diff --git a/.eslintrc.js b/.eslintrc.js index f5af7a6c4a..3945387128 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,7 +31,13 @@ module.exports = { "linebreak-style": [2, "unix"], "import/no-unresolved": [ 2, - { ignore: ["^@theme", "^@docusaurus/plugin-content-docs/client"] }, + { + ignore: [ + "^@theme", + "^@docusaurus/plugin-content-docs/client", + "astro:content", + ], + }, ], "import/extensions": 0, "import/no-extraneous-dependencies": 0, diff --git a/.gitignore b/.gitignore index 0efb134844..996ece3364 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # IDE .idea +# Astro +.astro + # Dependencies node_modules diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000000..ac6d11eb53 --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,166 @@ +// @ts-check +import { defineConfig } from "astro/config"; +import starlight from "@astrojs/starlight"; +import remarkHeaderId from "remark-heading-id"; +import starlightLlmsTxt from "starlight-llms-txt"; + +// https://astro.build/config +export default defineConfig({ + outDir: "./build", + site: "https://fsd.how", + markdown: { + remarkPlugins: [remarkHeaderId], + }, + integrations: [ + starlight({ + title: "Feature-Sliced Design", + favicon: "./static/img/favicon/adaptive.svg", + defaultLocale: "root", + customCss: ["./src/styles/custom.css"], + logo: { + src: "./static/img/brand/logo-primary.png", + replacesTitle: true, + }, + plugins: [starlightLlmsTxt()], + locales: { + root: { + label: "English", + lang: "en", + }, + ru: { + label: "Русский", + }, + uz: { + label: "O'zbekcha", + }, + kr: { + label: "한국어", + lang: "ko", + }, + ja: { + label: "日本語", + }, + vi: { + label: "Tiếng Việt", + }, + zh: { + label: "中文", + }, + }, + social: [ + { + icon: "github", + label: "GitHub", + href: "https://github.com/feature-sliced/documentation", + }, + { + icon: "discord", + label: "Discord", + href: "https://discord.gg/S8MzWTUsmp", + }, + ], + sidebar: [ + { + label: "🚀 Get Started", + translations: { + ru: "🚀 Начало работы", + ja: "🚀 はじめに", + }, + autogenerate: { directory: "docs/get-started" }, + }, + { + label: "🎯 Guides", + translations: { + ru: "🎯 Гайды", + ja: "🎯 ガイド", + }, + items: [ + { + label: "Examples", + translations: { + ru: "Примеры", + ja: "例", + }, + autogenerate: { directory: "docs/guides/examples" }, + }, + { + label: "Migration", + translations: { + ru: "Миграция", + ja: "移行", + uz: "Migratsiya", + }, + autogenerate: { + directory: "docs/guides/migration", + }, + }, + { + label: "Tech", + translations: { + ru: "Технологии", + ja: "技術", + uz: "Texnologiya", + }, + autogenerate: { directory: "docs/guides/tech" }, + }, + { + label: "Code smells & Issues", + translations: { + ru: "Известные проблемы", + ja: "コード臭いと問題", + uz: "Muammolar", + }, + autogenerate: { directory: "docs/guides/issues" }, + }, + ], + }, + { + label: "📚 Reference", + translations: { + ru: "📚 Справочник", + ja: "📚 参考書", + }, + autogenerate: { directory: "docs/reference" }, + }, + { + label: "🍰 About", + translations: { + ru: "🍰 О нас", + ja: "🍰 メソッドについて", + }, + items: [ + { + slug: "docs/about/mission", + }, + { + slug: "docs/about/motivation", + }, + { + slug: "docs/about/alternatives", + }, + { + label: "Understanding", + translations: { + ru: "Понимание", + ja: "理解", + }, + autogenerate: { + directory: "docs/about/understanding", + }, + collapsed: true, + }, + { + label: "Promote", + translations: { + ru: "Продвижение", + ja: "プロモート", + }, + autogenerate: { directory: "docs/about/promote" }, + collapsed: true, + }, + ], + }, + ], + }), + ], +}); diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/issues/cross-imports.mdx b/i18n/en/docusaurus-plugin-content-docs/current/guides/issues/cross-imports.mdx index 1e29ae65fd..b6dfa6eaec 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/guides/issues/cross-imports.mdx +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/issues/cross-imports.mdx @@ -1,18 +1,263 @@ --- sidebar_position: 4 -sidebar_class_name: sidebar-item--wip pagination_next: reference/layers --- -import WIP from '@site/src/shared/ui/wip/tmpl.mdx' -# Cross-imports +# Cross-import - +A **cross-import** is an import **between different slices within the same layer**. -> Cross-imports appear when the layer or abstraction begins to take too much responsibility than it should. That is why the methodology identifies new layers that allow you to uncouple these cross-imports +For example: +- importing `features/product` from `features/cart` +- importing `widgets/sidebar` from `widgets/header` -## See also +Cross-imports are a code smell: a warning sign that slices are becoming coupled. In some situations they may be hard to avoid, but they should always be deliberate and either documented or shared within the team/project. + +:::note +The `shared` and `app` layers do not have the concept of a slice, so imports *within* those layers are **not** considered cross-imports. +::: + +## Why is this a code smell? + +Cross-imports are not just a matter of style—they are generally considered a **code smell** because they blur the boundaries between domains and introduce implicit dependencies. + +Consider a case where the `cart` slice directly depends on `product` business logic. At first glance, this might seem convenient. However, this creates several problems: + +1. **Unclear ownership and responsibility.** When `cart` imports from `product`, it becomes unclear which slice "owns" the shared logic. If the `product` team changes their internal implementation, they might unknowingly break `cart`. This ambiguity makes it harder to reason about the codebase and assign responsibility for bugs or features. + +2. **Reduced isolation and testability.** One of the main benefits of sliced architecture is that each slice can be developed, tested, and deployed independently. Cross-imports break this isolation—testing `cart` now requires setting up `product` as well, and changes in one slice can cause unexpected test failures in another. + +3. **Increased cognitive load.** Working on `cart` also requires accounting for how `product` is structured and how it behaves. As cross-imports accumulate, tracing the impact of a change requires following more code across slice boundaries, and even small edits demand more context to be held in mind. + +4. **Path to circular dependencies.** Cross-imports often start as one-way dependencies but can evolve into bidirectional ones (A imports B, B imports A). This tends to lock slices together, making dependencies harder to untangle and increasing refactoring cost over time. + +The purpose of clear domain boundaries is to keep each slice focused and changeable within its own responsibility. When dependencies are loose, it becomes easier to predict the impact of a change and to keep review and testing scope contained. Cross-imports weaken this separation, expanding the impact of changes and increasing refactoring cost over time—this is why they are treated as a code smell worth addressing. + +In the sections below, we outline how these issues typically appear in real projects and what strategies you can use to address them. + +## Entities layer cross-imports + +Cross-imports in `entities` are often caused by splitting entities too granularly. Before reaching for `@x`, consider whether the boundaries should be merged instead. +Some teams use `@x` as a dedicated cross-import surface for `entities`, but it should be treated as a **last resort** — a **necessary compromise**, not a recommended approach. + +Think of `@x` as an explicit gateway for unavoidable domain references—not a general-purpose reuse mechanism. Overuse tends to lock entity boundaries together and makes refactoring more costly over time. + +For details about `@x`, see the [Public API documentation](/docs/reference/public-api). + +For concrete examples of cross-references between business entities, see: +- [Types guide — Business entities and their cross-references](/docs/guides/examples/types#business-entities-and-their-cross-references) +- [Layers reference — Entities](/docs/reference/layers#entities) + +## Features and widgets: Multiple strategies + +In the `features` and `widgets` layers, it's usually more realistic to say there are **multiple strategies** for handling cross-imports, rather than declaring them **always forbidden**. This section focuses less on code and more on the **patterns** you can choose from depending on your team and product context. + +### Strategy A: Slice merge + +If two slices are not truly independent and they are always changed together, merge them into a single larger slice. + +Example (before): +- `features/profile` +- `features/profileSettings` + +If these keep cross-importing each other and effectively move as one unit, they are likely one feature in practice. In that case, merging into `features/profile` is often the simpler and cleaner choice. + +### Strategy B: Push shared domain flows down into `entities` (domain-only) + +If multiple features share a domain-level flow, move that flow into a domain slice inside `entities` (for example, `entities/session`). + +Key principles: +- `entities` contains **domain types and domain logic only** +- UI remains in `features` / `widgets` +- features import and use the domain logic from `entities` + +For example, if both `features/auth` and `features/profile` need session validation, place session-related domain functions in `entities/session` and reuse them from both features. + +For more guidance, see [Layers reference — Entities](/docs/reference/layers#entities). + +### Strategy C: Compose from an upper layer (pages / app) + +Instead of connecting slices within the same layer via cross-imports, compose them at a higher level (`pages` / `app`). This approach uses **Inversion of Control (IoC)** patterns—rather than slices knowing about each other, an upper layer assembles and connects them. + +Common IoC techniques include: +- **Render props (React)**: Pass components or render functions as props +- **Slots (Vue)**: Use named slots to inject content from parent components +- **Dependency injection**: Pass dependencies through props or context + +#### Basic composition example (React): + +```tsx title="features/userProfile/index.ts" +export { UserProfilePanel } from './ui/UserProfilePanel'; +``` + +```tsx title="features/activityFeed/index.ts" +export { ActivityFeed } from './ui/ActivityFeed'; +``` + +```tsx title="pages/UserDashboardPage.tsx" +import React from 'react'; +import { UserProfilePanel } from '@/features/userProfile'; +import { ActivityFeed } from '@/features/activityFeed'; + +export function UserDashboardPage() { + return ( +
+ + +
+ ); +} +``` + +With this structure, `features/userProfile` and `features/activityFeed` do not know about each other. `pages/UserDashboardPage` composes them to build the full screen. + +#### Render props example (React): + +When one feature needs to render content from another, use render props to invert the dependency: + +```tsx title="features/commentList/ui/CommentList.tsx" +interface CommentListProps { + comments: Comment[]; + renderUserAvatar?: (userId: string) => React.ReactNode; +} + +export function CommentList({ comments, renderUserAvatar }: CommentListProps) { + return ( + + ); +} +``` + +```tsx title="pages/PostPage.tsx" +import { CommentList } from '@/features/commentList'; +import { UserAvatar } from '@/features/userProfile'; + +export function PostPage() { + return ( + } + /> + ); +} +``` + +Now `CommentList` doesn't import from `userProfile`—the page injects the avatar component. + +#### Slots example (Vue): + +Vue's slot system provides a natural way to compose features without cross-imports: + +```vue title="features/commentList/ui/CommentList.vue" + + + +``` + +```vue title="pages/PostPage.vue" + + + +``` + +The `CommentList` feature remains independent of `userProfile`. The page uses slots to compose them together. + +### Strategy D: Cross-feature reuse only via Public API + +If the above strategies don't fit your case and cross-feature reuse is truly unavoidable, allow it only through an explicit **Public API** (for example: exported hooks or UI components). Avoid directly accessing another slice's `store`/`model` or internal implementation details. + +Unlike strategies A-C which aim to eliminate cross-imports, this strategy accepts them while minimizing the risks through strict boundaries. + +#### Example code: + +```tsx title="features/auth/index.ts" + +export { useAuth } from './model/useAuth'; +export { AuthButton } from './ui/AuthButton'; +``` + +```tsx title="features/profile/ui/ProfileMenu.tsx" + +import React from 'react'; +import { useAuth, AuthButton } from '@/features/auth'; + +export function ProfileMenu() { + const { user } = useAuth(); + + if (!user) { + return ; + } + + return
{user.name}
; +} +``` + +For example, prevent `features/profile` from importing from paths like `features/auth/model/internal/*`. Restrict usage to only what `features/auth` explicitly exposes as its Public API. + +## When should cross-imports be treated as a problem? + +After reviewing these strategies, a natural question is: + +> When is a cross-import acceptable to keep, and when should it be treated as a code smell and refactored? + +Common warning signs: +- directly depending on another slice's store/model/business logic +- deep imports into another slice's internal files +- **bidirectional dependencies** (A imports B, and B imports A) +- changes in one slice frequently breaking another slice +- flows that should be composed in `pages` / `app`, but are forced into cross-imports within the same layer + +When you see these signals, treat the cross-import as a **code smell** and consider applying at least one of the strategies above. + +## How strict you are is a team/project decision + +How strictly to enforce these rules depends on the team and project. + +For example: +- In **early-stage products** with heavy experimentation, allowing some cross-imports may be a pragmatic speed trade-off. +- In **long-lived or regulated systems** (for example, fintech or large-scale services), stricter boundaries often pay off in maintainability and stability. + +Cross-imports are not an absolute prohibition here. They are dependencies that are **generally best avoided**, but sometimes used intentionally. + +If you do introduce a cross-import: +- treat it as a deliberate architectural choice +- document the reasoning +- revisit it periodically as the system evolves + +Teams should align on: +- what strictness level they want +- how to reflect it in lint rules, code review, and documentation +- when and how to reevaluate existing cross-imports over time + +## References - [(Thread) About the supposed inevitability of cross-ports](https://t.me/feature_sliced/4515) - [(Thread) About resolving cross-ports in entities](https://t.me/feature_sliced/3678) diff --git a/i18n/en/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx b/i18n/en/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx index 1ba2b6e2e9..ed1de2d012 100644 --- a/i18n/en/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx +++ b/i18n/en/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx @@ -1,97 +1,140 @@ --- sidebar_position: 2 -sidebar_class_name: sidebar-item--wip --- -import WIP from '@site/src/shared/ui/wip/tmpl.mdx' - -# Desegemented - - - -## Situation - -Very often, there is a situation on projects when modules related to a specific domain from the subject area are unnecessarily desegmented and scattered around the project - -```sh -├── components/ -| ├── DeliveryCard -| ├── DeliveryChoice -| ├── RegionSelect -| ├── UserAvatar -├── actions/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── epics/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── constants/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── helpers/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── entities/ -| ├── delivery/ -| | ├── getters.js -| | ├── selectors.js -| ├── region/ -| ├── user/ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Desegmentation + +Desegmentation (also known as horizontal slicing or packaging by layer) is a code organization pattern where files are grouped by their technical roles rather than by the business domains they serve. This means code with similar technical functions is stored in the same place, regardless of the business logic it handles. + +This approach is popular in meta-frameworks like Next and Nuxt due to its simplicity, as it's easy to get started and enables features like auto-imports and file-based routing: + +- 📂 app + - 📂 components + - 📄 DeliveryCard.jsx + - 📄 DeliveryChoice.jsx + - 📄 RegionSelect.jsx + - 📄 UserAvatar.jsx + - 📂 actions + - 📄 delivery.js + - 📄 region.js + - 📄 user.js + - 📂 composables + - 📄 delivery.js + - 📄 region.js + - 📄 user.js + - 📂 constants + - 📄 delivery.js + - 📄 region.js + - 📄 user.js + - 📂 utils + - 📄 delivery.js + - 📄 region.js + - 📄 user.js + - 📂 stores + - 📂 delivery + - 📄 getters.js + - 📄 actions.js + +This pattern also occurs in FSD codebases, in the form of generic folders: + +- 📂 features + - 📂 delivery + - 📂 ui + - 📂 components ⚠️ +- 📂 entities + - 📂 recommendations + - 📂 utils ⚠️ + +Files can also be a source of desegmentation. Files like `types.ts` can aggregate multiple domains, complicating navigation and future refactoring, especially in layers like `pages` or `widgets`: + +- 📂 pages + - 📂 delivery + - 📄 index.ts + - 📂 ui + - 📄 DeliveryCard.tsx + - 📄 DeliveryChoice.tsx + - 📄 UserAvatar.tsx + - 📂 model + - 📄 types.ts ⚠️ + - 📄 utils.ts ⚠️ + - 📂 api + - 📄 endpoints.ts ⚠️ + + + + +```ts title="pages/delivery/model/types.ts" +// ❌ Bad: Mixed business domains in generic file +export interface DeliveryOption { + id: string; + name: string; + price: number; +} + +export interface UserInfo { + id: string; + name: string; + avatar: string; +} ``` + + + +```ts title="pages/delivery/model/utils.ts" +// ❌ Bad: Mixed business domains in generic file +export function formatDeliveryPrice(price: number) { + return `$${price.toFixed(2)}`; +} + +export function getUserInitials(name: string) { + return name.split(' ').map(n => n[0]).join(''); +} +``` + + + +```ts title="pages/delivery/api/endpoints.ts" +// ❌ Bad: Mixed business domains in generic file +export async function fetchDeliveryOptions() { /* ... */ } +export async function fetchUserInfo() { /* ... */ } +``` + + + +## The Problem -## Problem +While this structure is easy to start with, it can lead to scalability issues in larger projects: -The problem manifests itself at least in violation of the principle of * * High Cohesion** and excessive stretching * * of the axis of changes** +- Low Cohesion: Modifying a single feature often requires editing files in multiple large folders, such as `pages`, `components`, and `stores`. -## If you ignore it +- Tight Coupling: Components can have unexpected dependencies, leading to complex and tangled dependency chains. -- If necessary, touch on the logic, for example, delivery - we will have to keep in mind that it lies in several places and touch on several places in the code-which unnecessarily stretches our * * Axis of changes** -- If we need to study the logic of the user, we will have to go through the whole project to study in detail * * actions, epics, constants, entities, components** - instead of it lying in one place -- Implicit connections and the uncontrollability of a growing subject area -- With this approach, the eye is very often blurred and you may not notice how we "create constants for the sake of constants", creating a dump in the corresponding project directory +- Difficult Refactoring: It requires additional effort to manually extract code related to a specific domain. ## Solution -Place all modules related to a specific domain/user case - directly next to each other - -So that when studying a particular module, all its components lie side by side, and are not scattered around the project - -> It also increases the discoverability and clarity of the code base and the relationships between modules - -```diff -- ├── components/ -- | ├── DeliveryCard -- | ├── DeliveryChoice -- | ├── RegionSelect -- | ├── UserAvatar -- ├── actions/ -- | ├── delivery.js -- | ├── region.js -- | ├── user.js -- ├── epics/{...} -- ├── constants/{...} -- ├── helpers/{...} - ├── entities/ - | ├── delivery/ -+ | | ├── ui/ # ~ components/ -+ | | | ├── card.js -+ | | | ├── choice.js -+ | | ├── model/ -+ | | | ├── actions.js -+ | | | ├── constants.js -+ | | | ├── epics.js -+ | | | ├── getters.js -+ | | | ├── selectors.js -+ | | ├── lib/ # ~ helpers - | ├── region/ - | ├── user/ -``` +Group all code that relates to a specific domain in one place. + +Avoid generic folder names such as `types`, `components`, `utils`, as well as generic file names like `types.ts`, `utils.ts`, or `helpers.ts`. Instead, use names that directly reflect the domain they represent. + +Avoid generic file names like `types.ts` if possible, especially in slices with multiple domains: + +- 📂 pages + - 📂 delivery + - 📄 index.tsx + - 📂 ui + - 📄 DeliveryPage.tsx + - 📄 DeliveryCard.tsx + - 📄 DeliveryChoice.tsx + - 📄 UserInfo.tsx + - 📂 model + - 📄 delivery.ts + - 📄 user.ts -## See also +## See Also * [(Article) About Low Coupling and High Cohesion clearly](https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/) * [(Article) Low Coupling and High Cohesion. The Law of Demeter](https://medium.com/german-gorelkin/low-coupling-high-cohesion-d36369fb1be9) diff --git a/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx b/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx deleted file mode 100644 index 28f3bddc42..0000000000 --- a/i18n/ja/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -sidebar_position: 2 -sidebar_class_name: sidebar-item--wip ---- - -import WIP from '@site/src/shared/ui/wip/tmpl.mdx' - -# デセグメンテーション - - - -## 状況 {#situation} - -プロジェクトでは、特定のドメインに関連するモジュールが過度にデセグメント化され、プロジェクト全体に散らばっていることがよくあります。 - -```sh -├── components/ -| ├── DeliveryCard -| ├── DeliveryChoice -| ├── RegionSelect -| ├── UserAvatar -├── actions/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── epics/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── constants/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── helpers/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── entities/ -| ├── delivery/ -| | ├── getters.js -| | ├── selectors.js -| ├── region/ -| ├── user/ -``` - - -## 問題 {#problem} - -問題は、**高い凝集性**の原則の違反と、**変更の軸**の過度な拡張として現れます。 - -## 無視する場合 {#if-you-ignore-it} - -- 例えば、配達に関するロジックに触れる必要がある場合、このロジックが複数の箇所に分散していることを考慮しなければならず、コード内で複数の箇所に触れる必要がある。これにより、**変更の軸**が過度に引き伸ばされる -- ユーザーに関するロジックを調べる必要がある場合、**actions、epics、constants、entities、components**の詳細を調べるためにプロジェクト全体を巡回しなければならない -- 暗黙関係と拡大するドメインの制御不能 - - このアプローチでは、視野が狭くなり、「定数のための定数」を作成し、プロジェクトの該当ディレクトリをごちゃごちゃさせてしまうことに気づかないことがよくある - -## 解決策 {#solution} - -特定のドメイン/ユースケースに関連するすべてのモジュールを近くに配置することです。 - -これは特定のモジュールを調べる際に、そのすべての構成要素がプロジェクト全体に散らばらず、近くに配置されるためです。 - -> これにより、コードベースとモジュール間の関係の発見しやすさと明確さが向上します。 - -```diff -- ├── components/ -- | ├── DeliveryCard -- | ├── DeliveryChoice -- | ├── RegionSelect -- | ├── UserAvatar -- ├── actions/ -- | ├── delivery.js -- | ├── region.js -- | ├── user.js -- ├── epics/{...} -- ├── constants/{...} -- ├── helpers/{...} - ├── entities/ - | ├── delivery/ -+ | | ├── ui/ # ~ components/ -+ | | | ├── card.js -+ | | | ├── choice.js -+ | | ├── model/ -+ | | | ├── actions.js -+ | | | ├── constants.js -+ | | | ├── epics.js -+ | | | ├── getters.js -+ | | | ├── selectors.js -+ | | ├── lib/ # ~ helpers - | ├── region/ - | ├── user/ -``` - -## 参照 {#see-also} -* [(記事) Cohesion and Coupling: the difference](https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/) diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx b/i18n/kr/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx deleted file mode 100644 index e0cc2da21f..0000000000 --- a/i18n/kr/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx +++ /dev/null @@ -1,97 +0,0 @@ ---- -sidebar_position: 2 -sidebar_class_name: sidebar-item--wip ---- - -import WIP from '@site/src/shared/ui/wip/tmpl.mdx' - -# Desegmentation - - - -## 상황 - -프로젝트에서 동일한 도메인의 모듈들이 서로 연관되어 있음에도 불구하고, 프로젝트 전체에 불필요하게 분산되어 있는 경우가 많습니다. - -```sh -├── components/ -| ├── DeliveryCard -| ├── DeliveryChoice -| ├── RegionSelect -| ├── UserAvatar -├── actions/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── epics/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── constants/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── helpers/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── entities/ -| ├── delivery/ -| | ├── getters.js -| | ├── selectors.js -| ├── region/ -| ├── user/ -``` - -## 문제점 - -이는 높은 응집도 원칙을 위반하며, **Changes Axis의 과도한 확장**을 초래합니다. - -## 무시했을 때의 결과 - -- delivery 관련 로직 수정 시 여러 위치의 코드를 찾아 수정해야 하며, 이는 **Changes Axis를 불필요하게 확장**합니다 -- user 관련 로직을 이해하려면 프로젝트 전반의 **actions, epics, constants, entities, components**를 모두 찾아봐야 합니다 -- 암묵적 연결로 인해 도메인 영역이 비대해지고 관리가 어려워집니다 -- 불필요한 파일들이 쌓여 문제 인식이 어려워집니다 - -## 해결 방안 - -도메인이나 use case와 관련된 모듈들을 한 곳에 모아 배치합니다. - -이를 통해 모듈 학습이나 수정 시 필요한 모든 요소를 쉽게 찾을 수 있습니다. - -> 이 접근은 코드베이스의 탐색성과 가독성을 높이고, 모듈 간 관계를 더 명확하게 보여줍니다. - -```diff -- ├── components/ -- | ├── DeliveryCard -- | ├── DeliveryChoice -- | ├── RegionSelect -- | ├── UserAvatar -- ├── actions/ -- | ├── delivery.js -- | ├── region.js -- | ├── user.js -- ├── epics/{...} -- ├── constants/{...} -- ├── helpers/{...} - ├── entities/ - | ├── delivery/ -+ | | ├── ui/ # ~ components/ -+ | | | ├── card.js -+ | | | ├── choice.js -+ | | ├── model/ -+ | | | ├── actions.js -+ | | | ├── constants.js -+ | | | ├── epics.js -+ | | | ├── getters.js -+ | | | ├── selectors.js -+ | | ├── lib/ # ~ helpers - | ├── region/ - | ├── user/ -``` - -## 참고 자료 - -* [(아티클) Coupling과 Cohesion의 명확한 이해](https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/) -* [(아티클) Coupling, Cohesion과 Law of Demeter](https://medium.com/german-gorelkin/low-coupling-high-cohesion-d36369fb1be9) \ No newline at end of file diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-electron.mdx b/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-electron.mdx index 622c977d21..a02d5797c9 100644 --- a/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-electron.mdx +++ b/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-electron.mdx @@ -1,7 +1,7 @@ --- sidebar_position: 10 --- -# Usage with Next.js +# Usage with Electron Electron 애플리케이션은 역할이 다른 여러 **프로세스**(Main, Renderer, Preload)로 구성됩니다. 따라서 FSD를 적용하려면 Electron 특성에 맞게 구조를 조정해야 합니다. diff --git a/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-nextjs.mdx b/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-nextjs.mdx index 7daf5d54e3..5ae2f831ea 100644 --- a/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-nextjs.mdx +++ b/i18n/kr/docusaurus-plugin-content-docs/current/guides/tech/with-nextjs.mdx @@ -1,7 +1,7 @@ --- sidebar_position: 10 --- -# Usage with Electron +# Usage with Next.js NextJS 프로젝트에도 FSD 아키텍처를 적용할 수 있지만, 구조적 차이로 두 가지 충돌이 발생합니다. diff --git a/i18n/ru/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx b/i18n/ru/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx deleted file mode 100644 index 3da4f36d17..0000000000 --- a/i18n/ru/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -sidebar_position: 2 -sidebar_class_name: sidebar-item--wip ---- - -import WIP from '@site/src/shared/ui/wip/tmpl.mdx' - -# Десегментация - - - -## Ситуация {#situation} - -Очень часто на проектах встречается ситуация, когда модули, относящиеся к конкретному домену из предметной области, излишне десегментированы и раскиданы по проекту - -```sh -├── components/ -| ├── DeliveryCard -| ├── DeliveryChoice -| ├── RegionSelect -| ├── UserAvatar -├── actions/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── epics/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── constants/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── helpers/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── entities/ -| ├── delivery/ -| | ├── getters.js -| | ├── selectors.js -| ├── region/ -| ├── user/ -``` - -## Проблема {#problem} - -Проблема проявляется как минимум в нарушении принципа **High Cohesion** и излишнего растягивания **оси изменений** - -## Если проигнорировать {#if-you-ignore-it} - -- При необходимости затронуть логику, например, доставки - нам придется держать в голове, что она лежит в нескольких местах и затронуть в коде именно несколько мест - что излишне растягивает нашу **Ось изменений** -- Если нам надо изучить логику по пользователю, нам придется пройтись по всему-всему проекту, чтобы изучить в деталях **actions, epics, constants, entities, components** - вместо того, чтобы это лежало в одном месте -- Неявные связи и неконтролируемость растущей предметной области - - При таком подходе очень часто замыливается глаз и можно не заметить, как мы "создаем константы ради констант", создавая свалку в соответствующей директории проекта - -## Решение {#solution} - -Располагать все модули, относящиеся к конкретному домену/юзкейсу - непосредственно рядом - -Чтобы при изучении конкретного модуля - все его составляющие лежали рядом, а не были раскиданы по проекту - -> Это также повышает discoverability и явность кодовой базы и связей между модулями - -```diff -- ├── components/ -- | ├── DeliveryCard -- | ├── DeliveryChoice -- | ├── RegionSelect -- | ├── UserAvatar -- ├── actions/ -- | ├── delivery.js -- | ├── region.js -- | ├── user.js -- ├── epics/{...} -- ├── constants/{...} -- ├── helpers/{...} - ├── entities/ - | ├── delivery/ -+ | | ├── ui/ # ~ components/ -+ | | | ├── card.js -+ | | | ├── choice.js -+ | | ├── model/ -+ | | | ├── actions.js -+ | | | ├── constants.js -+ | | | ├── epics.js -+ | | | ├── getters.js -+ | | | ├── selectors.js -+ | | ├── lib/ # ~ helpers - | ├── region/ - | ├── user/ -``` - -## См. также {#see-also} -* [(Статья) Про Low Coupling и High Cohesion наглядно](https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/) -* [(Статья) Low Coupling и High Cohesion. Закон Деметры](https://medium.com/german-gorelkin/low-coupling-high-cohesion-d36369fb1be9) diff --git a/i18n/vi/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx b/i18n/vi/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx deleted file mode 100644 index 736bbb7f4a..0000000000 --- a/i18n/vi/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx +++ /dev/null @@ -1,97 +0,0 @@ ---- -sidebar_position: 2 -sidebar_class_name: sidebar-item--wip ---- - -import WIP from '@site/src/shared/ui/wip/tmpl.mdx' - -# Desegmented - - - -## Tình huống - -Rất thường xuyên xảy ra tình huống trên các project khi các module liên quan đến một domain cụ thể từ lĩnh vực chủ đề bị phân tách không cần thiết và nằm rải rác khắp project - -```sh -├── components/ -| ├── DeliveryCard -| ├── DeliveryChoice -| ├── RegionSelect -| ├── UserAvatar -├── actions/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── epics/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── constants/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── helpers/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── entities/ -| ├── delivery/ -| | ├── getters.js -| | ├── selectors.js -| ├── region/ -| ├── user/ -``` - -## Vấn đề - -Vấn đề thể hiện ít nhất là vi phạm nguyên tắc **High Cohesion** và kéo dài quá mức **trục thay đổi** - -## Nếu bỏ qua - -- Nếu cần chạm vào logic, ví dụ delivery - chúng ta sẽ phải nhớ rằng nó nằm ở nhiều nơi và phải chạm vào nhiều chỗ trong code - điều này kéo dài không cần thiết **Trục thay đổi** của chúng ta -- Nếu cần nghiên cứu logic của user, chúng ta sẽ phải đi khắp project để tìm hiểu chi tiết **actions, epics, constants, entities, components** - thay vì để nó nằm ở một chỗ -- Các liên kết ngầm và sự mất kiểm soát của domain area đang phát triển -- Với cách tiếp cận này, mắt rất dễ bị mờ đi và bạn có thể không nhận ra khi chúng ta "tạo constants vì constants", tạo ra một đống rác trong thư mục tương ứng của project - -## Giải pháp - -Đặt tất cả các module liên quan đến một domain/use case cụ thể - ngay cạnh nhau - -Để khi nghiên cứu một module cụ thể, tất cả các thành phần của nó nằm cạnh nhau, không bị rải rác khắp project - -> Điều này cũng tăng khả năng khám phá và sự rõ ràng của code base và mối quan hệ giữa các module - -```diff -- ├── components/ -- | ├── DeliveryCard -- | ├── DeliveryChoice -- | ├── RegionSelect -- | ├── UserAvatar -- ├── actions/ -- | ├── delivery.js -- | ├── region.js -- | ├── user.js -- ├── epics/{...} -- ├── constants/{...} -- ├── helpers/{...} - ├── entities/ - | ├── delivery/ -+ | | ├── ui/ # ~ components/ -+ | | | ├── card.js -+ | | | ├── choice.js -+ | | ├── model/ -+ | | | ├── actions.js -+ | | | ├── constants.js -+ | | | ├── epics.js -+ | | | ├── getters.js -+ | | | ├── selectors.js -+ | | ├── lib/ # ~ helpers - | ├── region/ - | ├── user/ -``` - -## Xem thêm - -* [(Article) Về Low Coupling và High Cohesion một cách rõ ràng](https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/) -* [(Article) Low Coupling và High Cohesion. Law of Demeter](https://medium.com/german-gorelkin/low-coupling-high-cohesion-d36369fb1be9) \ No newline at end of file diff --git a/i18n/zh/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx b/i18n/zh/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx deleted file mode 100644 index 9b011b03a3..0000000000 --- a/i18n/zh/docusaurus-plugin-content-docs/current/guides/issues/desegmented.mdx +++ /dev/null @@ -1,97 +0,0 @@ ---- -sidebar_position: 2 -sidebar_class_name: sidebar-item--wip ---- - -import WIP from '@site/src/shared/ui/wip/tmpl.mdx' - -# 去分段化 - - - -## 情况 - -在项目中经常出现这样的情况:与主题领域中特定域相关的模块被不必要地去分段化并分散在项目周围 - -```sh -├── components/ -| ├── DeliveryCard -| ├── DeliveryChoice -| ├── RegionSelect -| ├── UserAvatar -├── actions/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── epics/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── constants/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── helpers/ -| ├── delivery.js -| ├── region.js -| ├── user.js -├── entities/ -| ├── delivery/ -| | ├── getters.js -| | ├── selectors.js -| ├── region/ -| ├── user/ -``` - -## 问题 - -该问题至少表现为违反了**高内聚**原则和过度拉伸**变更轴** - -## 如果您忽略它 - -- 如果需要涉及逻辑,例如交付 - 我们必须记住它位于多个地方,并涉及代码中的多个地方 - 这不必要地拉伸了我们的**变更轴** -- 如果我们需要研究用户的逻辑,我们将不得不遍历整个项目来详细研究**actions、epics、constants、entities、components** - 而不是将其放在一个地方 -- 隐式连接和不断增长的主题领域的不可控性 -- 使用这种方法,眼睛经常会模糊,您可能不会注意到我们如何"为了常量而创建常量",在相应的项目目录中创建垃圾场 - -## 解决方案 - -将与特定域/用户案例相关的所有模块 - 直接彼此相邻放置 - -这样,在研究特定模块时,其所有组件都并排放置,而不是分散在项目周围 - -> 它还增加了代码库的可发现性和清晰度以及模块之间的关系 - -```diff -- ├── components/ -- | ├── DeliveryCard -- | ├── DeliveryChoice -- | ├── RegionSelect -- | ├── UserAvatar -- ├── actions/ -- | ├── delivery.js -- | ├── region.js -- | ├── user.js -- ├── epics/{...} -- ├── constants/{...} -- ├── helpers/{...} - ├── entities/ - | ├── delivery/ -+ | | ├── ui/ # ~ components/ -+ | | | ├── card.js -+ | | | ├── choice.js -+ | | ├── model/ -+ | | | ├── actions.js -+ | | | ├── constants.js -+ | | | ├── epics.js -+ | | | ├── getters.js -+ | | | ├── selectors.js -+ | | ├── lib/ # ~ helpers - | ├── region/ - | ├── user/ -``` - -## See also - -* [(Article) About Low Coupling and High Cohesion clearly](https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/) -* [(Article) Low Coupling and High Cohesion. The Law of Demeter](https://medium.com/german-gorelkin/low-coupling-high-cohesion-d36369fb1be9) diff --git a/package.json b/package.json index d94dc1c1ef..10182964bb 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "2.1.0", "private": true, "scripts": { + "dev": "astro dev", "docusaurus": "docusaurus", "start": "docusaurus start", "start:ru": "docusaurus start --locale ru", @@ -12,7 +13,7 @@ "start:ja": "docusaurus start --locale ja", "start:zh": "docusaurus start --locale zh", "start:vi": "docusaurus start --locale vi", - "build": "docusaurus build", + "build": "astro build", "swizzle": "docusaurus swizzle", "format": "prettier --cache --experimental-cli --write .", "test": "pnpm run test:lint && pnpm run build", @@ -49,6 +50,7 @@ "react-fast-marquee": "^1.6.5" }, "devDependencies": { + "@astrojs/starlight": "^0.37.4", "@babel/eslint-parser": "^7.28.4", "@docusaurus/module-type-aliases": "^3.9.1", "@docusaurus/theme-classic": "^3.9.1", @@ -63,12 +65,16 @@ "@types/react-dom": "^19.1.9", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", + "astro": "^5.16.16", "docusaurus-plugin-sass": "^0.2.6", "eslint": "^7.32.0", "eslint-config-prettier": "^9.1.2", "eslint-import-resolver-alias": "1.1.2", "prettier": "^3.6.2", + "remark-heading-id": "^1.0.1", "sass": "^1.93.2", + "sharp": "^0.34.5", + "starlight-llms-txt": "^0.7.0", "stylelint": "^16.24.0", "stylelint-config-recess-order": "^5.1.1", "stylelint-config-recommended": "^14.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b59b35c602..a7982e2353 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,22 +13,22 @@ importers: version: 6.0.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/core': specifier: ^3.9.1 - version: 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + version: 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/faster': specifier: ^3.9.1 version: 3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)) '@docusaurus/plugin-client-redirects': specifier: ^3.9.1 - version: 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + version: 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/plugin-content-docs': specifier: ^3.9.1 - version: 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + version: 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/plugin-ideal-image': specifier: ^3.9.1 - version: 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + version: 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/preset-classic': specifier: ^3.9.1 - version: 3.9.1(@algolia/client-search@5.38.0)(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(search-insights@2.17.3)(typescript@5.9.2) + version: 3.9.1(@algolia/client-search@5.38.0)(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(search-insights@2.17.3)(typescript@5.9.2) '@fontsource-variable/overpass': specifier: ^5.2.8 version: 5.2.8 @@ -37,7 +37,7 @@ importers: version: 3.1.1(@types/react@19.1.13)(react@19.1.1) '@signalwire/docusaurus-plugin-llms-txt': specifier: ^1.2.2 - version: 1.2.2(@docusaurus/core@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)) + version: 1.2.2(@docusaurus/core@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -66,6 +66,9 @@ importers: specifier: ^1.6.5 version: 1.6.5(react-dom@19.1.1(react@19.1.1))(react@19.1.1) devDependencies: + '@astrojs/starlight': + specifier: ^0.37.4 + version: 0.37.4(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2)) '@babel/eslint-parser': specifier: ^7.28.4 version: 7.28.4(@babel/core@7.28.4)(eslint@7.32.0) @@ -74,7 +77,7 @@ importers: version: 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/theme-classic': specifier: ^3.9.1 - version: 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + version: 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/tsconfig': specifier: ^3.9.1 version: 3.9.1 @@ -108,9 +111,12 @@ importers: '@typescript-eslint/parser': specifier: ^6.21.0 version: 6.21.0(eslint@7.32.0)(typescript@5.9.2) + astro: + specifier: ^5.16.16 + version: 5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2) docusaurus-plugin-sass: specifier: ^0.2.6 - version: 0.2.6(@docusaurus/core@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@rspack/core@1.5.7)(sass@1.93.2)(webpack@5.101.3(@swc/core@1.13.19)) + version: 0.2.6(@docusaurus/core@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@rspack/core@1.5.7)(sass@1.93.2)(webpack@5.101.3(@swc/core@1.13.19)) eslint: specifier: ^7.32.0 version: 7.32.0 @@ -123,9 +129,18 @@ importers: prettier: specifier: ^3.6.2 version: 3.6.2 + remark-heading-id: + specifier: ^1.0.1 + version: 1.0.1 sass: specifier: ^1.93.2 version: 1.93.2 + sharp: + specifier: ^0.34.5 + version: 0.34.5 + starlight-llms-txt: + specifier: ^0.7.0 + version: 0.7.0(@astrojs/starlight@0.37.4(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2)))(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2)) stylelint: specifier: ^16.24.0 version: 16.24.0(typescript@5.9.2) @@ -260,6 +275,37 @@ packages: react: '>=16.0.0' react-dom: '>=16.0.0' + '@astrojs/compiler@2.13.0': + resolution: {integrity: sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==} + + '@astrojs/internal-helpers@0.7.5': + resolution: {integrity: sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==} + + '@astrojs/markdown-remark@6.3.10': + resolution: {integrity: sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==} + + '@astrojs/mdx@4.3.13': + resolution: {integrity: sha512-IHDHVKz0JfKBy3//52JSiyWv089b7GVSChIXLrlUOoTLWowG3wr2/8hkaEgEyd/vysvNQvGk+QhysXpJW5ve6Q==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + peerDependencies: + astro: ^5.0.0 + + '@astrojs/prism@3.3.0': + resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/sitemap@3.7.0': + resolution: {integrity: sha512-+qxjUrz6Jcgh+D5VE1gKUJTA3pSthuPHe6Ao5JCxok794Lewx8hBFaWHtOnN0ntb2lfOf7gvOi9TefUswQ/ZVA==} + + '@astrojs/starlight@0.37.4': + resolution: {integrity: sha512-ygPGDgRd9nCcNgaYMNN7UeAMAkDOR1ibv3ps3xEz+cuvKG3CRLd19UwdB+Gyz1tbkyfjPWPkFKNhLwNybro8Tw==} + peerDependencies: + astro: ^5.5.0 + + '@astrojs/telemetry@3.3.0': + resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@babel/code-frame@7.12.11': resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==} @@ -361,6 +407,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -382,6 +432,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1': resolution: {integrity: sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==} engines: {node: '>=6.9.0'} @@ -844,6 +899,10 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + '@cacheable/memoize@2.0.2': resolution: {integrity: sha512-wPrr7FUiq3Qt4yQyda2/NcOLTJCFcQSU3Am2adP+WLy+sz93/fKTokVTHmtz+rjp4PD7ee0AEOeRVNN6IvIfsg==} @@ -853,6 +912,10 @@ packages: '@cacheable/utils@2.0.2': resolution: {integrity: sha512-JTFM3raFhVv8LH95T7YnZbf2YoE9wEtkPPStuRF9a6ExZ103hFvs+QyCuYJ6r0hA9wRtbzgZtwUCoDWxssZd4Q==} + '@capsizecss/unpack@4.0.0': + resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} + engines: {node: '>=18'} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -1133,6 +1196,10 @@ packages: peerDependencies: postcss: ^8.4 + '@ctrl/tinycolor@4.2.0': + resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} + engines: {node: '>=14'} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -1373,12 +1440,168 @@ packages: '@emnapi/core@1.5.0': resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} - '@emnapi/runtime@1.5.0': - resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1408,6 +1631,18 @@ packages: resolution: {integrity: sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==} engines: {node: ^10.12.0 || >=12.0.0} + '@expressive-code/core@0.41.6': + resolution: {integrity: sha512-FvJQP+hG0jWi/FLBSmvHInDqWR7jNANp9PUDjdMqSshHb0y7sxx3vHuoOr6SgXjWw+MGLqorZyPQ0aAlHEok6g==} + + '@expressive-code/plugin-frames@0.41.6': + resolution: {integrity: sha512-d+hkSYXIQot6fmYnOmWAM+7TNWRv/dhfjMsNq+mIZz8Tb4mPHOcgcfZeEM5dV9TDL0ioQNvtcqQNuzA1sRPjxg==} + + '@expressive-code/plugin-shiki@0.41.6': + resolution: {integrity: sha512-Y6zmKBmsIUtWTzdefqlzm/h9Zz0Rc4gNdt2GTIH7fhHH2I9+lDYCa27BDwuBhjqcos6uK81Aca9dLUC4wzN+ng==} + + '@expressive-code/plugin-text-markers@0.41.6': + resolution: {integrity: sha512-PBFa1wGyYzRExMDzBmAWC6/kdfG1oLn4pLpBeTfIRrALPjcGA/59HP3e7q9J0Smk4pC7U+lWkA2LHR8FYV8U7Q==} + '@fontsource-variable/overpass@5.2.8': resolution: {integrity: sha512-7/gP2f9ylVFYEhdyeHl6JSEcmNQ5QBdDs5BuT4V+FqSvkk9XjwdikmtTJuNTRWevMAhBkTUWf/dm68G2SbMdjQ==} @@ -1426,6 +1661,159 @@ packages: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} deprecated: Use @eslint/object-schema instead + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1548,6 +1936,42 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@pagefind/darwin-arm64@1.4.0': + resolution: {integrity: sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.4.0': + resolution: {integrity: sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A==} + cpu: [x64] + os: [darwin] + + '@pagefind/default-ui@1.4.0': + resolution: {integrity: sha512-wie82VWn3cnGEdIjh4YwNESyS1G6vRHwL6cNjy9CFgNnWW/PGRjsLq300xjVH5sfPFK3iK36UxvIBymtQIEiSQ==} + + '@pagefind/freebsd-x64@1.4.0': + resolution: {integrity: sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.4.0': + resolution: {integrity: sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.4.0': + resolution: {integrity: sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-x64@1.4.0': + resolution: {integrity: sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g==} + cpu: [x64] + os: [win32] + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -1657,6 +2081,153 @@ packages: react: '>=18.0.0' react-dom: '>=18.0.0' + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.57.0': + resolution: {integrity: sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.0': + resolution: {integrity: sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.0': + resolution: {integrity: sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.0': + resolution: {integrity: sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.0': + resolution: {integrity: sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.0': + resolution: {integrity: sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.0': + resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.57.0': + resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.57.0': + resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.57.0': + resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.57.0': + resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.57.0': + resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.57.0': + resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.57.0': + resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.57.0': + resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.57.0': + resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.57.0': + resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.57.0': + resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.57.0': + resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.57.0': + resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.0': + resolution: {integrity: sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.0': + resolution: {integrity: sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.0': + resolution: {integrity: sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.0': + resolution: {integrity: sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.0': + resolution: {integrity: sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==} + cpu: [x64] + os: [win32] + '@rspack/binding-darwin-arm64@1.5.7': resolution: {integrity: sha512-prQ/vgJxOPdlYiR4gVeOEKofTCEOu70JQIQApqFnw8lKM7rd9ag8ogDNqmc2L/GGXGHLAqds28oeKXRlzYf7+Q==} cpu: [arm64] @@ -1729,6 +2300,27 @@ packages: '@rushstack/eslint-patch@1.0.6': resolution: {integrity: sha512-Myxw//kzromB9yWgS8qYGuGVf91oBUUJpNvy5eM50sqvmKLbKjwLxohJnkWGTeeI9v9IBMtPLxz5Gc60FIfvCA==} + '@shikijs/core@3.21.0': + resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==} + + '@shikijs/engine-javascript@3.21.0': + resolution: {integrity: sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ==} + + '@shikijs/engine-oniguruma@3.21.0': + resolution: {integrity: sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==} + + '@shikijs/langs@3.21.0': + resolution: {integrity: sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==} + + '@shikijs/themes@3.21.0': + resolution: {integrity: sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==} + + '@shikijs/types@3.21.0': + resolution: {integrity: sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sideway/address@4.1.5': resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} @@ -2014,6 +2606,9 @@ packages: '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} + '@types/braces@3.0.5': + resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} + '@types/connect-history-api-fallback@1.5.4': resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} @@ -2071,6 +2666,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2089,12 +2687,18 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + '@types/micromatch@4.0.10': + resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node-forge@1.3.14': resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} @@ -2406,6 +3010,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -2417,6 +3025,9 @@ packages: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -2441,6 +3052,16 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + astro-expressive-code@0.41.6: + resolution: {integrity: sha512-l47tb1uhmVIebHUkw+HEPtU/av0G4O8Q34g2cbkPvC7/e9ZhANcjUUciKt9Hp6gSVDdIuXBBLwJQn2LkeGMOAw==} + peerDependencies: + astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta + + astro@5.16.16: + resolution: {integrity: sha512-MFlFvQ84ixaHyqB3uGwMhNHdBLZ3vHawyq3PqzQS2TNWiNfQrxp5ag6S3lX+Cvnh0MUcXX+UnJBPMBHjP1/1ZQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2456,6 +3077,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + b4a@1.7.3: resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} peerDependencies: @@ -2531,6 +3156,9 @@ packages: bare-url@2.2.2: resolution: {integrity: sha512-g+ueNGKkrjMazDG3elZO1pNs3HY5+mMmOet1jtKyhOaCnkLzitxf26z7hoAEkDNgdNmnc1KIlt/dw6Po6xZMpA==} + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2544,6 +3172,9 @@ packages: bcp-47-match@2.0.3: resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + bcp-47@2.1.0: + resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + big.js@5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} @@ -2572,6 +3203,10 @@ packages: resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} engines: {node: '>=14.16'} + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2647,6 +3282,10 @@ packages: resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} engines: {node: '>=14.16'} + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -2699,6 +3338,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -2710,6 +3353,10 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -2781,6 +3428,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2796,6 +3447,9 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + common-ancestor-path@1.0.1: + resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} + common-path-prefix@3.0.0: resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} @@ -2844,6 +3498,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.2: + resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} @@ -2851,6 +3508,10 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + copy-webpack-plugin@11.0.0: resolution: {integrity: sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==} engines: {node: '>= 14.15.0'} @@ -2891,6 +3552,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + crypto-random-string@4.0.0: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} @@ -3110,6 +3774,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -3122,6 +3789,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -3135,6 +3805,10 @@ packages: resolution: {integrity: sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==} engines: {node: '>=8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} @@ -3143,9 +3817,20 @@ packages: engines: {node: '>= 4.0.0'} hasBin: true + deterministic-object-hash@2.0.2: + resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} + engines: {node: '>=18'} + + devalue@5.6.2: + resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3154,6 +3839,9 @@ packages: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dns-packet@5.6.1: resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} engines: {node: '>=6'} @@ -3205,6 +3893,10 @@ packages: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3221,6 +3913,9 @@ packages: electron-to-chromium@1.5.224: resolution: {integrity: sha512-kWAoUu/bwzvnhpdZSIc6KUyvkI1rbRXMT0Eq8pKReyOyaPZcctMli+EgvcN1PAvwVc7Tdo4Fxi2PsLNDU05mdg==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3311,6 +4006,11 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3481,6 +4181,9 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3503,6 +4206,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -3526,6 +4232,9 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} + expressive-code@0.41.6: + resolution: {integrity: sha512-W/5+IQbrpCIM5KGLjO35wlp1NCwDOOVQb+PAvzEoGkW1xjGM807ZGfBKptNWH6UECvt6qgmLyWolCMYKh7eQmA==} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -3566,6 +4275,15 @@ packages: resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} engines: {node: '>=0.8.0'} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + feed@4.2.2: resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==} engines: {node: '>=0.4.0'} @@ -3625,6 +4343,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -3634,6 +4356,13 @@ packages: debug: optional: true + fontace@0.4.0: + resolution: {integrity: sha512-moThBCItUe2bjZip5PF/iZClpKHGLwMvR79Kp8XpGRBrvoRSnySN4VcILdv3/MJzbhvUA5WeiUXF5o538m5fvg==} + + fontkitten@1.0.2: + resolution: {integrity: sha512-piJxbLnkD9Xcyi7dWJRnqszEURixe7CrF/efBfbffe2DPyabmuIuqraruY8cXTs19QoM8VJzx47BDRVNXETM7Q==} + engines: {node: '>=20'} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3689,6 +4418,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3714,6 +4447,9 @@ packages: github-slugger@1.5.0: resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3791,6 +4527,9 @@ packages: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} + h3@1.15.5: + resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==} + handle-thing@2.0.1: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} @@ -3836,6 +4575,9 @@ packages: hast-util-embedded@3.0.0: resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} @@ -3915,6 +4657,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-minifier-terser@6.1.0: resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} engines: {node: '>=12'} @@ -3944,6 +4689,9 @@ packages: webpack: optional: true + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} @@ -3992,6 +4740,9 @@ packages: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} + i18next@23.16.8: + resolution: {integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -4033,6 +4784,9 @@ packages: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + import-modules@2.1.0: resolution: {integrity: sha512-8HEWcnkbGpovH9yInoisxaSoIg9Brbul+Ju3Kqe2UsYDUBJD/iQjSgEj0zPcTDPKfPp2fs5xlv1i+JSye/m1/A==} engines: {node: '>=8'} @@ -4084,6 +4838,9 @@ packages: resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} engines: {node: '>= 10'} + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -4349,6 +5106,10 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4405,6 +5166,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + known-css-properties@0.36.0: resolution: {integrity: sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==} @@ -4560,9 +5325,19 @@ packages: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lru-cache@11.2.5: + resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.1: + resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -4585,6 +5360,9 @@ packages: mathml-tag-names@2.1.3: resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + mdast-util-directive@3.1.0: resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} @@ -4908,6 +5686,13 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -4925,10 +5710,16 @@ packages: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-forge@1.3.1: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + node-releases@2.0.21: resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} @@ -4998,6 +5789,12 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -5013,6 +5810,12 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.4: + resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -5053,6 +5856,10 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + p-locate@2.0.0: resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} engines: {node: '>=4'} @@ -5077,6 +5884,10 @@ packages: resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} engines: {node: '>=8'} + p-queue@8.1.1: + resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} + engines: {node: '>=18'} + p-retry@6.2.1: resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==} engines: {node: '>=16.17'} @@ -5085,6 +5896,10 @@ packages: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} engines: {node: '>=8'} + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + p-try@1.0.0: resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} engines: {node: '>=4'} @@ -5097,6 +5912,13 @@ packages: resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==} engines: {node: '>=14.16'} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pagefind@1.4.0: + resolution: {integrity: sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==} + hasBin: true + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -5115,6 +5937,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + parse-numeric-range@1.3.0: resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} @@ -5178,6 +6003,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5185,6 +6013,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pify@3.0.0: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} @@ -5433,6 +6265,12 @@ packages: peerDependencies: postcss: ^8.1.0 + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + postcss-nesting@13.0.2: resolution: {integrity: sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==} engines: {node: '>=18'} @@ -5707,6 +6545,9 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -5810,6 +6651,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + recma-build-jsx@1.0.0: resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} @@ -5835,6 +6680,15 @@ packages: regenerate@1.4.2: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true @@ -5866,6 +6720,12 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true + rehype-expressive-code@0.41.6: + resolution: {integrity: sha512-aBMX8kxPtjmDSFUdZlAWJkMvsQ4ZMASfee90JWIAV8tweltXLzkWC3q++43ToTelI8ac5iC0B3/S/Cl4Ql1y2g==} + + rehype-format@5.0.1: + resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==} + rehype-minify-whitespace@6.0.2: resolution: {integrity: sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw==} @@ -5881,6 +6741,12 @@ packages: rehype-remark@10.0.1: resolution: {integrity: sha512-EmDndlb5NVwXGfUa4c9GPK+lXeItTilLhE6ADSaQuHr4JUlKw9MidzGzx4HpqZrNCt6vnHmEifXQiiA+CEnjYQ==} + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + relateurl@0.2.7: resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} engines: {node: '>= 0.10'} @@ -5898,6 +6764,10 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-heading-id@1.0.1: + resolution: {integrity: sha512-GmJjuCeEkYvwFlvn/Skjc/1Qafj71412gbQnrwUmP/tKskmAf1cMRlZRNoovV+aIvsSRkTb2rCmGv2b9RdoJbQ==} + engines: {node: '>=8'} + remark-mdx@3.1.1: resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} @@ -5907,6 +6777,10 @@ packages: remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} @@ -5960,6 +6834,18 @@ packages: resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} engines: {node: '>=14.16'} + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -5973,6 +6859,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rollup@4.57.0: + resolution: {integrity: sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rtlcss@4.3.0: resolution: {integrity: sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==} engines: {node: '>=12.0.0'} @@ -6083,6 +6974,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -6130,6 +7026,10 @@ packages: resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} engines: {node: '>=14.15.0'} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6142,6 +7042,9 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + shiki@3.21.0: + resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -6186,6 +7089,11 @@ packages: engines: {node: '>=12.0.0', npm: '>=5.6.0'} hasBin: true + sitemap@8.0.2: + resolution: {integrity: sha512-LwktpJcyZDoa0IL6KT++lQ53pbSrx2c9ge41/SeLTyqy2XUNA6uR4+P9u5IVo5lPeL2arAcOKn1aZAxoYbCKlQ==} + engines: {node: '>=14.0.0', npm: '>=6.0.0'} + hasBin: true + skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} @@ -6202,6 +7110,10 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + smol-toml@1.6.0: + resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} + engines: {node: '>= 18'} + snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -6256,6 +7168,13 @@ packages: resolution: {integrity: sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==} engines: {node: '>=12'} + starlight-llms-txt@0.7.0: + resolution: {integrity: sha512-KAay6JLXqB0GiNQ481z3Z/h/y4xeAU55TUGLz+npjxcRvN3h/7rDxjmyLiphZF8xfoqqSTduQPanl5Ct4Je6kA==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} + peerDependencies: + '@astrojs/starlight': '>=0.31' + astro: ^5.15.9 + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -6271,6 +7190,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-replace-string@2.0.0: + resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} + streamx@2.23.0: resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} @@ -6282,6 +7204,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -6435,6 +7361,11 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + svgo@4.0.0: + resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==} + engines: {node: '>=16'} + hasBin: true + swc-loader@0.2.6: resolution: {integrity: sha512-9Zi9UP2YmDpgmQVbyOPJClY0dwf58JDyDMQ7uRc4krmc72twNI2fvlBWHLqVekBpPc7h5NJkGVT1zNDxFrqhvg==} peerDependencies: @@ -6507,12 +7438,23 @@ packages: thunky@1.1.0: resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -6550,6 +7492,16 @@ packages: peerDependencies: typescript: '>=4.2.0' + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -6587,6 +7539,10 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -6615,10 +7571,19 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -6645,6 +7610,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unifont@0.7.3: + resolution: {integrity: sha512-b0GtQzKCyuSHGsfj5vyN8st7muZ6VCI4XD4vFlr7Uy1rlWVYxC3npnfk8MyreHxJYrz1ooLDqDzFe9XqQTlAhA==} + unique-string@3.0.0: resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} engines: {node: '>=12'} @@ -6652,21 +7620,45 @@ packages: unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@3.0.0: + resolution: {integrity: sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A==} + unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + unist-util-position-from-estree@2.0.0: resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-remove@4.0.0: + resolution: {integrity: sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@2.1.2: + resolution: {integrity: sha512-DyN5vD4NE3aSeB+PXYNKxzGsfocxp6asDc2XXE3b0ekO2BaRUpBicbbUygfSvYfUz1IkmjFR1YF7dPklraMZ2g==} + unist-util-visit-parents@6.0.1: resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@1.4.1: + resolution: {integrity: sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw==} + unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} @@ -6678,6 +7670,68 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unstorage@1.17.4: + resolution: {integrity: sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -6746,6 +7800,54 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.1: + resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + watchpack@2.4.4: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} @@ -6831,6 +7933,10 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -6848,6 +7954,10 @@ packages: resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} engines: {node: '>=12'} + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + wildcard@2.0.1: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} @@ -6863,6 +7973,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6909,13 +8023,42 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yocto-queue@1.2.1: resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} engines: {node: '>=12.20'} + yocto-spinner@0.2.3: + resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==} + engines: {node: '>=18.19'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod-to-ts@1.2.0: + resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} + peerDependencies: + typescript: ^4.9.4 || ^5.0.2 + zod: ^3 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.11: resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} @@ -7076,6 +8219,111 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) + '@astrojs/compiler@2.13.0': {} + + '@astrojs/internal-helpers@0.7.5': {} + + '@astrojs/markdown-remark@6.3.10': + dependencies: + '@astrojs/internal-helpers': 0.7.5 + '@astrojs/prism': 3.3.0 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.2 + shiki: 3.21.0 + smol-toml: 1.6.0 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/mdx@4.3.13(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2))': + dependencies: + '@astrojs/markdown-remark': 6.3.10 + '@mdx-js/mdx': 3.1.1 + acorn: 8.15.0 + astro: 5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2) + es-module-lexer: 1.7.0 + estree-util-visit: 2.0.0 + hast-util-to-html: 9.0.5 + piccolore: 0.1.3 + rehype-raw: 7.0.0 + remark-gfm: 4.0.1 + remark-smartypants: 3.0.2 + source-map: 0.7.6 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@3.3.0': + dependencies: + prismjs: 1.30.0 + + '@astrojs/sitemap@3.7.0': + dependencies: + sitemap: 8.0.2 + stream-replace-string: 2.0.0 + zod: 3.25.76 + + '@astrojs/starlight@0.37.4(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2))': + dependencies: + '@astrojs/markdown-remark': 6.3.10 + '@astrojs/mdx': 4.3.13(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2)) + '@astrojs/sitemap': 3.7.0 + '@pagefind/default-ui': 1.4.0 + '@types/hast': 3.0.4 + '@types/js-yaml': 4.0.9 + '@types/mdast': 4.0.4 + astro: 5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2) + astro-expressive-code: 0.41.6(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2)) + bcp-47: 2.1.0 + hast-util-from-html: 2.0.3 + hast-util-select: 6.0.4 + hast-util-to-string: 3.0.1 + hastscript: 9.0.1 + i18next: 23.16.8 + js-yaml: 4.1.0 + klona: 2.0.6 + magic-string: 0.30.21 + mdast-util-directive: 3.1.0 + mdast-util-to-markdown: 2.1.2 + mdast-util-to-string: 4.0.0 + pagefind: 1.4.0 + rehype: 13.0.2 + rehype-format: 5.0.1 + remark-directive: 3.0.1 + ultrahtml: 1.6.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/telemetry@3.3.0': + dependencies: + ci-info: 4.3.1 + debug: 4.4.3 + dlv: 1.1.3 + dset: 3.1.4 + is-docker: 3.0.0 + is-wsl: 3.1.0 + which-pm-runs: 1.1.0 + transitivePeerDependencies: + - supports-color + '@babel/code-frame@7.12.11': dependencies: '@babel/highlight': 7.25.9 @@ -7227,6 +8475,8 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helper-wrap-function@7.28.3': @@ -7253,6 +8503,10 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 @@ -7848,6 +9102,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@cacheable/memoize@2.0.2': dependencies: '@cacheable/utils': 2.0.2 @@ -7862,6 +9121,10 @@ snapshots: '@cacheable/utils@2.0.2': {} + '@capsizecss/unpack@4.0.0': + dependencies: + fontkitten: 1.0.2 + '@colors/colors@1.5.0': optional: true @@ -8153,6 +9416,8 @@ snapshots: dependencies: postcss: 8.5.6 + '@ctrl/tinycolor@4.2.0': {} + '@discoveryjs/json-ext@0.5.7': {} '@docsearch/css@4.1.0': {} @@ -8200,7 +9465,7 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/bundler@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/bundler@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: '@babel/core': 7.28.4 '@docusaurus/babel': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8212,7 +9477,7 @@ snapshots: clean-css: 5.3.3 copy-webpack-plugin: 11.0.0(webpack@5.101.3(@swc/core@1.13.19)) css-loader: 6.11.0(@rspack/core@1.5.7)(webpack@5.101.3(@swc/core@1.13.19)) - css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(webpack@5.101.3(@swc/core@1.13.19)) + css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(lightningcss@1.30.1)(webpack@5.101.3(@swc/core@1.13.19)) cssnano: 6.1.2(postcss@8.5.6) file-loader: 6.2.0(webpack@5.101.3(@swc/core@1.13.19)) html-minifier-terser: 7.2.0 @@ -8243,10 +9508,10 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/core@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: '@docusaurus/babel': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@docusaurus/bundler': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/bundler': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/logger': 3.9.1 '@docusaurus/mdx-loader': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8401,9 +9666,9 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-client-redirects@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-client-redirects@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/logger': 3.9.1 '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils-common': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8432,13 +9697,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-blog@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-content-blog@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/logger': 3.9.1 '@docusaurus/mdx-loader': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@docusaurus/plugin-content-docs': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/theme-common': 3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@docusaurus/plugin-content-docs': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/theme-common': 3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils-common': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8473,13 +9738,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/logger': 3.9.1 '@docusaurus/mdx-loader': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/module-type-aliases': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@docusaurus/theme-common': 3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@docusaurus/theme-common': 3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils-common': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8513,9 +9778,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-content-pages@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/mdx-loader': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8543,9 +9808,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-css-cascade-layers@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-css-cascade-layers@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils-validation': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8570,9 +9835,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-debug@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) fs-extra: 11.3.2 @@ -8598,9 +9863,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-google-analytics@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils-validation': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react: 19.1.1 @@ -8624,9 +9889,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-google-gtag@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils-validation': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@types/gtag.js': 0.0.12 @@ -8651,9 +9916,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-google-tag-manager@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils-validation': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react: 19.1.1 @@ -8677,9 +9942,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-ideal-image@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-ideal-image@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/lqip-loader': 3.9.1(webpack@5.101.3(@swc/core@1.13.19)) '@docusaurus/responsive-loader': 1.7.1(sharp@0.32.6) '@docusaurus/theme-translations': 3.9.1 @@ -8710,9 +9975,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-sitemap@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/logger': 3.9.1 '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8741,9 +10006,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/plugin-svgr@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils-validation': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8771,22 +10036,22 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/preset-classic@3.9.1(@algolia/client-search@5.38.0)(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(search-insights@2.17.3)(typescript@5.9.2)': - dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-content-blog': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-content-docs': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-content-pages': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-css-cascade-layers': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-debug': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-google-analytics': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-google-gtag': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-google-tag-manager': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-sitemap': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-svgr': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/theme-classic': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/theme-common': 3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@docusaurus/theme-search-algolia': 3.9.1(@algolia/client-search@5.38.0)(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(search-insights@2.17.3)(typescript@5.9.2) + '@docusaurus/preset-classic@3.9.1(@algolia/client-search@5.38.0)(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(search-insights@2.17.3)(typescript@5.9.2)': + dependencies: + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-content-blog': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-content-docs': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-content-pages': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-css-cascade-layers': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-debug': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-google-analytics': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-google-gtag': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-google-tag-manager': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-sitemap': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-svgr': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/theme-classic': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/theme-common': 3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@docusaurus/theme-search-algolia': 3.9.1(@algolia/client-search@5.38.0)(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(search-insights@2.17.3)(typescript@5.9.2) '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react: 19.1.1 react-dom: 19.1.1(react@19.1.1) @@ -8822,16 +10087,16 @@ snapshots: optionalDependencies: sharp: 0.32.6 - '@docusaurus/theme-classic@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': + '@docusaurus/theme-classic@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/logger': 3.9.1 '@docusaurus/mdx-loader': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/module-type-aliases': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@docusaurus/plugin-content-blog': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-content-docs': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/plugin-content-pages': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/theme-common': 3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@docusaurus/plugin-content-blog': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-content-docs': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-content-pages': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/theme-common': 3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/theme-translations': 3.9.1 '@docusaurus/types': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8869,11 +10134,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-common@3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + '@docusaurus/theme-common@3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@docusaurus/mdx-loader': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/module-type-aliases': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@docusaurus/plugin-content-docs': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/plugin-content-docs': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils-common': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@types/history': 4.7.11 @@ -8893,13 +10158,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-search-algolia@3.9.1(@algolia/client-search@5.38.0)(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(search-insights@2.17.3)(typescript@5.9.2)': + '@docusaurus/theme-search-algolia@3.9.1(@algolia/client-search@5.38.0)(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(@types/react@19.1.13)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(search-insights@2.17.3)(typescript@5.9.2)': dependencies: '@docsearch/react': 4.1.0(@algolia/client-search@5.38.0)(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(search-insights@2.17.3) - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) '@docusaurus/logger': 3.9.1 - '@docusaurus/plugin-content-docs': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) - '@docusaurus/theme-common': 3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@docusaurus/plugin-content-docs': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/theme-common': 3.9.1(@docusaurus/plugin-content-docs@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/theme-translations': 3.9.1 '@docusaurus/utils': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@docusaurus/utils-validation': 3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -9034,7 +10299,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.5.0': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true @@ -9044,6 +10309,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@7.32.0)': dependencies: eslint: 7.32.0 @@ -9088,6 +10431,31 @@ snapshots: transitivePeerDependencies: - supports-color + '@expressive-code/core@0.41.6': + dependencies: + '@ctrl/tinycolor': 4.2.0 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hastscript: 9.0.1 + postcss: 8.5.6 + postcss-nested: 6.2.0(postcss@8.5.6) + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.2 + + '@expressive-code/plugin-frames@0.41.6': + dependencies: + '@expressive-code/core': 0.41.6 + + '@expressive-code/plugin-shiki@0.41.6': + dependencies: + '@expressive-code/core': 0.41.6 + shiki: 3.21.0 + + '@expressive-code/plugin-text-markers@0.41.6': + dependencies: + '@expressive-code/core': 0.41.6 + '@fontsource-variable/overpass@5.2.8': {} '@hapi/hoek@9.3.0': {} @@ -9106,6 +10474,102 @@ snapshots: '@humanwhocodes/object-schema@1.2.1': {} + '@img/colour@1.0.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.8 @@ -9250,7 +10714,7 @@ snapshots: '@napi-rs/wasm-runtime@1.0.5': dependencies: '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.8.1 '@tybys/wasm-util': 0.10.1 optional: true @@ -9272,6 +10736,28 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@oslojs/encoding@1.1.0': {} + + '@pagefind/darwin-arm64@1.4.0': + optional: true + + '@pagefind/darwin-x64@1.4.0': + optional: true + + '@pagefind/default-ui@1.4.0': {} + + '@pagefind/freebsd-x64@1.4.0': + optional: true + + '@pagefind/linux-arm64@1.4.0': + optional: true + + '@pagefind/linux-x64@1.4.0': + optional: true + + '@pagefind/windows-x64@1.4.0': + optional: true + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -9354,6 +10840,89 @@ snapshots: react-dom: 19.1.1(react@19.1.1) react-is: 18.3.1 + '@rollup/pluginutils@5.3.0(rollup@4.57.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.57.0 + + '@rollup/rollup-android-arm-eabi@4.57.0': + optional: true + + '@rollup/rollup-android-arm64@4.57.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.0': + optional: true + + '@rollup/rollup-darwin-x64@4.57.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.0': + optional: true + '@rspack/binding-darwin-arm64@1.5.7': optional: true @@ -9409,6 +10978,39 @@ snapshots: '@rushstack/eslint-patch@1.0.6': {} + '@shikijs/core@3.21.0': + dependencies: + '@shikijs/types': 3.21.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.21.0': + dependencies: + '@shikijs/types': 3.21.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.4 + + '@shikijs/engine-oniguruma@3.21.0': + dependencies: + '@shikijs/types': 3.21.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.21.0': + dependencies: + '@shikijs/types': 3.21.0 + + '@shikijs/themes@3.21.0': + dependencies: + '@shikijs/types': 3.21.0 + + '@shikijs/types@3.21.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sideway/address@4.1.5': dependencies: '@hapi/hoek': 9.3.0 @@ -9417,9 +11019,9 @@ snapshots: '@sideway/pinpoint@2.0.0': {} - '@signalwire/docusaurus-plugin-llms-txt@1.2.2(@docusaurus/core@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))': + '@signalwire/docusaurus-plugin-llms-txt@1.2.2(@docusaurus/core@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))': dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) fs-extra: 11.3.2 hast-util-select: 6.0.4 hast-util-to-html: 9.0.5 @@ -9671,6 +11273,8 @@ snapshots: dependencies: '@types/node': 22.18.6 + '@types/braces@3.0.5': {} + '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 4.19.6 @@ -9742,6 +11346,8 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -9758,10 +11364,18 @@ snapshots: '@types/mdx@2.0.13': {} + '@types/micromatch@4.0.10': + dependencies: + '@types/braces': 3.0.5 + '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + '@types/node-forge@1.3.14': dependencies: '@types/node': 22.18.6 @@ -10142,6 +11756,8 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -10160,6 +11776,8 @@ snapshots: is-string: 1.1.1 math-intrinsics: 1.1.0 + array-iterate@2.0.1: {} + array-union@2.1.0: {} array.prototype.flat@1.3.3: @@ -10190,6 +11808,113 @@ snapshots: astring@1.9.0: {} + astro-expressive-code@0.41.6(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2)): + dependencies: + astro: 5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2) + rehype-expressive-code: 0.41.6 + + astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2): + dependencies: + '@astrojs/compiler': 2.13.0 + '@astrojs/internal-helpers': 0.7.5 + '@astrojs/markdown-remark': 6.3.10 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.57.0) + acorn: 8.15.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.3.1 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.1 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.6.2 + diff: 8.0.3 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.25.12 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.4.0 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.1 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.3 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.3 + shiki: 3.21.0 + smol-toml: 1.6.0 + svgo: 4.0.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.2) + ultrahtml: 1.6.0 + unifont: 0.7.3 + unist-util-visit: 5.0.0 + unstorage: 1.17.4 + vfile: 6.0.3 + vite: 6.4.1(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0) + vitefu: 1.1.1(vite@6.4.1(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.2)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + async-function@1.0.0: {} autoprefixer@10.4.21(postcss@8.5.6): @@ -10206,6 +11931,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axobject-query@4.1.0: {} + b4a@1.7.3: {} babel-loader@9.2.1(@babel/core@7.28.4)(webpack@5.101.3(@swc/core@1.13.19)): @@ -10284,6 +12011,8 @@ snapshots: bare-path: 3.0.0 optional: true + base-64@1.0.0: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.8.7: {} @@ -10292,6 +12021,12 @@ snapshots: bcp-47-match@2.0.3: {} + bcp-47@2.1.0: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + big.js@5.2.2: {} binary-extensions@2.3.0: {} @@ -10348,6 +12083,17 @@ snapshots: widest-line: 4.0.1 wrap-ansi: 8.1.0 + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -10434,6 +12180,8 @@ snapshots: camelcase@7.0.1: {} + camelcase@8.0.0: {} + caniuse-api@3.0.0: dependencies: browserslist: 4.26.2 @@ -10503,12 +12251,18 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + chownr@1.1.4: {} chrome-trace-event@1.0.4: {} ci-info@3.9.0: {} + ci-info@4.3.1: {} + classnames@2.5.1: {} clean-css@5.3.3: @@ -10571,6 +12325,8 @@ snapshots: commander@10.0.1: {} + commander@11.1.0: {} + commander@2.20.3: {} commander@5.1.0: {} @@ -10579,6 +12335,8 @@ snapshots: commander@8.3.0: {} + common-ancestor-path@1.0.1: {} + common-path-prefix@3.0.0: {} compressible@2.0.18: @@ -10631,10 +12389,14 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@1.2.2: {} + cookie-signature@1.0.6: {} cookie@0.7.1: {} + cookie@1.1.1: {} + copy-webpack-plugin@11.0.0(webpack@5.101.3(@swc/core@1.13.19)): dependencies: fast-glob: 3.3.3 @@ -10679,6 +12441,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + crypto-random-string@4.0.0: dependencies: type-fest: 1.4.0 @@ -10710,12 +12476,12 @@ snapshots: postcss-modules-scope: 3.2.1(postcss@8.5.6) postcss-modules-values: 4.0.0(postcss@8.5.6) postcss-value-parser: 4.2.0 - semver: 7.7.2 + semver: 7.7.3 optionalDependencies: '@rspack/core': 1.5.7 webpack: 5.101.3(@swc/core@1.13.19) - css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(webpack@5.101.3(@swc/core@1.13.19)): + css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(lightningcss@1.30.1)(webpack@5.101.3(@swc/core@1.13.19)): dependencies: '@jridgewell/trace-mapping': 0.3.31 cssnano: 6.1.2(postcss@8.5.6) @@ -10726,6 +12492,7 @@ snapshots: webpack: 5.101.3(@swc/core@1.13.19) optionalDependencies: clean-css: 5.3.3 + lightningcss: 1.30.1 css-prefers-color-scheme@10.0.0(postcss@8.5.6): dependencies: @@ -10902,12 +12669,16 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + depd@1.1.2: {} depd@2.0.0: {} dequal@2.0.3: {} + destr@2.0.5: {} + destroy@1.2.0: {} detect-libc@1.0.3: @@ -10915,6 +12686,8 @@ snapshots: detect-libc@2.1.1: {} + detect-libc@2.1.2: {} + detect-node@2.1.0: {} detect-port@1.6.1: @@ -10924,16 +12697,26 @@ snapshots: transitivePeerDependencies: - supports-color + deterministic-object-hash@2.0.2: + dependencies: + base-64: 1.0.0 + + devalue@5.6.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 + diff@8.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 direction@2.0.1: {} + dlv@1.1.3: {} + dns-packet@5.6.1: dependencies: '@leichtgewicht/ip-codec': 2.0.5 @@ -10946,9 +12729,9 @@ snapshots: dependencies: esutils: 2.0.3 - docusaurus-plugin-sass@0.2.6(@docusaurus/core@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@rspack/core@1.5.7)(sass@1.93.2)(webpack@5.101.3(@swc/core@1.13.19)): + docusaurus-plugin-sass@0.2.6(@docusaurus/core@3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2))(@rspack/core@1.5.7)(sass@1.93.2)(webpack@5.101.3(@swc/core@1.13.19)): dependencies: - '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) + '@docusaurus/core': 3.9.1(@docusaurus/faster@3.9.1(@docusaurus/types@3.9.1(@swc/core@1.13.19)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)))(@mdx-js/react@3.1.1(@types/react@19.1.13)(react@19.1.1))(@rspack/core@1.5.7)(@swc/core@1.13.19)(lightningcss@1.30.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2) sass: 1.93.2 sass-loader: 16.0.5(@rspack/core@1.5.7)(sass@1.93.2)(webpack@5.101.3(@swc/core@1.13.19)) transitivePeerDependencies: @@ -11004,6 +12787,8 @@ snapshots: dependencies: is-obj: 2.0.0 + dset@3.1.4: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -11018,6 +12803,8 @@ snapshots: electron-to-chromium@1.5.224: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -11156,6 +12943,35 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -11387,6 +13203,8 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -11404,6 +13222,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: dependencies: bare-events: 2.7.0 @@ -11462,6 +13282,13 @@ snapshots: transitivePeerDependencies: - supports-color + expressive-code@0.41.6: + dependencies: + '@expressive-code/core': 0.41.6 + '@expressive-code/plugin-frames': 0.41.6 + '@expressive-code/plugin-shiki': 0.41.6 + '@expressive-code/plugin-text-markers': 0.41.6 + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -11500,6 +13327,10 @@ snapshots: dependencies: websocket-driver: 0.7.4 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + feed@4.2.2: dependencies: xml-js: 1.6.11 @@ -11573,8 +13404,18 @@ snapshots: flatted@3.3.3: {} + flattie@1.1.1: {} + follow-redirects@1.15.11: {} + fontace@0.4.0: + dependencies: + fontkitten: 1.0.2 + + fontkitten@1.0.2: + dependencies: + tiny-inflate: 1.0.3 + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -11619,6 +13460,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -11651,6 +13494,8 @@ snapshots: github-slugger@1.5.0: {} + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -11749,6 +13594,18 @@ snapshots: dependencies: duplexer: 0.1.2 + h3@1.15.5: + dependencies: + cookie-es: 1.2.2 + crossws: 0.3.5 + defu: 6.1.4 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.3 + uncrypto: 0.1.3 + handle-thing@2.0.1: {} has-bigints@1.1.0: {} @@ -11784,6 +13641,16 @@ snapshots: '@types/hast': 3.0.4 hast-util-is-element: 3.0.0 + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.1 + hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.4 @@ -12003,6 +13870,8 @@ snapshots: html-escaper@2.0.2: {} + html-escaper@3.0.3: {} + html-minifier-terser@6.1.0: dependencies: camel-case: 4.1.2 @@ -12038,6 +13907,8 @@ snapshots: '@rspack/core': 1.5.7 webpack: 5.101.3(@swc/core@1.13.19) + html-whitespace-sensitive-tag-names@3.0.1: {} + htmlparser2@6.1.0: dependencies: domelementtype: 2.3.0 @@ -12102,6 +13973,10 @@ snapshots: hyperdyperid@1.2.0: {} + i18next@23.16.8: + dependencies: + '@babel/runtime': 7.28.4 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -12129,6 +14004,8 @@ snapshots: import-lazy@4.0.0: {} + import-meta-resolve@4.2.0: {} + import-modules@2.1.0: {} imurmurhash@0.1.4: {} @@ -12166,6 +14043,8 @@ snapshots: ipaddr.js@2.2.0: {} + iron-webcrypto@1.2.1: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -12408,6 +14287,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -12455,6 +14338,8 @@ snapshots: kleur@3.0.3: {} + klona@2.0.6: {} + known-css-properties@0.36.0: {} known-css-properties@0.37.0: {} @@ -12578,10 +14463,22 @@ snapshots: lowercase-keys@3.0.0: {} + lru-cache@11.2.5: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.1: + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + source-map-js: 1.2.1 + markdown-extensions@2.0.0: {} markdown-table@2.0.0: @@ -12596,6 +14493,12 @@ snapshots: mathml-tag-names@2.1.3: {} + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + mdast-util-directive@3.1.0: dependencies: '@types/mdast': 4.0.4 @@ -12615,7 +14518,7 @@ snapshots: '@types/mdast': 4.0.4 escape-string-regexp: 5.0.0 unist-util-is: 6.0.0 - unist-util-visit-parents: 6.0.1 + unist-util-visit-parents: 6.0.2 mdast-util-from-markdown@2.0.2: dependencies: @@ -13186,6 +15089,12 @@ snapshots: neo-async@2.6.2: {} + neotraverse@0.6.18: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -13207,8 +15116,12 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 + node-fetch-native@1.6.7: {} + node-forge@1.3.1: {} + node-mock-http@1.0.4: {} + node-releases@2.0.21: {} normalize-package-data@2.5.0: @@ -13282,6 +15195,14 @@ snapshots: obuf@1.1.2: {} + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + ohash@2.0.11: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -13296,6 +15217,14 @@ snapshots: dependencies: mimic-fn: 2.1.0 + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.4: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + open@10.2.0: dependencies: default-browser: 5.2.1 @@ -13342,6 +15271,10 @@ snapshots: dependencies: yocto-queue: 1.2.1 + p-limit@6.2.0: + dependencies: + yocto-queue: 1.2.1 + p-locate@2.0.0: dependencies: p-limit: 1.3.0 @@ -13365,6 +15298,11 @@ snapshots: eventemitter3: 4.0.7 p-timeout: 3.2.0 + p-queue@8.1.1: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 6.1.4 + p-retry@6.2.1: dependencies: '@types/retry': 0.12.2 @@ -13375,6 +15313,8 @@ snapshots: dependencies: p-finally: 1.0.0 + p-timeout@6.1.4: {} + p-try@1.0.0: {} p-try@2.2.0: {} @@ -13384,7 +15324,18 @@ snapshots: got: 12.6.1 registry-auth-token: 5.1.0 registry-url: 6.0.1 - semver: 7.7.2 + semver: 7.7.3 + + package-manager-detector@1.6.0: {} + + pagefind@1.4.0: + optionalDependencies: + '@pagefind/darwin-arm64': 1.4.0 + '@pagefind/darwin-x64': 1.4.0 + '@pagefind/freebsd-x64': 1.4.0 + '@pagefind/linux-arm64': 1.4.0 + '@pagefind/linux-x64': 1.4.0 + '@pagefind/windows-x64': 1.4.0 param-case@3.0.4: dependencies: @@ -13417,6 +15368,15 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + parse-numeric-range@1.3.0: {} parse5-htmlparser2-tree-adapter@7.1.0: @@ -13467,10 +15427,14 @@ snapshots: path-type@4.0.0: {} + piccolore@0.1.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} + picomatch@4.0.3: {} + pify@3.0.0: {} pkg-dir@7.0.0: @@ -13636,7 +15600,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@5.9.2) jiti: 1.21.7 postcss: 8.5.6 - semver: 7.7.2 + semver: 7.7.3 webpack: 5.101.3(@swc/core@1.13.19) transitivePeerDependencies: - typescript @@ -13713,6 +15677,11 @@ snapshots: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 + postcss-nested@6.2.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + postcss-nesting@13.0.2(postcss@8.5.6): dependencies: '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.0) @@ -14029,6 +15998,8 @@ snapshots: quick-lru@5.1.1: {} + radix3@1.1.2: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -14155,6 +16126,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + recma-build-jsx@1.0.0: dependencies: '@types/estree': 1.0.8 @@ -14201,6 +16174,16 @@ snapshots: regenerate@1.4.2: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexp-tree@0.1.27: {} regexp.prototype.flags@1.5.4: @@ -14237,6 +16220,15 @@ snapshots: dependencies: jsesc: 3.1.0 + rehype-expressive-code@0.41.6: + dependencies: + expressive-code: 0.41.6 + + rehype-format@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-format: 1.1.0 + rehype-minify-whitespace@6.0.2: dependencies: '@types/hast': 3.0.4 @@ -14270,6 +16262,19 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.4 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + relateurl@0.2.7: {} remark-directive@3.0.1: @@ -14309,6 +16314,11 @@ snapshots: transitivePeerDependencies: - supports-color + remark-heading-id@1.0.1: + dependencies: + lodash: 4.17.21 + unist-util-visit: 1.4.1 + remark-mdx@3.1.1: dependencies: mdast-util-mdx: 3.0.0 @@ -14333,6 +16343,13 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + remark-stringify@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -14383,6 +16400,31 @@ snapshots: dependencies: lowercase-keys: 3.0.0 + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.0.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + retry@0.13.1: {} reusify@1.1.0: {} @@ -14391,6 +16433,37 @@ snapshots: dependencies: glob: 7.2.3 + rollup@4.57.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.0 + '@rollup/rollup-android-arm64': 4.57.0 + '@rollup/rollup-darwin-arm64': 4.57.0 + '@rollup/rollup-darwin-x64': 4.57.0 + '@rollup/rollup-freebsd-arm64': 4.57.0 + '@rollup/rollup-freebsd-x64': 4.57.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.0 + '@rollup/rollup-linux-arm-musleabihf': 4.57.0 + '@rollup/rollup-linux-arm64-gnu': 4.57.0 + '@rollup/rollup-linux-arm64-musl': 4.57.0 + '@rollup/rollup-linux-loong64-gnu': 4.57.0 + '@rollup/rollup-linux-loong64-musl': 4.57.0 + '@rollup/rollup-linux-ppc64-gnu': 4.57.0 + '@rollup/rollup-linux-ppc64-musl': 4.57.0 + '@rollup/rollup-linux-riscv64-gnu': 4.57.0 + '@rollup/rollup-linux-riscv64-musl': 4.57.0 + '@rollup/rollup-linux-s390x-gnu': 4.57.0 + '@rollup/rollup-linux-x64-gnu': 4.57.0 + '@rollup/rollup-linux-x64-musl': 4.57.0 + '@rollup/rollup-openbsd-x64': 4.57.0 + '@rollup/rollup-openharmony-arm64': 4.57.0 + '@rollup/rollup-win32-arm64-msvc': 4.57.0 + '@rollup/rollup-win32-ia32-msvc': 4.57.0 + '@rollup/rollup-win32-x64-gnu': 4.57.0 + '@rollup/rollup-win32-x64-msvc': 4.57.0 + fsevents: 2.3.3 + rtlcss@4.3.0: dependencies: escalade: 3.2.0 @@ -14484,7 +16557,7 @@ snapshots: semver-diff@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 semver@5.7.2: {} @@ -14492,6 +16565,8 @@ snapshots: semver@7.7.2: {} + semver@7.7.3: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -14591,6 +16666,37 @@ snapshots: - bare-buffer - react-native-b4a + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -14599,6 +16705,17 @@ snapshots: shell-quote@1.8.3: {} + shiki@3.21.0: + dependencies: + '@shikijs/core': 3.21.0 + '@shikijs/engine-javascript': 3.21.0 + '@shikijs/engine-oniguruma': 3.21.0 + '@shikijs/langs': 3.21.0 + '@shikijs/themes': 3.21.0 + '@shikijs/types': 3.21.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -14658,6 +16775,13 @@ snapshots: arg: 5.0.2 sax: 1.4.1 + sitemap@8.0.2: + dependencies: + '@types/node': 17.0.45 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.4.1 + skin-tone@2.0.0: dependencies: unicode-emoji-modifier-base: 1.0.0 @@ -14672,6 +16796,8 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + smol-toml@1.6.0: {} + snake-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -14737,6 +16863,25 @@ snapshots: srcset@4.0.0: {} + starlight-llms-txt@0.7.0(@astrojs/starlight@0.37.4(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2)))(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2)): + dependencies: + '@astrojs/mdx': 4.3.13(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2)) + '@astrojs/starlight': 0.37.4(astro@5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2)) + '@types/hast': 3.0.4 + '@types/micromatch': 4.0.10 + astro: 5.16.16(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(rollup@4.57.0)(sass@1.93.2)(terser@5.44.0)(typescript@5.9.2) + github-slugger: 2.0.0 + hast-util-select: 6.0.4 + micromatch: 4.0.8 + rehype-parse: 9.0.1 + rehype-remark: 10.0.1 + remark-gfm: 4.0.1 + remark-stringify: 11.0.0 + unified: 11.0.5 + unist-util-remove: 4.0.0 + transitivePeerDependencies: + - supports-color + statuses@1.5.0: {} statuses@2.0.1: {} @@ -14748,6 +16893,8 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-replace-string@2.0.0: {} + streamx@2.23.0: dependencies: events-universal: 1.0.1 @@ -14768,6 +16915,12 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 @@ -14984,6 +17137,16 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 + svgo@4.0.0: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.1.0 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.4.1 + swc-loader@0.2.6(@swc/core@1.13.19)(webpack@5.101.3(@swc/core@1.13.19)): dependencies: '@swc/core': 1.13.19 @@ -15074,10 +17237,19 @@ snapshots: thunky@1.1.0: {} + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {} + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + tinypool@1.1.1: {} to-regex-range@5.0.1: @@ -15102,6 +17274,10 @@ snapshots: dependencies: typescript: 5.9.2 + tsconfck@3.1.6(typescript@5.9.2): + optionalDependencies: + typescript: 5.9.2 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -15131,6 +17307,8 @@ snapshots: type-fest@2.19.0: {} + type-fest@4.41.0: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -15175,6 +17353,10 @@ snapshots: typescript@5.9.2: {} + ufo@1.6.3: {} + + ultrahtml@1.6.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -15182,6 +17364,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@6.21.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -15207,6 +17391,12 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unifont@0.7.3: + dependencies: + css-tree: 3.1.0 + ofetch: 1.5.1 + ohash: 2.0.11 + unique-string@3.0.0: dependencies: crypto-random-string: 4.0.0 @@ -15216,10 +17406,17 @@ snapshots: '@types/unist': 3.0.3 unist-util-is: 6.0.0 + unist-util-is@3.0.0: {} + unist-util-is@6.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + unist-util-position-from-estree@2.0.0: dependencies: '@types/unist': 3.0.3 @@ -15228,15 +17425,43 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + + unist-util-remove@4.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.2 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@2.1.2: + dependencies: + unist-util-is: 3.0.0 + unist-util-visit-parents@6.0.1: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.0 + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + + unist-util-visit@1.4.1: + dependencies: + unist-util-visit-parents: 2.1.2 + unist-util-visit@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -15247,6 +17472,17 @@ snapshots: unpipe@1.0.0: {} + unstorage@1.17.4: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.5 + lru-cache: 11.2.5 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.3 + update-browserslist-db@1.1.3(browserslist@4.26.2): dependencies: browserslist: 4.26.2 @@ -15266,7 +17502,7 @@ snapshots: is-yarn-global: 0.4.1 latest-version: 7.0.0 pupa: 3.3.0 - semver: 7.7.2 + semver: 7.7.3 semver-diff: 4.0.0 xdg-basedir: 5.1.0 @@ -15323,6 +17559,26 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@6.4.1(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.18.6 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.1 + sass: 1.93.2 + terser: 5.44.0 + + vitefu@1.1.1(vite@6.4.1(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)): + optionalDependencies: + vite: 6.4.1(@types/node@22.18.6)(jiti@1.21.7)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0) + watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 @@ -15498,6 +17754,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-pm-runs@1.1.0: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -15520,6 +17778,10 @@ snapshots: dependencies: string-width: 5.1.2 + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + wildcard@2.0.1: {} word-wrap@1.2.5: {} @@ -15536,6 +17798,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} write-file-atomic@3.0.3: @@ -15564,10 +17832,31 @@ snapshots: dependencies: sax: 1.4.1 + xxhash-wasm@1.1.0: {} + yallist@3.1.1: {} + yargs-parser@21.1.1: {} + yocto-queue@1.2.1: {} + yocto-spinner@0.2.3: + dependencies: + yoctocolors: 2.1.2 + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-to-ts@1.2.0(typescript@5.9.2)(zod@3.25.76): + dependencies: + typescript: 5.9.2 + zod: 3.25.76 + + zod@3.25.76: {} + zod@4.1.11: {} zwitch@2.0.4: {} diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000000..91b8dcd815 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,7 @@ +import { defineCollection } from "astro:content"; +import { docsLoader } from "@astrojs/starlight/loaders"; +import { docsSchema } from "@astrojs/starlight/schema"; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +}; diff --git a/src/content/docs/docs/about/alternatives.mdx b/src/content/docs/docs/about/alternatives.mdx new file mode 100644 index 0000000000..a0400b91e9 --- /dev/null +++ b/src/content/docs/docs/about/alternatives.mdx @@ -0,0 +1,139 @@ +--- +title: Alternatives +sidebar: + order: 3 + badge: + text: WIP + variant: caution +--- + +import { FileTree } from '@astrojs/starlight/components'; + +History of architecture approaches + +## Big Ball of Mud + +> What is it; Why is it so common; When it starts to bring problems; What to do and how does FSD help in this + +- [(Article) Oleg Isonen - Last words on UI architecture before an AI takes over](https://oleg008.medium.com/last-words-on-ui-architecture-before-an-ai-takes-over-468c78f18f0d) +- [(Report) Julia Nikolaeva, iSpring - Big Ball of Mud and other problems of the monolith, we have handled](http://youtu.be/gna4Ynz1YNI) +- [(Article) DD - Big Ball of mud](https://thedomaindrivendesign.io/big-ball-of-mud/) + + +## Smart & Dumb components + +> About the approach; About applicability in the frontend; Methodology position + +About obsolescence, about a new view from the methodology + +Why component-containers approach is evil? + +- [(Article) Den Abramov-Presentation and Container Components (TLDR: deprecated)](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) + + +## Design Principles + +> What are we talking about; FSD position + +SOLID, GRASP, KISS, YAGNI, ... - and why they don't work well together in practice + +And how does it aggregate these practices + +- [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Design Principles)](https://youtu.be/SnzPAr_FJ7w?t=380) + + +## DDD + +> About the approach; Why does it work poorly in practice + +What is the difference, how does it improve applicability, where does it adopt practices + +- [(Article) DDD, Hexagonal, Onion, Clean, CQRS, ... How I put it all together](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) +- [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Clean Architecture, DDD)](https://youtu.be/SnzPAr_FJ7w?t=528) + + +## Clean Architecture + +> About the approach; About applicability in the frontend; FSD position + +How are they similar (to many), how are they different + +- [(Thread) About use-case/interactor in the methodology](https://t.me/feature_sliced/3897) +- [(Thread) About DI in the methodology](https://t.me/feature_sliced/4592) +- [(Article) Alex Bespoyasov - Clean Architecture on frontend](https://bespoyasov.me/blog/clean-architecture-on-frontend/) +- [(Article) DDD, Hexagonal, Onion, Clean, CQRS, ... How I put it all together](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) +- [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Clean Architecture, DDD)](https://youtu.be/SnzPAr_FJ7w?t=528) +- [(Article) Misconceptions of Clean Architecture](http://habr.com/ru/company/mobileup/blog/335382/) + + +## Frameworks + +> About applicability in the frontend; Why frameworks do not solve problems; why there is no single approach; FSD position + +Framework-agnostic, conventional-approach + +- [(Article) About the reasons for creating the methodology (fragment about frameworks)](/docs/about/motivation) +- [(Thread) About the applicability of the methodology for different frameworks](https://t.me/feature_sliced/3867) + + +## Atomic Design + +### What is it? +In Atomic Design, the scope of responsibility is divided into standardized layers. + +Atomic Design is broken down into **5 layers** (from top to bottom): + +1. `pages` - Functionality similar to the `pages` layer in FSD. +2. `templates` - Components that define the structure of a page without tying to specific content. +3. `organisms` - Modules consisting of molecules that have business logic. +4. `molecules` - More complex components that generally do not contain business logic. +5. `atoms` - UI components without business logic. + +Modules at one layer interact only with modules in the layers below, similar to FSD. +That is, molecules are built from atoms, organisms from molecules, templates from organisms, and pages from templates. +Atomic Design also implies the use of Public API within modules for isolation. + +### Applicability to frontend + +Atomic Design is relatively common in projects. Atomic Design is more popular among web designers than in development. +Web designers often use Atomic Design to create scalable and easily maintainable designs. +In development, Atomic Design is often mixed with other architectural methodologies. + +However, since Atomic Design focuses on UI components and their composition, a problem arises with implementing +business logic within the architecture. + +The problem is that Atomic Design does not provide a clear level of responsibility for business logic, +leading to its distribution across various components and levels, complicating maintenance and testing. +The business logic becomes blurred, making it difficult to clearly separate responsibilities and rendering +the code less modular and reusable. + +### How does it relate to FSD? + +In the context of FSD, some elements of Atomic Design can be applied to create flexible and scalable UI components. +The `atoms` and `molecules` layers can be implemented in `shared/ui` in FSD, simplifying the reuse and +maintenance of basic UI elements. + + +- shared/ + - ui/ + - atoms/ + - molecules/ + + +A comparison of FSD and Atomic Design shows that both methodologies strive for modularity and reusability +but focus on different aspects. Atomic Design is oriented towards visual components and their composition. +FSD focuses on dividing the application's functionality into independent modules and their interconnections. + +- [Atomic Design Methodology](https://atomicdesign.bradfrost.com/table-of-contents/) +- [(Thread) About applicability in shared / ui](https://t.me/feature_sliced/1653) +- [(Video) Briefly about Atomic Design](https://youtu.be/Yi-A20x2dcA) +- [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Atomic Design)](https://youtu.be/SnzPAr_FJ7w?t=587) + +## Feature Driven + +> About the approach; About applicability in the frontend; FSD position + +About compatibility, historical development and comparison + +- [(Talk) Oleg Isonen - Feature Driven Architecture](https://youtu.be/BWAeYuWFHhs) +- [Feature Driven-Short specification (from the point of view of FSD)](https://github.com/feature-sliced/documentation/tree/rc/feature-driven) diff --git a/src/content/docs/docs/about/mission.mdx b/src/content/docs/docs/about/mission.mdx new file mode 100644 index 0000000000..b7fd00b594 --- /dev/null +++ b/src/content/docs/docs/about/mission.mdx @@ -0,0 +1,51 @@ +--- +title: Mission +sidebar: + order: 1 +--- + +Here we describe the goals and limitations of the applicability of the methodology-which we are guided by when developing the methodology + +- We see our goal as a balance between ideology and simplicity +- We won't be able to make a silver bullet that fits everyone + +**Nevertheless, the methodology should be close and accessible to a fairly wide range of developers** + +## Goals + +### Intuitive clarity for a wide range of developers + +The methodology should be accessible - for most of the team in projects + +*Because even with all the future tools , it will not be enough, if only experienced seniors/leads will understand the methodology* + +### Solving everyday problems + +The methodology should set out the reasons and solutions to our everyday problems when developing projects + +**And also-attach tools to all this (cli, linters)** + +So that developers can use a *battle-tested* approach that allows them to bypass long-standing problems of architecture and development + +> *@sergeysova: Imagine, that a developer writes code within the framework of the methodology and he has problems 10 times less often, simply because other people have thought out the solution to many problems.* + +## Limitations + +We do not want to *impose our point of view*, and at the same time we understand that *many of our habits, as developers, interfere from day to day* + +Everyone has their own level of experience in designing and developing systems, **therefore, it is worth understanding the following:** + +- **Will not work**: very simple, very clear, for everyone + > *@sergeysova: Some concepts cannot be intuitively understood until you encounter problems and spend years solving them.* + > + > - *In math world: is graph theory.* + > - *In physics: quantum mechanics.* + > - *In programming: application architecture.* + +- **Possible and desirable**: simplicity, extensibility + +## See also + +- [Architecture problems][refs-architecture--problems] + +[refs-architecture--problems]: /docs/about/understanding/architecture#problems diff --git a/src/content/docs/docs/about/motivation.mdx b/src/content/docs/docs/about/motivation.mdx new file mode 100644 index 0000000000..231ff514b2 --- /dev/null +++ b/src/content/docs/docs/about/motivation.mdx @@ -0,0 +1,149 @@ +--- +title: Motivation +sidebar: + order: 2 +--- + +import { Aside } from '@astrojs/starlight/components'; + +The main idea of **Feature-Sliced Design** is to facilitate and reduce the cost of developing complex and developing projects, based on [combining research results, discussing the experience of various kinds of a wide range of developers][ext-discussions]. + +Obviously, this will not be a silver bullet, and of course, the methodology will have its own [limits of applicability][refs-mission]. + +Nevertheless, there are reasonable questions regarding *the feasibility of such a methodology as a whole* + + + +## Why are there not enough existing solutions? +> It usually, these arguments: +> +> - *"Why you need some new methodology, if you already have long-established approaches and principles of design such as `SOLID`, `KISS`, `YAGNI`, `DDD`, `GRASP`, `DRY`, etc."* +> - *"All the problems are solved by good project documentation, tests, and structured processes"* +> - *"Problems would not have happened if all developers are following all the above"* +> - *"Everything was invented before you, you just can't use it"* +> - *"Take \{FRAMEWORK_NAME\} - everything has already been decided for you there"* + +### Principles alone are not enough + +**The existence of principles alone is not enough to design a good architecture** + +Not everyone knows them completely, even fewer understand and apply them correctly + +*The design principles are too general, and do not give a specific answer to the question: "How to design the structure and architecture of a scalable and flexible application?"* + +### Processes don't always work + +*Documentation/Tests/Processes* are, of course, good, but alas, even at high costs for them - **they do not always solve the problems posed by the architecture and the introduction of new people into the project** + +- The time of entry of each developer into the project is not greatly reduced, because the documentation will most often come out huge / outdated +- Constantly make sure that everyone understands architecture in the same way-it also requires a huge amount of resources +- Do not forget about the bus-factor + +### Existing frameworks cannot be applied everywhere + +- Existing solutions usually have a high entry threshold, which makes it difficult to find new developers +- Also, most often, the choice of technology has already been determined before the onset of serious problems in the project, and therefore you need to be able to "work with what is" - **without being tied to the technology** + +> Q: *"In my project `React/Vue/Redux/Effector/Mobx/{YOUR_TECH}` - how can I better build the structure of entities and the relationships between them?"* + +### As a result + +We get *"unique as snowflakes"* projects, each of which requires a long immersion of the employee, and knowledge that is unlikely to be applicable on another project + +> @sergeysova: *"This is exactly the situation that currently exists in our field of frontend development: each lead will invent different architectures and project structures, while it is not a fact that these structures will pass the test of time, as a result, a maximum of two people can develop the project besides him, and each new developer needs to be immersed again."* + +## Why do developers need the methodology? + +### Focus on business features, not on architecture problems + +The methodology allows you to save resources on designing a scalable and flexible architecture, instead directing the attention of developers to the development of the main functionality. At the same time, the architectural solutions themselves are standardized from project to project. + +*A separate question is that the methodology should earn the trust of the community, so that another developer can get acquainted with it and rely on it in solving the problems of his project within the time available to him* + +### An experience-proven solution + +The methodology is designed for developers who are aimed at *a proven solution for designing complex business logic* + +*However, it is clear that the methodology is generally about a set of best-practices, articles that address certain problems and cases during development. Therefore, the methodology will also be useful for the rest of the developers-who somehow face problems during development and design* + +### Project Health + +The methodology will allow *to solve and track the problems of the project in advance, without requiring a huge amount of resources* + +**Most often, technical debt accumulates and accumulates over time, and the responsibility for its resolution lies on both the lead and the team** + +The methodology will allow you to *warn* possible problems in the scaling and development of the project in advance + +## Why does a business need a methodology? + +### Fast onboarding + +With the methodology, you can hire a person to the project who **is already previously familiar with this approach, and not train again** + +*People start to understand and benefit the project faster, and there are additional guarantees to find people for the next iterations of the project* + +### An experience-proven solution + +With the methodology, the business will get *a solution for most of the issues that arise during the development of systems* + +Since most often a business wants to get a framework / solution that would solve the lion's share of problems during the development of the project + +### Applicability for different stages of the project + +The methodology can benefit the project *both at the stage of project support and development, and at the MVP stage* + +Yes, the most important thing for MVP is *"features, not the architecture laid down for the future"*. But even in conditions of limited deadlines, knowing the best-practices from the methodology, you can *"do with little blood"*, when designing the MVP version of the system, finding a reasonable compromise +(rather than modeling features "at random") + +*The same can be said about testing* + +## When is our methodology not needed? + +- If the project will live for a short time +- If the project does not need a supported architecture +- If the business does not perceive the connection between the code base and the speed of feature delivery +- If it is more important for the business to close orders as soon as possible, without further support + +### Business Size + +- **Small business** - most often needs a ready-made and very fast solution. Only when the business grows (at least to almost average), he understands that in order for customers to continue using, it is necessary, among other things, to devote time to the quality and stability of the solutions being developed +- **Medium-sized business** - usually understands all the problems of development, and even if it is necessary to *"arrange a race for features"*, he still spends time on quality improvements, refactoring and tests (and of course-on an extensible architecture) +- **Big business** - usually already has an extensive audience, staff, and a much more extensive set of its practices, and probably even its own approach to architecture, so the idea of taking someone else's comes to them not so often + +## Plans + +The main part of the goals [is set out here][refs-mission--goals], but in addition, it is worth talking about our expectations from the methodology in the future + +### Combining experience + +Now we are trying to combine all our diverse experience of the `core-team`, and get a methodology hardened by practice as a result + +Of course, we can get Angular 3.0 as a result, but it is much more important here to **investigate the very problem of designing the architecture of complex systems** + +*And yes - we have complaints about the current version of the methodology, but we want to work together to come to a single and optimal solution (taking into account, among other things, the experience of the community)* + +### Life outside the specification + +If everything goes well, then the methodology will not be limited only to the specification and the toolkit + +- Perhaps there will be reports, articles +- There may be `CODE_MODEs` for migrations to other technologies of projects written according to the methodology +- It is possible that as a result we will be able to reach the maintainers of large technological solutions + - *Especially for React, compared to other frameworks - this is the main problem, because it does not say how to solve certain problems* + +## See also + +- [(Discussion) Don't need a methodology?][disc-src] +- [About the methodology's mission: goals and limitations][refs-mission] +- [Types of knowledge in the project][refs-knowledge] + +[refs-mission]: /docs/about/mission +[refs-mission--goals]: /docs/about/mission#goals +[refs-knowledge]: /docs/about/understanding/knowledge-types + +[disc-src]: https://github.com/feature-sliced/documentation/discussions/27 +[ext-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/docs/about/promote/for-company.mdx b/src/content/docs/docs/about/promote/for-company.mdx new file mode 100644 index 0000000000..a7c00b9e5d --- /dev/null +++ b/src/content/docs/docs/about/promote/for-company.mdx @@ -0,0 +1,16 @@ +--- +title: Promote in company +sidebar: + order: 4 + badge: + text: WIP + variant: caution +--- + +## Do the project and the company need a methodology? + +> About the justification of the application, Those duty + +## How can I submit a methodology to a business? + +## How to prepare and justify a plan to move to the methodology? diff --git a/src/content/docs/docs/about/promote/for-team.mdx b/src/content/docs/docs/about/promote/for-team.mdx new file mode 100644 index 0000000000..fa48ce19e1 --- /dev/null +++ b/src/content/docs/docs/about/promote/for-team.mdx @@ -0,0 +1,16 @@ +--- +title: Promote in team +sidebar: + order: 3 + badge: + text: WIP + variant: caution +--- + +- Onboard newcomers +- Development Guidelines ("where to search N module", etc...) +- New approach for tasks + +## See also +- [(Thread) The simplicity of the old approaches and the importance of mindfulness](https://t.me/feature_sliced/3360) +- [(Thread) About the convenience of searching by layers](https://t.me/feature_sliced/1918) diff --git a/src/content/docs/docs/about/promote/integration.mdx b/src/content/docs/docs/about/promote/integration.mdx new file mode 100644 index 0000000000..0fee7ab52f --- /dev/null +++ b/src/content/docs/docs/about/promote/integration.mdx @@ -0,0 +1,25 @@ +--- +title: Integration aspects +sidebar: + order: 1 +--- + +## Summary + +First 5 minutes (RU): + + + +## Also + +**Advantages**: +- [Overview](/docs/get-started/overview) +- CodeReview +- Onboarding + + +**Disadvantages:** +- Mental complexity +- High entry threshold +- "Layers hell" +- Typical problems of feature-based approaches diff --git a/src/content/docs/docs/about/promote/partial-application.mdx b/src/content/docs/docs/about/promote/partial-application.mdx new file mode 100644 index 0000000000..e01b7e07d2 --- /dev/null +++ b/src/content/docs/docs/about/promote/partial-application.mdx @@ -0,0 +1,10 @@ +--- +title: Partial Application +sidebar: + order: 2 + badge: + text: WIP + variant: caution +--- + +> How to partially apply the methodology? Does it make sense? What if I ignore it? diff --git a/src/content/docs/docs/about/understanding/abstractions.mdx b/src/content/docs/docs/about/understanding/abstractions.mdx new file mode 100644 index 0000000000..9fd9ace0fc --- /dev/null +++ b/src/content/docs/docs/about/understanding/abstractions.mdx @@ -0,0 +1,24 @@ +--- +title: Abstractions +sidebar: + order: 6 + badge: + text: WIP + variant: caution +--- + +## The law of leaky abstractions + +## Why are there so many abstractions + +> Abstractions help to cope with the complexity of the project. The question is - will these abstractions be specific only for this project, or will we try to derive general abstractions based on the specifics of the frontend + +> Architecture and applications in general are inherently complex, and the only question is how to better distribute and describe this complexity + +## About scopes of responsibility + +> About optional abstractions + +## See also +- [About the need for new layers](https://t.me/feature_sliced/2801) +- [About the difficulty in understanding the methodology and layers](https://t.me/feature_sliced/2619) diff --git a/src/content/docs/docs/about/understanding/architecture.mdx b/src/content/docs/docs/about/understanding/architecture.mdx new file mode 100644 index 0000000000..f0972bfc68 --- /dev/null +++ b/src/content/docs/docs/about/understanding/architecture.mdx @@ -0,0 +1,97 @@ +--- +title: About architecture +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +## Problems + +Usually, the conversation about architecture is raised when the development stops due to certain problems in the project. + +### Bus-factor & Onboarding + +Only a limited number of people understand the project and its architecture + +**Examples:** + +- *"It's difficult to add a person to the development"* +- *"For every problem, everyone has their own opinion on how to get around" (let's envy the angular)* +- *"I don't understand what is happening in this big piece of monolith"* + +### Implicit and uncontrolled consequences + +A lot of implicit side effects during development/refactoring *("everything depends on everything")* + +**Examples:** + +- *"The feature imports the feature"* +- *"I updated the store of one page, and the functionality fell off on the other"* +- *"The logic is smeared all over the application, and it is impossible to track where the beginning is, where the end is"* + +### Uncontrolled reuse of logic + +It is difficult to reuse/modify existing logic + +At the same time, there are usually [two extremes](https://github.com/feature-sliced/documentation/discussions/14): + +- Either the logic is written completely from scratch for each module *(with possible repetitions in the existing codebase)* +- Either there is a tendency to transfer all-all implemented modules to `shared` folders, thereby creating a large dump of modules *from it (where most are used only in one place)* + +**Examples:** + +- *"I have **N** implementations of the same business logic in my project, for which I still pay"* +- *"There are 6 different components of the button/pop-up/... In the project"* +- *"Dump of helpers"* + +## Requirements + +Therefore, it seems logical to present the desired *requirements for an ideal architecture:* + + + +### Explicitness + +- It should be **easy to master and explain** the project and its architecture to the team +- The structure should reflect the real **business values of the project** +- There must be explicit **side effects and connections** between abstractions +- It should be **easy to detect duplicate logic** without interfering with unique implementations +- There should be no **dispersion of logic** throughout the project +- There should not be **too many heterogeneous abstractions and rules** for a good architecture + +### Control + +- A good architecture should **speed up the solution of tasks, the introduction of features** +- It should be possible to control the development of the project +- It should be easy to **expand, modify, delete the code** +- The * decomposition and isolation of** functionality must be observed +- Each component of the system must be **easily replaceable and removable** + - *[No need to optimize for changes][ext-kof-not-modification] - we can't predict the future* + - *[Better-optimize for deletion][ext-kof-but-removing] - based on the context that already exists* + +### Adaptability + +- A good architecture should be applicable **to most projects** + - *With existing infrastructure solutions* + - *At any stage of development* +- There should be no dependence on the framework and platform +- It should be possible to **easily scale the project and the team**, with the possibility of parallelization of development +- It should be easy **to adapt to changing requirements and circumstances** + +## See also + +- [(React Berlin Talk) Oleg Isonen - Feature Driven Architecture][ext-kof] +- [(React SPB Meetup #1) Sergey Sova - Feature Slices][ext-slices-spb] +- [(Article) About project modularization][ext-medium] +- [(Article) About Separation of Concerns and structuring by features][ext-ryanlanciaux] + +[ext-kof-not-modification]: https://youtu.be/BWAeYuWFHhs?t=1631 +[ext-kof-but-removing]: https://youtu.be/BWAeYuWFHhs?t=1666 + +[ext-slices-spb]: https://t.me/feature_slices +[ext-kof]: https://youtu.be/BWAeYuWFHhs +[ext-medium]: https://alexmngn.medium.com/why-react-developers-should-modularize-their-applications-d26d381854c1 +[ext-ryanlanciaux]: https://ryanlanciaux.com/blog/2017/08/20/a-feature-based-approach-to-react-development/ diff --git a/src/content/docs/docs/about/understanding/knowledge-types.mdx b/src/content/docs/docs/about/understanding/knowledge-types.mdx new file mode 100644 index 0000000000..88e2ca4f49 --- /dev/null +++ b/src/content/docs/docs/about/understanding/knowledge-types.mdx @@ -0,0 +1,31 @@ +--- +title: Knowledge types in the project +sidebar: + label: Knowledge types + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + +The following "types of knowledge" can be distinguished in any project: + +* **Fundamental knowledge** + Knowledge that does not change much over time, such as algorithms, computer science, programming language mechanisms and its APIs. + +* **Technology stack** + Knowledge of the set of technical solutions used in a project, including programming languages, frameworks, and libraries. + +* **Project knowledge** + Knowledge that is specific to the current project and not valuable outside of it. This knowledge is essential for newly-onboarded developers to be able to contribute effectively. + + + +## See also \{#see-also\} + +- [(Video 🇷🇺) Ilya Klimov - On Types of Knowledge][ext-klimov] + +[ext-klimov]: https://youtu.be/4xyb_tA-uw0?t=249 diff --git a/src/content/docs/docs/about/understanding/naming.mdx b/src/content/docs/docs/about/understanding/naming.mdx new file mode 100644 index 0000000000..644366b492 --- /dev/null +++ b/src/content/docs/docs/about/understanding/naming.mdx @@ -0,0 +1,48 @@ +--- +title: Naming +sidebar: + order: 4 +--- + +Different developers have different experiences and contexts, which can lead to misunderstandings on the team when the same entities are called differently. For example: + +- Components for display can be called "ui", "components", "ui-kit", "views", … +- The code that is reused throughout the application can be called "core", "shared", "app", … +- Business logic code can be called "store", "model", "state", … + +## Naming in Feature-Sliced Design \{#naming-in-fsd\} + +The methodology uses specific terms such as: + +- "app", "process", "page", "feature", "entity", "shared" as layer names, +- "ui', "model", "lib", "api", "config" as segment names. + +It is very important to stick to these terms to prevent confusion among team members and new developers joining the project. Using standard names also helps when asking for help from the community. + +## Naming Conflicts \{#when-can-naming-interfere\} + +Naming conflicts can occur when terms used in the FSD methodology overlap with terms used in the business: + +- `FSD#process` vs simulated process in an application, +- `FSD#page` vs log page, +- `FSD#model` vs car model. + +For example, a developer who sees the word "process" in the code will spend extra time trying to figure out what process is meant. Such **collisions can disrupt the development process**. + +When the project glossary contains terminology specific to FSD, it is critical to be careful when discussing these terms with the team and technical disinterested parties. + +To communicate effectively with the team, it is recommended that the abbreviation "FSD" be used to prefix the methodology terms. For example, when talking about a process, you might say, "We can put this process on the FSD features layer." + +Conversely, when communicating with non-technical stakeholders, it is better to limit the use of FSD terminology and refrain from mentioning the internal structure of the code base. + +## See also \{#see-also\} + +- [(Discussion) Adaptability of naming][disc-src] +- [(Discussion) Entity Naming Survey][disc-naming] +- [(Discussion) "processes" vs "flows" vs ...][disc-processes] +- [(Discussion) "model" vs "store" vs ...][disc-model] + +[disc-model]: https://github.com/feature-sliced/documentation/discussions/68 +[disc-naming]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-464894 +[disc-processes]: https://github.com/feature-sliced/documentation/discussions/20 +[disc-src]: https://github.com/feature-sliced/documentation/discussions/16 diff --git a/src/content/docs/docs/about/understanding/needs-driven.mdx b/src/content/docs/docs/about/understanding/needs-driven.mdx new file mode 100644 index 0000000000..57763cc5ae --- /dev/null +++ b/src/content/docs/docs/about/understanding/needs-driven.mdx @@ -0,0 +1,162 @@ +--- +title: Needs driven +sidebar: + order: 2 +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +## Why? + +To choose a clear name for an entity and understand its components, **you need to clearly understand what task will be solved with the help of all this code.** + +> _@sergeysova: During development, we try to give each entity or function a name that clearly reflects the intentions and meaning of the code being executed._ + +_After all, without understanding the task, it is impossible to write the right tests that cover the most important cases, put down errors that help the user in the right places, even it is banal not to interrupt the user's flow because of fixable non-critical errors._ + +## What tasks are we talking about? + +Frontend develops applications and interfaces for end users, so we solve the tasks of these consumers. + +When a person comes to us, **he wants to solve some of his pain or close a need.** + +_The task of managers and analysts is to formulate this need, and implement developers taking into account the features of web development (loss of communication, backend error, typo, missed the cursor or finger)._ + +**This very goal, with which the user came, is the task of the developers.** + +> _One small solved problem is a feature in the Feature-Sliced Design methodology — you need to cut the entire scope of project tasks into small goals._ + +## How does this affect development? + +### Task decomposition + +When a developer begins to implement a task, in order to simplify the understanding and support of the code, he mentally **cuts it into stages**: + +* first _split into top-level entities_ and _implement them_, +* then these entities _split into smaller ones_ +* and so on + +_In the process of splitting into entities, the developer is forced to give them a name that would clearly reflect his idea and help to understand what task the code solves when reading the listing_ +_At the same time, we do not forget that we are trying to help the user reduce pain or realize needs_ + +### Understanding the essence of the task + +But to give a clear name to an entity, **the developer must know enough about its purpose** + +* how is he going to use this entity, +* what part of the user's task does it implement, where else can this entity be applied, +* in what other tasks can it participate, +* and so on + +It is not difficult to draw a conclusion: **while the developer will reflect on the name of entities within the framework of the methodology, he will be able to find poorly formulated tasks even before writing the code.** + +> How to give a name to an entity if you do not understand well what tasks it can solve, how can you even divide a task into entities if you do not understand it well? + +## How to formulate it? + +**To formulate a task that is solved by features, you need to understand the task itself**, and this is already the responsibility of the project manager and analysts. + +_The methodology can only tell the developer what tasks the product manager should pay close attention to._ + +> _@sergeysova: the Whole frontend is primarily a display of information, any component in the first turn, displays, and then the task "to show the user something" has no practical value._ +> +> _Even without taking into account the specifics of the frontend can ask, "why do I have to show you", so you can continue to ask until't get out of pain or the need of the consumer._ + +As soon as we were able to get to the basic needs or pains, we can go back and figure out **how exactly your product or service can help the user with his goals** + +Any new task in your tracker is aimed at solving business problems, and the business tries to solve the user's tasks at the same time earning money on it. This means that each task has certain goals, even if they are not spelled out in the description text. + +_**The developer must clearly understand what goal this or that task is pursuing**, but not every company can afford to build processes perfectly, although this is a separate conversation, nevertheless, the developer may well "ping" the right managers himself to find out this and do his part of the work effectively._ + +## And what is the benefit? + +Now let's look at the whole process from beginning to end. + +### 1. Understanding user tasks + +When a developer understands his pain and how the business closes them, he can offer solutions that are not available to the business due to the specifics of web development. + +> But of course, all this can work only if the developer is not indifferent to what he is doing and for what, otherwise _why then the methodology and some approaches?_ + +### 2. Structuring and ordering + +With the understanding of tasks comes **a clear structure both in the head and in the tasks along with the code** + +### 3. Understanding the feature and its components + +**One feature is one useful functionality for the user** + +* When several features are implemented in one feature, this is **a violation of borders** +* The feature can be indivisible and growing - **and this is not bad** +* **Bad** - when the feature does not answer the question _"What is the business value for the user?"_ +* There can be no "map-office" feature + * But `booking-meeting-on-the-map`, `search-for-an-employee`, `change-of-workplace` - **yes** + +> _@sergeysova: The point is that the feature contains only code that implements the functionality itself_, without unnecessary details and internal solutions (ideally)* +> +> *Open the feature code **and see only what relates to the task** - no more* + +### 4. Profit + +Business very rarely turns its course radically in the other direction, which means **the reflection of business tasks in the frontend application code is a very significant profit.** + +_Then you don't have to explain to each new team member what this or that code does, and in general why it was added - **everything will be explained through the business tasks that are already reflected in the code.**_ + +> What is called ["Business Language" in Domain Driven Development][ext-ubiq-lang] + +--- + +## Back to reality + +If business processes are understood and good names are given at the design stage - _then it is not particularly problematic to transfer this understanding and logic to the code._ + +**However, in practice**, tasks and functionality are usually developed "too" iteratively and (or) there is no time to think through the design. + +**As a result, the feature makes sense today, and if you expand this feature in a month, you can rewrite the gender of the project.** + +> *[[From the discussion][disc-src]]: The developer tries to think 2-3 steps ahead, taking into account future wishes, but here he rests on his own experience* +> +> _Burns experience engineer usually immediately looking 10 steps ahead, and understand where one feature to divide and combine with the other_ +> +> _But sometimes that comes the task which had to face the experience, and nowhere to take the understanding of how literacy to decompose, with the least unfortunate consequences in the future_ + +## The role of methodology + +**The methodology helps to solve the problems of developers, so that it is easier to solve the problems of users.** + +There is no solution to the problems of developers only for the sake of developers + +But in order for the developer to solve his tasks, **you need to understand the user's tasks** - on the contrary, it will not work + +### Methodology requirements + +It becomes clear that you need to identify at least two requirements for **Feature-Sliced Design**: + +1. The methodology should tell **how to create features, processes and entities** + + * Which means it should clearly explain _how to divide the code between them_, which means that the naming of these entities should also be laid down in the specification. + +2. The methodology should help the architecture **[easily adapt to the changing requirements of the project][refs-arch--adaptability]** + +## See also + +* [(Post) Stimulation for a clear formulation of tasks (+ discussion)][disc-src] + > _**The current article** is an adaptation of this discussion, you can read the full uncut version at the link_ +* [(Discussion) How to break the functionality and what it is][tg-src] +* [(Article) "How to better organize your applications"][ext-medium] + +[refs-arch--adaptability]: architecture#adaptability + +[ext-medium]: https://alexmngn.medium.com/how-to-better-organize-your-react-applications-2fd3ea1920f1 +[disc-src]: https://t.me/sergeysova/318 +[tg-src]: https://t.me/atomicdesign/18972 +[ext-ubiq-lang]: https://thedomaindrivendesign.io/developing-the-ubiquitous-language diff --git a/src/content/docs/docs/about/understanding/signals.mdx b/src/content/docs/docs/about/understanding/signals.mdx new file mode 100644 index 0000000000..07b8fba8a3 --- /dev/null +++ b/src/content/docs/docs/about/understanding/signals.mdx @@ -0,0 +1,19 @@ +--- +title: Signals of architecture +sidebar: + order: 5 + badge: + text: WIP + variant: caution +--- + +> If there is a limitation on the part of the architecture, then there are obvious reasons for this, and consequences if they are ignored + +> The methodology and architecture gives signals, and how to deal with it depends on what risks you are ready to take on and what is most suitable for your team) + +## See also + +- [(Thread) About signals from architecture and dataflow](https://t.me/feature_sliced/2070) +- [(Thread) About the fundamental nature of architecture](https://t.me/feature_sliced/2492) +- [(Thread) About highlighting weak points](https://t.me/feature_sliced/3979) +- [(Thread) How to understand that the data model is swollen](https://t.me/feature_sliced/4228) diff --git a/src/content/docs/docs/branding.mdx b/src/content/docs/docs/branding.mdx new file mode 100644 index 0000000000..2c7993af07 --- /dev/null +++ b/src/content/docs/docs/branding.mdx @@ -0,0 +1,81 @@ +--- +title: Branding Guidelines +--- + +import { Aside } from '@astrojs/starlight/components'; + +FSD's visual identity is based on its core-concepts: `Layered`, `Sliced self-contained parts`, `Parts & Compose`, `Segmented`. +But also we tend to design simple, pretty identity, which should convey the FSD philisophy and be easy to recognize. + +**Please, use FSD's identity "as-is", without changes but with our assets for your comfort.** This brand guide will help you to use FSD's identity correctly. + + + +## Title + +- ✅ **Correct:** `Feature-Sliced Design`, `FSD` +- ❌ **Incorrect:** `Feature-Sliced`, `Feature Sliced`, `FeatureSliced`, `feature-sliced`, `feature sliced`, `FS` + +## Emojii + +The cake 🍰 image represents FSD core concepts quite well, so it has been chosen as our signature emoji + +> Example: *"🍰 Architectural design methodology for Frontend projects"* + +## Logo & Palette + +FSD has few variations of logo for different context, but it recommended to prefer **primary** + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ThemeLogo (Ctrl/Cmd + Click for download)Usage
primary
(#29BEDC, #517AED)
logo-primaryPreferred in most cases
flat
(#3193FF)
logo-flatFor one-color context
monochrome
(#FFF)
logo-monochromeFor grayscale context
square
(#3193FF)
logo-squareFor square boundaries
+ +## Banners & Schemes + +banner-primary +banner-monochrome + +## Social Preview + +Work in progress... + +## Presentation template + +Work in progress... + +## See also + +- [Discussion (github)](https://github.com/feature-sliced/documentation/discussions/399) +- [History of development with references (figma)](https://www.figma.com/file/RPphccpoeasVB0lMpZwPVR/FSD-Brand?node-id=0%3A1) diff --git a/src/content/docs/docs/get-started/faq.mdx b/src/content/docs/docs/get-started/faq.mdx new file mode 100644 index 0000000000..9f1f00c4fb --- /dev/null +++ b/src/content/docs/docs/get-started/faq.mdx @@ -0,0 +1,68 @@ +--- +# pagination_next: guides/examples/auth +title: FAQ +sidebar: + order: 20 +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +### Is there a toolkit or a linter? + +Yes! We have a linter called [Steiger][ext-steiger] to check your project's architecture and [folder generators][ext-tools] through a CLI or IDEs. + +### Where to store the layout/template of pages? + +If you need plain markup layouts, you can keep them in `shared/ui`. If you need to use higher layers inside, there are a few options: + +- Perhaps you don't need layouts at all? If the layout is only a few lines, it might be reasonable to duplicate the code in each page rather than try to abstract it. +- If you do need layouts, you can have them as separate widgets or pages, and compose them in your router configuration in App. Nested routing is another option. + +### What is the difference between a feature and an entity? + +An _entity_ is a real-life concept that your app is working with. A _feature_ is an interaction that provides real-life value to your app’s users, the thing people want to do with your entities. + +For more information, along with examples, see the Reference page on [slices][reference-entities]. + +### Can I embed pages/features/entities into each other? + +Yes, but this embedding should happen in higher layers. For example, inside a widget, you can import both features and then insert one feature into another as props/children. + +You cannot import one feature from another feature, this is prohibited by the [**import rule on layers**][import-rule-layers]. + +### What about Atomic Design? + +The current version of the methodology does not require nor prohibit the use of Atomic Design together with Feature-Sliced Design. + +For example, Atomic Design [can be applied well](https://t.me/feature_sliced/1653) for the `ui` segment of modules. + +### Are there any useful resources/articles/etc. about FSD? + +Yes! https://github.com/feature-sliced/awesome + +### Why do I need Feature-Sliced Design? + +It helps you and your team to quickly overview the project in terms of its main value-bringing components. A standardized architecture helps to speed up onboarding and resolves debates about code structure. See the [motivation][motivation] page to learn more about why FSD was created. + +### Does a novice developer need an architecture/methodology? + +Rather yes than no + +*Usually, when you design and develop a project in one person, everything goes smoothly. But if there are pauses in development, new developers are added to the team - then problems come* + +### How do I work with the authorization context? + +Answered [here](/docs/guides/examples/auth) + +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[import-rule-layers]: /docs/reference/layers#import-rule-on-layers +[reference-entities]: /docs/reference/layers#entities +[motivation]: /docs/about/motivation +[telegram]: https://t.me/feature_sliced +[discord]: https://discord.gg/S8MzWTUsmp +[github-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/docs/get-started/overview.mdx b/src/content/docs/docs/get-started/overview.mdx new file mode 100644 index 0000000000..7cd85d42ad --- /dev/null +++ b/src/content/docs/docs/get-started/overview.mdx @@ -0,0 +1,166 @@ +--- +title: Overview +sidebar: + order: 1 +--- + +import { FileTree, LinkCard, Aside } from '@astrojs/starlight/components'; + +**Feature-Sliced Design** (FSD) is an architectural methodology for scaffolding front-end applications. Simply put, it's a compilation of rules and conventions on organizing code. The main purpose of this methodology is to make the project more understandable and stable in the face of ever-changing business requirements. + +Apart from a set of conventions, FSD is also a toolchain. We have a [linter][ext-steiger] to check your project's architecture, [folder generators][ext-tools] through a CLI or IDEs, as well as a rich library of [examples][examples]. + +## Is it right for me? \{#is-it-right-for-me\} + +FSD can be implemented in projects and teams of any size. It is right for your project if: + +- You're doing **frontend** (UI on web, mobile, desktop, etc.) +- You're building an **application**, not a library + +And that's it! There are no restrictions on what programming language, UI framework, or state manager you use. You can also adopt FSD incrementally, use it in monorepos, and scale to great lengths by breaking your app into packages and implementing FSD individually within them. + +If you already have an architecture and you're considering a switch to FSD, make sure that the current architecture is **causing trouble** in your team. For example, if your project has grown too large and inter-connected to efficiently implement new features, or if you're expecting a lot of new members to join the team. If the current architecture works, maybe it's not worth changing. But if you do decide to migrate, see the [Migration][migration] section for guidance. + +## Basic example \{#basic-example\} + +Here is a simple project that implements FSD: + + + +- app/ +- pages/ +- shared/ + + + +These top-level folders are called _layers_. Let's look deeper: + + + +- app/ + - routes/ + - analytics/ +- pages/ + - home/ + - article-reader/ + - ui/ + - api/ + - setings/ +- shared/ + - ui/ + - api/ + + + +Folders inside `📂 pages` are called _slices_. They divide the layer by domain (in this case, by pages). + +Folders inside `📂 app`, `📂 shared`, and `📂 pages/article-reader` are called _segments_, and they divide slices (or layers) by technical purpose, i.e. what the code is for. + +## Concepts \{#concepts\} + +Layers, slices, and segments form a hierarchy like this: + +
+ ![Hierarchy of FSD concepts, described below](../../../../../static/img/visual_schema.jpg) + +
+

Pictured above: three pillars, labeled left to right as "Layers", "Slices", and "Segments" respectively.

+

The "Layers" pillar contains seven divisions arranged top to bottom and labeled "app", "processes", "pages", "widgets", "features", "entities", and "shared". The "processes" division is crossed out. The "entities" division is connected to the second pillar "Slices" in a way that conveys that the second pillar is the content of "entities".

+

The "Slices" pillar contains three divisions arranged top to bottom and labeled "user", "post", and "comment". The "post" division is connected to the third pillar "Segments" in the same way such that it's the content of "post".

+

The "Segments" pillar contains three divisions, arranged top to bottom and labeled "ui", "model", and "api".

+
+
+ +### Layers \{#layers\} + +Layers are standardized across all FSD projects. You don't have to use all of the layers, but their names are important. There are currently seven of them (from top to bottom): + +1. **App** — everything that makes the app run — routing, entrypoints, global styles, providers. +2. **Processes** (deprecated) — complex inter-page scenarios. +3. **Pages** — full pages or large parts of a page in nested routing. +4. **Widgets** — large self-contained chunks of functionality or UI, usually delivering an entire use case. +5. **Features** — _reused_ implementations of entire product features, i.e. actions that bring business value to the user. +6. **Entities** — business entities that the project works with, like `user` or `product`. +7. **Shared** — reusable functionality, especially when it's detached from the specifics of the project/business, though not necessarily. + + +The trick with layers is that modules on one layer can only know about and import from modules from the layers strictly below. + +### Slices \{#slices\} + +Next up are slices, which partition the code by business domain. You're free to choose any names for them, and create as many as you wish. Slices make your codebase easier to navigate by keeping logically related modules close together. + +Slices cannot use other slices on the same layer, and that helps with high cohesion and low coupling. + +### Segments \{#segments\} + +Slices, as well as layers App and Shared, consist of segments, and segments group your code by its purpose. Segment names are not constrained by the standard, but there are several conventional names for the most common purposes: + +- `ui` — everything related to UI display: UI components, date formatters, styles, etc. +- `api` — backend interactions: request functions, data types, mappers, etc. +- `model` — the data model: schemas, interfaces, stores, and business logic. +- `lib` — library code that other modules on this slice need. +- `config` — configuration files and feature flags. + +Usually these segments are enough for most layers, you would only create your own segments in Shared or App, but this is not a rule. + +## Advantages \{#advantages\} + +- **Uniformity** + Since the structure is standardized, projects become more uniform, which makes onboarding new members easier for the team. + +- **Stability in face of changes and refactoring** + A module on one layer cannot use other modules on the same layer, or the layers above. + This allows you to make isolated modifications without unforeseen consequences to the rest of the app. + +- **Controlled reuse of logic** + Depending on the layer, you can make code very reusable or very local. + This keeps a balance between following the **DRY** principle and practicality. + +- **Orientation to business and users needs** + The app is split into business domains and usage of the business language is encouraged in naming, so that you can do useful product work without fully understanding all other unrelated parts of the project. + +## Incremental adoption \{#incremental-adoption\} + +If you have an existing codebase that you want to migrate to FSD, we suggest the following strategy. We found it useful in our own migration experience. + +1. Start by slowly shaping up the App and Shared layers module-by-module to create a foundation. + +2. Distribute all of the existing UI across Widgets and Pages using broad strokes, even if they have dependencies that violate the rules of FSD. + +3. Start gradually resolving import violations and also extracting Entities and possibly even Features. + +It's advised to refrain from adding new large entities while refactoring or refactoring only certain parts of the project. + +## Next steps \{#next-steps\} + + + + + + + +[tutorial]: /docs/get-started/tutorial +[examples]: /examples +[migration]: /docs/guides/migration/from-custom +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[ext-telegram]: https://t.me/feature_sliced diff --git a/src/content/docs/docs/get-started/tutorial.mdx b/src/content/docs/docs/get-started/tutorial.mdx new file mode 100644 index 0000000000..7d2eb61f13 --- /dev/null +++ b/src/content/docs/docs/get-started/tutorial.mdx @@ -0,0 +1,2265 @@ +--- +title: Tutorial +sidebar: + order: 2 +--- + +import { FileTree, Code } from '@astrojs/starlight/components'; + +## Part 1. On paper + +This tutorial will examine the Real World App, also known as Conduit. Conduit is a basic [Medium](https://medium.com/) clone — it lets you read and write articles as well as comment on the articles of others. + +![Conduit home page](../../../../../static/img/tutorial/realworld-feed-anonymous.jpg) + +This is a pretty small application, so we will keep it simple and avoid excessive decomposition. It’s highly likely that the entire app will fit into just three layers: **App**, **Pages**, and **Shared**. If not, we will introduce additional layers as we go. Ready? + +### Start by listing the pages + +If we look at the screenshot above, we can assume at least the following pages: + +- Home (article feed) +- Sign in and sign up +- Article reader +- Article editor +- User profile viewer +- User profile editor (user settings) + +Every one of these pages will become its own *slice* on the Pages *layer*. Recall from the overview that slices are simply folders inside of layers and layers are simply folders with predefined names like `pages`. + +As such, our Pages folder will look like this: + + +- pages/ + - feed/ + - sign-in/ + - article-read/ + - article-edit/ + - profile/ + - settings/ + + +The key difference of Feature-Sliced Design from an unregulated code structure is that pages cannot reference each other. That is, one page cannot import code from another page. This is due to the **import rule on layers**: + +*A module (file) in a slice can only import other slices when they are located on layers strictly below.* + +In this case, a page is a slice, so modules (files) inside this page can only reference code from layers below, not from the same layer, Pages. + +### Close look at the feed + +
+ ![Anonymous user’s perspective](../../../../../static/img/tutorial/realworld-feed-anonymous.jpg) +
+ _Anonymous user’s perspective_ +
+
+ +
+ ![Authenticated user’s perspective](../../../../../static/img/tutorial/realworld-feed-authenticated.jpg) +
+ _Authenticated user’s perspective_ +
+
+ +There are three dynamic areas on the feed page: + +1. Sign-in links with an indication if you are signed in +2. List of tags that triggers filtering in the feed +3. One/two feeds of articles, each article with a like button + +The sign-in links are a part of a header that is common to all pages, we will revisit it separately. + +#### List of tags + +To build the list of tags, we need to fetch the available tags, render each tag as a chip, and store the selected tags in a client-side storage. These operations fall into categories “API interaction”, “user interface”, and “storage”, respectively. In Feature-Sliced Design, code is separated by purpose using *segments*. Segments are folders in slices, and they can have arbitrary names that describe the purpose, but some purposes are so common that there’s a convention for certain segment names: + +- 📂 `api/` for backend interactions +- 📂 `ui/` for code that handles rendering and appearance +- 📂 `model/` for storage and business logic +- 📂 `config/` for feature flags, environment variables and other forms of configuration + +We will place code that fetches tags into `api`, the tag component into `ui`, and the storage interaction into `model`. + +#### Articles + +Using the same grouping principles, we can decompose the feed of articles into the same three segments: + +- 📂 `api/`: fetch paginated articles with like count; like an article +- 📂 `ui/`: + - tab list that can render an extra tab if a tag is selected + - individual article + - functional pagination +- 📂 `model/`: client-side storage of the currently loaded articles and current page (if needed) + +### Reuse generic code + +Most pages are very different in intent, but certain things stay the same across the entire app — for example, the UI kit that conforms to the design language, or the convention on the backend that everything is done with a REST API with the same authentication method. Since slices are meant to be isolated, code reuse is facilitated by a lower layer, **Shared**. + +Shared is different from other layers in the sense that it contains segments, not slices. In this way, the Shared layer can be thought of as a hybrid between a layer and a slice. + +Usually, the code in Shared is not planned ahead of time, but rather extracted during development, because only during development does it become clear which parts of code are actually shared. However, it’s still helpful to keep a mental note of what kind of code naturally belongs in Shared: + +- 📂 `ui/` — the UI kit, pure appearance, no business logic. For example, buttons, modal dialogs, form inputs. +- 📂 `api/` — convenience wrappers around request making primitives (like `fetch()` on the Web) and, optionally, functions for triggering particular requests according to the backend specification. +- 📂 `config/` — parsing environment variables +- 📂 `i18n/` — configuration of language support +- 📂 `router/` — routing primitives and route constants + +Those are just a few examples of segment names in Shared, but you can omit any of them or create your own. The only important thing to remember when creating new segments is that segment names should describe **purpose (the why), not essence (the what)**. Names like “components”, “hooks”, “modals” *should not* be used because they describe what these files are, but don’t help to navigate the code inside. This requires people on the team to dig through every file in such folders and also keeps unrelated code close, which leads to broad areas of code being affected by refactoring and thus makes code review and testing harder. + +### Define a strict public API + +In the context of Feature-Sliced Design, the term *public API* refers to a slice or segment declaring what can be imported from it by other modules in the project. For example, in JavaScript that can be an `index.js` file re-exporting objects from other files in the slice. This enables freedom in refactoring code inside a slice as long as the contract with the outside world (i.e. the public API) stays the same. + +For the Shared layer that has no slices, it’s usually more convenient to define a separate public API for each segment as opposed to defining one single index of everything in Shared. This keeps imports from Shared naturally organized by intent. For other layers that have slices, the opposite is true — it’s usually more practical to define one index per slice and let the slice decide its own set of segments that is unknown to the outside world because other layers usually have a lot less exports. + +Our slices/segments will appear to each other as follows: + + +- pages/ + - feed/ + - index + - sign-in/ + - index + - article-read/ + - index + - ... +- shared/ + - ui/ + - index + - api/ + - index + - ... + + +Whatever is inside folders like `pages/feed` or `shared/ui` is only known to those folders, and other files should not rely on the internal structure of these folders. + +### Large reused blocks in the UI + +Earlier we made a note to revisit the header that appears on every page. Rebuilding it from scratch on every page would be impractical, so it’s only natural to want to reuse it. We already have Shared to facilitate code reuse, however, there’s a caveat to putting large blocks of UI in Shared — the Shared layer is not supposed to know about any of the layers above. + +Between Shared and Pages there are three other layers: Entities, Features, and Widgets. Some projects may have something in those layers that they need in a large reusable block, and that means we can’t put that reusable block in Shared, or else it would be importing from upper layers, which is prohibited. That’s where the Widgets layer comes in. It is located above Shared, Entities, and Features, so it can use them all. + +In our case, the header is very simple — it’s a static logo and top-level navigation. The navigation needs to make a request to the API to determine if the user is currently logged in or not, but that can be handled by a simple import from the `api` segment. Therefore, we will keep our header in Shared. + +### Close look at a page with a form + +Let’s also examine a page that’s intended for editing, not reading. For example, the article writer: + +![Conduit post editor](../../../../../static/img/tutorial/realworld-editor-authenticated.jpg) + +It looks trivial, but contains several aspects of application development that we haven’t explored yet — form validation, error states, and data persistence. + +If we were to build this page, we would grab some inputs and buttons from Shared and put together a form in the `ui` segment of this page. Then, in the `api` segment, we would define a mutation request to create the article on the backend. + +To validate the request before sending, we need a validation schema, and a good place for it is the `model` segment, since it’s the data model. There we will produce error messages and display them using another component in the `ui` segment. + +To improve user experience, we could also persist the inputs to prevent accidental data loss. This is also a job of the `model` segment. + +### Summary + +We have examined several pages and outlined a preliminary structure for our application: + +1. Shared layer + 1. `ui` will contain our reusable UI kit + 2. `api` will contain our primitive interactions with the backend + 3. The rest will be arranged on demand +2. Pages layer — each page is a separate slice + 1. `ui` will contain the page itself and all of its parts + 2. `api` will contain more specialized data fetching, using `shared/api` + 3. `model` might contain client-side storage of the data that we will display + +Let’s get building! + +## Part 2. In code + +Now that we have a plan, let’s put it to practice. We will use React and [Remix](https://remix.run). + +There's a template ready for this project, clone it from GitHub to get a headstart: [https://github.com/feature-sliced/tutorial-conduit/tree/clean](https://github.com/feature-sliced/tutorial-conduit/tree/clean). + +Install dependencies with `npm install` and start the development server with `npm run dev`. Open [http://localhost:3000](http://localhost:3000) and you should see a blank app. + +### Lay out the pages + +Let’s start by creating blank components for all our pages. Run the following command in your project: + +```bash +npx fsd pages feed sign-in article-read article-edit profile settings --segments ui +``` + +This will create folders like `pages/feed/ui/` and an index file, `pages/feed/index.ts`, for every page. + +### Connect the feed page + +Let’s connect the root route of our application to the feed page. Create a component, `FeedPage.tsx` in `pages/feed/ui` and put the following inside it: + + +```tsx title="pages/feed/ui/FeedPage.tsx" +export function FeedPage() { + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+
+ ); +} +``` + +Then re-export this component in the feed page’s public API, the `pages/feed/index.ts` file: + + + +```ts title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +``` + +Now connect it to the root route. In Remix, routing is file-based, and the route files are located in the `app/routes` folder, which nicely coincides with Feature-Sliced Design. + +Use the `FeedPage` component in `app/routes/_index.tsx`: + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +Then, if you run the dev server and open the application, you should see the Conduit banner! + +![The banner of Conduit](../../../../../static/img/tutorial/conduit-banner.jpg) + +### API client + +To talk to the RealWorld backend, let’s create a convenient API client in Shared. Create two segments, `api` for the client and `config` for variables like the backend base URL: + +```bash +npx fsd shared --segments api config +``` + +Then create `shared/config/backend.ts`: + +```tsx title="shared/config/backend.ts" +export { mockBackendUrl as backendBaseUrl } from "mocks/handlers"; +``` + +```tsx title="shared/config/index.ts" +export { backendBaseUrl } from "./backend"; +``` + +Since the RealWorld project conveniently provides an [OpenAPI specification](https://github.com/gothinkster/realworld/blob/main/api/openapi.yml), we can take advantage of auto-generated types for our client. We will use [the `openapi-fetch` package](https://openapi-ts.pages.dev/openapi-fetch/) that comes with an additional type generator. + +Run the following command to generate up-to-date API typings: + +```bash +npm run generate-api-types +``` + +This will create a file `shared/api/v1.d.ts`. We will use this file to create a typed API client in `shared/api/client.ts`: + +```tsx title="shared/api/client.ts" +import createClient from "openapi-fetch"; + +import { backendBaseUrl } from "shared/config"; +import type { paths } from "./v1"; + +export const { GET, POST, PUT, DELETE } = createClient({ baseUrl: backendBaseUrl }); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; +``` + +### Real data in the feed + +We can now proceed to adding articles to the feed, fetched from the backend. Let’s begin by implementing an article preview component. + +Create `pages/feed/ui/ArticlePreview.tsx` with the following content: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +export function ArticlePreview({ article }) { /* TODO */ } +``` + +Since we’re writing in TypeScript, it would be nice to have a typed article object. If we explore the generated `v1.d.ts`, we can see that the article object is available through `components["schemas"]["Article"]`. So let’s create a file with our data models in Shared and export the models: + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; +``` + +Now we can come back to the article preview component and fill the markup with data. Update the component with the following content: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+ +
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +The like button doesn’t do anything for now, we will fix that when we get to the article reader page and implement the liking functionality. + +Now we can fetch the articles and render out a bunch of these cards. Fetching data in Remix is done with *loaders* — server-side functions that fetch exactly what a page needs. Loaders interact with the API on the page’s behalf, so we will put them in the `api` segment of a page: + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; + +import { GET } from "shared/api"; + +export const loader = async () => { + const { data: articles, error, response } = await GET("/articles"); + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return json({ articles }); +}; +``` + +To connect it to the page, we need to export it with the name `loader` from the route file: + +```tsx title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +export { loader } from "./api/loader"; +``` + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export { loader } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +And the final step is to render these cards in the feed. Update your `FeedPage` with the following code: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+
+
+
+ ); +} +``` + +### Filtering by tag + +Regarding the tags, our job is to fetch them from the backend and to store the currently selected tag. We already know how to do fetching — it’s another request from the loader. We will use a convenience function `promiseHash` from a package `remix-utils`, which is already installed. + +Update the loader file, `pages/feed/api/loader.ts`, with the following code: + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async () => { + return json( + await promiseHash({ + articles: throwAnyErrors(GET("/articles")), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +You might notice that we extracted the error handling into a generic function `throwAnyErrors`. It looks pretty useful, so we might want to reuse it later, but for now let’s just keep an eye on it. + +Now, to the list of tags. It needs to be interactive — clicking on a tag should make that tag selected. By Remix convention, we will use the URL search parameters as our storage for the selected tag. Let the browser take care of storage while we focus on more important things. + +Update `pages/feed/ui/FeedPage.tsx` with the following code: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles, tags } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +Then we need to use the `tag` search parameter in our loader. Change the `loader` function in `pages/feed/api/loader.ts` to the following: + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { params: { query: { tag: selectedTag } } }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +That’s it, no `model` segment necessary. Remix is pretty neat. + +### Pagination + +In a similar fashion, we can implement the pagination. Feel free to give it a shot yourself or just copy the code below. There’s no one to judge you anyway. + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const [searchParams] = useSearchParams(); + const { articles, tags } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} + +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +So that’s also done. There’s also the tab list that can be similarly implemented, but let’s hold on to that until we implement authentication. Speaking of which! + +### Authentication + +Authentication involves two pages — one to login and another to register. They are mostly the same, so it makes sense to keep them in the same slice, `sign-in`, so that they can reuse code if needed. + +Create `RegisterPage.tsx` in the `ui` segment of `pages/sign-in` with the following content: + +```tsx title="pages/sign-in/ui/RegisterPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { register } from "../api/register"; + +export function RegisterPage() { + const registerData = useActionData(); + + return ( +
+
+
+
+

Sign up

+

+ Have an account? +

+ + {registerData?.error && ( +
    + {registerData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +We have a broken import to fix now. It involves a new segment, so create that: + +```bash +npx fsd pages sign-in -s api +``` + +However, before we can implement the backend part of registering, we need some infrastructure code for Remix to handle sessions. That goes to Shared, in case any other page needs it. + +Put the following code in `shared/api/auth.server.ts`. This is highly Remix-specific, so don’t worry too much about it, just copy-paste: + +```tsx title="shared/api/auth.server.ts" +import { createCookieSessionStorage, redirect } from "@remix-run/node"; +import invariant from "tiny-invariant"; + +import type { User } from "./models"; + +invariant( + process.env.SESSION_SECRET, + "SESSION_SECRET must be set for authentication to work", +); + +const sessionStorage = createCookieSessionStorage<{ + user: User; +}>({ + cookie: { + name: "__session", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [process.env.SESSION_SECRET], + secure: process.env.NODE_ENV === "production", + }, +}); + +export async function createUserSession({ + request, + user, + redirectTo, +}: { + request: Request; + user: User; + redirectTo: string; +}) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.set("user", user); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 7, // 7 days + }), + }, + }); +} + +export async function getUserFromSession(request: Request) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + return session.get("user") ?? null; +} + +export async function requireUser(request: Request) { + const user = await getUserFromSession(request); + + if (user === null) { + throw redirect("/login"); + } + + return user; +} +``` + +And also export the `User` model from the `models.ts` file right next to it: + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +export type User = components["schemas"]["User"]; +``` + +Before this code can work, the `SESSION_SECRET` environment variable needs to be set. Create a file called `.env` in the root of the project, write `SESSION_SECRET=` and then mash some keys on your keyboard to create a long random string. You should get something like this: + +```bash title=".env" +SESSION_SECRET=dontyoudarecopypastethis +``` + +Finally, add some exports to the public API to make use of this code: + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +``` + +Now we can write the code that will talk to the RealWorld backend to actually do the registration. We will keep that in `pages/sign-in/api`. Create a file called `register.ts` and put the following code inside: + +```tsx title="pages/sign-in/api/register.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const register = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const username = formData.get("username")?.toString() ?? ""; + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users", { + body: { user: { email, password, username } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +``` + +Almost done! Just need to connect the page and action to the `/register` route. Create `register.tsx` in `app/routes`: + +```tsx title="app/routes/register.tsx" +import { RegisterPage, register } from "pages/sign-in"; + +export { register as action }; + +export default RegisterPage; +``` + +Now if you go to [http://localhost:3000/register](http://localhost:3000/register), you should be able to create a user! The rest of the application won’t react to this yet, we’ll address that momentarily. + +In a very similar way, we can implement the login page. Give it a try or just grab the code and move on: + +```tsx title="pages/sign-in/api/sign-in.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const signIn = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users/login", { + body: { user: { email, password } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/ui/SignInPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { signIn } from "../api/sign-in"; + +export function SignInPage() { + const signInData = useActionData(); + + return ( +
+
+
+
+

Sign in

+

+ Need an account? +

+ + {signInData?.error && ( +
    + {signInData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +export { SignInPage } from './ui/SignInPage'; +export { signIn } from './api/sign-in'; +``` + +```tsx title="app/routes/login.tsx" +import { SignInPage, signIn } from "pages/sign-in"; + +export { signIn as action }; + +export default SignInPage; +``` + +Now let’s give the users a way to actually get to these pages. + +### Header + +As we discussed in part 1, the app header is commonly placed either in Widgets or in Shared. We will put it in Shared because it’s very simple and all the business logic can be kept outside of it. Let’s create a place for it: + +```bash +npx fsd shared ui +``` + +Now create `shared/ui/Header.tsx` with the following contents: + +```tsx title="shared/ui/Header.tsx" +import { useContext } from "react"; +import { Link, useLocation } from "@remix-run/react"; + +import { CurrentUser } from "../api/currentUser"; + +export function Header() { + const currentUser = useContext(CurrentUser); + const { pathname } = useLocation(); + + return ( + + ); +} +``` + +Export this component from `shared/ui`: + +```tsx title="shared/ui/index.ts" +export { Header } from "./Header"; +``` + +In the header, we rely on the context that’s kept in `shared/api`. Create that as well: + +```tsx title="shared/api/currentUser.ts" +import { createContext } from "react"; + +import type { User } from "./models"; + +export const CurrentUser = createContext(null); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +export { CurrentUser } from "./currentUser"; +``` + +Now let’s add the header to the page. We want it to be on every single page, so it makes sense to simply add it to the root route and wrap the outlet (the place where the page will be rendered) with the `CurrentUser` context provider. This way our entire app and also the header has access to the current user object. We will also add a loader to actually obtain the current user object from cookies. Drop the following into `app/root.tsx`: + +```tsx title="app/root.tsx" +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from "@remix-run/react"; + +import { Header } from "shared/ui"; +import { getUserFromSession, CurrentUser } from "shared/api"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export const loader = ({ request }: LoaderFunctionArgs) => + getUserFromSession(request); + +export default function App() { + const user = useLoaderData(); + + return ( + + + + + + + + + + + + + +
+ + + + + + + + ); +} +``` + +At this point, you should end up with the following on the home page: + +
+ ![The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing.](../../../../../static/img/tutorial/realworld-feed-without-tabs.jpg) + +
The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing.
+
+ +### Tabs + +Now that we can detect the authentication state, let’s also quickly implement the tabs and post likes to be done with the feed page. We need another form, but this page file is getting kind of large, so let’s move these forms into adjacent files. We will create `Tabs.tsx`, `PopularTags.tsx`, and `Pagination.tsx` with the following content: + +```tsx title="pages/feed/ui/Tabs.tsx" +import { useContext } from "react"; +import { Form, useSearchParams } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; + +export function Tabs() { + const [searchParams] = useSearchParams(); + const currentUser = useContext(CurrentUser); + + return ( +
+
+
    + {currentUser !== null && ( +
  • + +
  • + )} +
  • + +
  • + {searchParams.has("tag") && ( +
  • + + {searchParams.get("tag")} + +
  • + )} +
+
+
+ ); +} +``` + +```tsx title="pages/feed/ui/PopularTags.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; + +export function PopularTags() { + const { tags } = useLoaderData(); + + return ( +
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+ ); +} +``` + +```tsx title="pages/feed/ui/Pagination.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; + +export function Pagination() { + const [searchParams] = useSearchParams(); + const { articles } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ + ); +} +``` + +And now we can significantly simplify the feed page itself: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; +import { Tabs } from "./Tabs"; +import { PopularTags } from "./PopularTags"; +import { Pagination } from "./Pagination"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ + + {articles.articles.map((article) => ( + + ))} + + +
+ +
+ +
+
+
+
+ ); +} +``` + +We also need to account for the new tab in the loader function: + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + /* unchanged */ +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + if (url.searchParams.get("source") === "my-feed") { + const userSession = await requireUser(request); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles/feed", { + params: { + query: { + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + headers: { Authorization: `Token ${userSession.token}` }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); + } + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +Before we leave the feed page, let’s add some code that handles likes to posts. Change your `ArticlePreview.tsx` to the following: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Form, Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+
+ +
+
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +This code will send a POST request to `/article/:slug` with `_action=favorite` to mark the article as favorite. It won’t work yet, but as we start working on the article reader, we will implement this too. + +And with that we are officially done with the feed! Yay! + +### Article reader + +First, we need data. Let’s create a loader: + +```bash +npx fsd pages article-read -s api +``` + +```tsx title="pages/article-read/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import invariant from "tiny-invariant"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, getUserFromSession } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + invariant(params.slug, "Expected a slug parameter"); + const currentUser = await getUserFromSession(request); + const authorization = currentUser + ? { Authorization: `Token ${currentUser.token}` } + : undefined; + + return json( + await promiseHash({ + article: throwAnyErrors( + GET("/articles/{slug}", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + comments: throwAnyErrors( + GET("/articles/{slug}/comments", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + }), + ); +}; +``` + +```tsx title="pages/article-read/index.ts" +export { loader } from "./api/loader"; +``` + +Now we can connect it to the route `/article/:slug` by creating the a route file called `article.$slug.tsx`: + +```tsx title="app/routes/article.$slug.tsx" +export { loader } from "pages/article-read"; +``` + +The page itself consists of three main blocks — the article header with actions (repeated twice), the article body, and the comments section. This is the markup for the page, it’s not particularly interesting: + +```tsx title="pages/article-read/ui/ArticleReadPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticleMeta } from "./ArticleMeta"; +import { Comments } from "./Comments"; + +export function ArticleReadPage() { + const { article } = useLoaderData(); + + return ( +
+
+
+

{article.article.title}

+ + +
+
+ +
+
+
+

{article.article.body}

+
    + {article.article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} +``` + +What’s more interesting is the `ArticleMeta` and `Comments`. They contain write operations such as liking an article, leaving a comment, etc. To get them to work, we first need to implement the backend part. Create `action.ts` in the `api` segment of the page: + +```tsx title="pages/article-read/api/action.ts" +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { namedAction } from "remix-utils/named-action"; +import { redirectBack } from "remix-utils/redirect-back"; +import invariant from "tiny-invariant"; + +import { DELETE, POST, requireUser } from "shared/api"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const currentUser = await requireUser(request); + + const authorization = { Authorization: `Token ${currentUser.token}` }; + + const formData = await request.formData(); + + return namedAction(formData, { + async delete() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirect("/"); + }, + async favorite() { + invariant(params.slug, "Expected a slug parameter"); + await POST("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfavorite() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async createComment() { + invariant(params.slug, "Expected a slug parameter"); + const comment = formData.get("comment"); + invariant(typeof comment === "string", "Expected a comment parameter"); + await POST("/articles/{slug}/comments", { + params: { path: { slug: params.slug } }, + headers: { ...authorization, "Content-Type": "application/json" }, + body: { comment: { body: comment } }, + }); + return redirectBack(request, { fallback: "/" }); + }, + async deleteComment() { + invariant(params.slug, "Expected a slug parameter"); + const commentId = formData.get("id"); + invariant(typeof commentId === "string", "Expected an id parameter"); + const commentIdNumeric = parseInt(commentId, 10); + invariant( + !Number.isNaN(commentIdNumeric), + "Expected a numeric id parameter", + ); + await DELETE("/articles/{slug}/comments/{id}", { + params: { path: { slug: params.slug, id: commentIdNumeric } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async followAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await POST("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfollowAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await DELETE("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + }); +}; +``` + +Export that from the slice and then from the route. While we’re at it, let’s also connect the page itself: + +```tsx title="pages/article-read/index.ts" +export { ArticleReadPage } from "./ui/ArticleReadPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/article.$slug.tsx" +import { ArticleReadPage } from "pages/article-read"; + +export { loader, action } from "pages/article-read"; + +export default ArticleReadPage; +``` + +Now, even though we haven’t implemented the like button on the reader page yet, the like button in the feed will start working! That’s because it’s been sending “like” requests to this route. Give that a try. + +`ArticleMeta` and `Comments` are, again, a bunch of forms. We’ve done this before, let’s grab their code and move on: + +```tsx title="pages/article-read/ui/ArticleMeta.tsx" +import { Form, Link, useLoaderData } from "@remix-run/react"; +import { useContext } from "react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function ArticleMeta() { + const currentUser = useContext(CurrentUser); + const { article } = useLoaderData(); + + return ( +
+
+ + + + +
+ + {article.article.author.username} + + {article.article.createdAt} +
+ + {article.article.author.username == currentUser?.username ? ( + <> + + Edit Article + +    + + + ) : ( + <> + + +    + + + )} +
+
+ ); +} +``` + +```tsx title="pages/article-read/ui/Comments.tsx" +import { useContext } from "react"; +import { Form, Link, useLoaderData } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function Comments() { + const { comments } = useLoaderData(); + const currentUser = useContext(CurrentUser); + + return ( +
+ {currentUser !== null ? ( +
+
+ +
+
+ + +
+
+ ) : ( +
+
+

+ Sign in +   or   + Sign up +   to add comments on this article. +

+
+
+ )} + + {comments.comments.map((comment) => ( +
+
+

{comment.body}

+
+ +
+ + + +   + + {comment.author.username} + + {comment.createdAt} + {comment.author.username === currentUser?.username && ( + +
+ + +
+
+ )} +
+
+ ))} +
+ ); +} +``` + +And with that our article reader is also complete! The buttons to follow the author, like a post, and leave a comment should now function as expected. + +
+ ![Article reader with functioning buttons to like and follow](../../../../../static/img/tutorial/realworld-article-reader.jpg) + +
Article reader with functioning buttons to like and follow
+
+ +### Article editor + +This is the last page that we will cover in this tutorial, and the most interesting part here is how we’re going to validate form data. + +The page itself, `article-edit/ui/ArticleEditPage.tsx`, will be quite simple, extra complexity stowed away into two other components: + +```tsx title="pages/article-edit/ui/ArticleEditPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { TagsInput } from "./TagsInput"; +import { FormErrors } from "./FormErrors"; + +export function ArticleEditPage() { + const article = useLoaderData(); + + return ( +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+
+
+
+
+ ); +} +``` + +This page gets the current article (unless we’re writing from scratch) and fills in the corresponding form fields. We’ve seen this before. The interesting part is `FormErrors`, because it will receive the validation result and display it to the user. Let’s take a look: + +```tsx title="pages/article-edit/ui/FormErrors.tsx" +import { useActionData } from "@remix-run/react"; +import type { action } from "../api/action"; + +export function FormErrors() { + const actionData = useActionData(); + + return actionData?.errors != null ? ( +
    + {actionData.errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null; +} +``` + +Here we are assuming that our action will return the `errors` field, an array of human-readable error messages. We will get to the action shortly. + +Another component is the tags input. It’s just a plain input field with an additional preview of chosen tags. Not much to see here: + +```tsx title="pages/article-edit/ui/TagsInput.tsx" +import { useEffect, useRef, useState } from "react"; + +export function TagsInput({ + name, + defaultValue, +}: { + name: string; + defaultValue?: Array; +}) { + const [tagListState, setTagListState] = useState(defaultValue ?? []); + + function removeTag(tag: string): void { + const newTagList = tagListState.filter((t) => t !== tag); + setTagListState(newTagList); + } + + const tagsInput = useRef(null); + useEffect(() => { + tagsInput.current && (tagsInput.current.value = tagListState.join(",")); + }, [tagListState]); + + return ( + <> + + setTagListState(e.target.value.split(",").filter(Boolean)) + } + /> +
+ {tagListState.map((tag) => ( + + + [" ", "Enter"].includes(e.key) && removeTag(tag) + } + onClick={() => removeTag(tag)} + >{" "} + {tag} + + ))} +
+ + ); +} +``` + +Now, for the API part. The loader should look at the URL, and if it contains an article slug, that means we’re editing an existing article, and its data should be loaded. Otherwise, return nothing. Let’s create that loader: + +```ts title="pages/article-edit/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const currentUser = await requireUser(request); + + if (!params.slug) { + return { article: null }; + } + + return throwAnyErrors( + GET("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: { Authorization: `Token ${currentUser.token}` }, + }), + ); +}; +``` + +The action will take the new field values, run them through our data schema, and if everything is correct, commit those changes to the backend, either by updating an existing article or creating a new one: + +```tsx title="pages/article-edit/api/action.ts" +import { json, redirect, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, PUT, requireUser } from "shared/api"; +import { parseAsArticle } from "../model/parseAsArticle"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + try { + const { body, description, title, tags } = parseAsArticle( + await request.formData(), + ); + const tagList = tags?.split(",") ?? []; + + const currentUser = await requireUser(request); + const payload = { + body: { + article: { + title, + description, + body, + tagList, + }, + }, + headers: { Authorization: `Token ${currentUser.token}` }, + }; + + const { data, error } = await (params.slug + ? PUT("/articles/{slug}", { + params: { path: { slug: params.slug } }, + ...payload, + }) + : POST("/articles", payload)); + + if (error) { + return json({ errors: error }, { status: 422 }); + } + + return redirect(`/article/${data.article.slug ?? ""}`); + } catch (errors) { + return json({ errors }, { status: 400 }); + } +}; +``` + +The schema doubles as a parsing function for `FormData`, which allows us to conveniently get the clean fields or just throw the errors to handle at the end. Here’s how that parsing function could look: + +```tsx title="pages/article-edit/model/parseAsArticle.ts" +export function parseAsArticle(data: FormData) { + const errors = []; + + const title = data.get("title"); + if (typeof title !== "string" || title === "") { + errors.push("Give this article a title"); + } + + const description = data.get("description"); + if (typeof description !== "string" || description === "") { + errors.push("Describe what this article is about"); + } + + const body = data.get("body"); + if (typeof body !== "string" || body === "") { + errors.push("Write the article itself"); + } + + const tags = data.get("tags"); + if (typeof tags !== "string") { + errors.push("The tags must be a string"); + } + + if (errors.length > 0) { + throw errors; + } + + return { title, description, body, tags: data.get("tags") ?? "" } as { + title: string; + description: string; + body: string; + tags: string; + }; +} +``` + +Arguably, it’s a bit lengthy and repetitive, but that’s the price we pay for human-readable errors. This could also be a Zod schema, for example, but then we would have to render error messages on the frontend, and this form is not worth the complication. + +One last step — connect the page, the loader, and the action to the routes. Since we neatly support both creation and editing, we can export the same thing from both `editor._index.tsx` and `editor.$slug.tsx`: + +```tsx title="pages/article-edit/index.ts" +export { ArticleEditPage } from "./ui/ArticleEditPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/editor._index.tsx, app/routes/editor.$slug.tsx (same content)" +import { ArticleEditPage } from "pages/article-edit"; + +export { loader, action } from "pages/article-edit"; + +export default ArticleEditPage; +``` + +We’re done now! Log in and try creating a new article. Or “forget” to write the article and see the validation kick in. + +
+ ![The Conduit article editor, with the title field saying “New article” and the rest of the fields empty. Above the form there are two errors: “**Describe what this article is about” and “Write the article itself”.**](../../../../../static/img/tutorial/realworld-article-editor.jpg) + +
The Conduit article editor, with the title field saying “New article” and the rest of the fields empty. Above the form there are two errors: **“Describe what this article is about”** and **“Write the article itself”**.
+
+ +The profile and settings pages are very similar to the article reader and editor, they are left as an exercise for the reader, that’s you :) diff --git a/src/content/docs/docs/guides/examples/api-requests.mdx b/src/content/docs/docs/guides/examples/api-requests.mdx new file mode 100644 index 0000000000..a975b6a412 --- /dev/null +++ b/src/content/docs/docs/guides/examples/api-requests.mdx @@ -0,0 +1,144 @@ +--- +title: Handling API Requests +sidebar: + order: 4 +--- + +import { FileTree, Tabs, TabItem, Aside } from '@astrojs/starlight/components'; + +## Shared API Requests \{#shared-api-requests\} + +Start by placing common API request logic in the `shared/api` directory. This makes it easy to reuse requests across your application and helps with faster prototyping. For many projects, this is all you'll need for API calls. + +A typical file structure would be: + + +- shared/ + - client.ts + - index.ts + - endpoints/ + - login.ts + + +The `client.ts` file centralizes your HTTP request setup. It wraps your chosen method (like `fetch()` or an `axios` instance) and handles common configurations, such as: + +- Backend base URL. +- Default headers (e.g., for authentication). +- Data serialization. + +Here are examples for `axios` and `fetch`: + + + + +```ts title="shared/api/client.ts" +// Example using axios +import axios from 'axios'; + +export const client = axios.create({ + baseURL: 'https://your-api-domain.com/api/', + timeout: 5000, + headers: { 'X-Custom-Header': 'my-custom-value' } +}); +``` + + + +```ts title="shared/api/client.ts" +export const client = { + async post(endpoint: string, body: any, options?: RequestInit) { + const response = await fetch(`https://your-api-domain.com/api${endpoint}`, { + method: 'POST', + body: JSON.stringify(body), + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'my-custom-value', + ...options?.headers, + }, + }); + return response.json(); + } + // ... other methods like put, delete, etc. +}; +``` + + + + +Organize your individual API request functions in `shared/api/endpoints`, grouping them by the API endpoint. + + + +```ts title="shared/api/endpoints/login.ts" +import { client } from '../client'; + +export interface LoginCredentials { + email: string; + password: string; +} + +export function login(credentials: LoginCredentials) { + return client.post('/login', credentials); +} +``` +Use an `index.ts` file in `shared/api` to export your request functions. + +```ts title="shared/api/index.ts" +export { client } from './client'; // If you want to export the client itself +export { login } from './endpoints/login'; +export type { LoginCredentials } from './endpoints/login'; +``` + +## Slice-Specific API Requests \{#slice-specific-api-requests\} + +If an API request is only used by a specific slice (like a single page or feature) and won't be reused, place it in the api segment of that slice. This keeps slice-specific logic neatly contained. + + +- pages/ + - login/ + - index.ts + - api/ + - login.ts + - ui/ + - LoginPage.tsx + + +```ts title="pages/login/api/login.ts" +import { client } from 'shared/api'; + +interface LoginCredentials { + email: string; + password: string; +} + +export function login(credentials: LoginCredentials) { + return client.post('/login', credentials); +} +``` + +You don't need to export `login()` function in the page's public API, because it's unlikely that any other place in the app will need this request. + + + +## Using Client Generators \{#client-generators\} + +If your backend has an OpenAPI specification, tools like [orval](https://orval.dev/) or [openapi-typescript](https://openapi-ts.dev/) can generate API types and request functions for you. Place the generated code in, for example, `shared/api/openapi`. Make sure to include `README.md` to document what those types are, and how to generate them. + +## Integrating with Server State Libraries \{#server-state-libraries\} + +When using server state libraries like [TanStack Query (React Query)](https://tanstack.com/query/latest) or [Pinia Colada](https://pinia-colada.esm.dev/) you might need to share types or cache keys between slices. Use the `shared` layer for things like: + +- API data types +- Cache keys +- Common query/mutation options + +For more details on how to work with server state libraries, refer to [React Query article](/docs/guides/tech/with-react-query) diff --git a/src/content/docs/docs/guides/examples/auth.mdx b/src/content/docs/docs/guides/examples/auth.mdx new file mode 100644 index 0000000000..0584ea12e4 --- /dev/null +++ b/src/content/docs/docs/guides/examples/auth.mdx @@ -0,0 +1,178 @@ +--- +title: Authentication +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Broadly, authentication consists of the following steps: + +1. Get the credentials from the user +1. Send them to the backend +1. Store the token to make authenticated requests + +## How to get credentials from the user + +We are assuming that your app is responsible for getting credentials. If you have authentication via OAuth, you can simply create a login page with a link to the OAuth provider's login page and skip to [step 3](#how-to-store-the-token-for-authenticated-requests). + +### Dedicated page for login + +Usually, websites have dedicated pages for login, where you enter your username and password. These pages are quite simple, so they don't require decomposition. Login and registration forms are quite similar in appearance, so they can even be grouped into one page. Create a slice for your login/registration page on the Pages layer: + + +- pages/ + - login/ + - ui/ + - LoginPage.tsx + - RegisterPage.tsx + - index.ts + - ... + + +Here we created two components and exported them both in the index file of the slice. These components will contain forms that are responsible for presenting the user with understandable controls to get their credentials. + +### Dialog for login + +If your app has a dialog for login that can be used on any page, consider making that dialog a widget. That way, you can still avoid too much decomposition, but have the freedom to reuse this dialog on any page. + + +- widgets/ + - login-dialog/ + - ui/ + - LoginDialog.tsx + - index.ts + - ... + + +The rest of this guide is written for the dedicated page approach, but the same principles apply to the dialog widget. + +### Client-side validation + +Sometimes, especially for registration, it makes sense to perform client-side validation to let the user know quickly that they made a mistake. Validation can take place in the `model` segment of the login page. Use a schema validation library, for example, [Zod][ext-zod] for JS/TS, and expose that schema to the `ui` segment: + +```ts title="pages/login/model/registration-schema.ts" +import { z } from "zod"; + +export const registrationData = z.object({ + email: z.string().email(), + password: z.string().min(6), + confirmPassword: z.string(), +}).refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], +}); +``` + +Then, in the `ui` segment, you can use this schema to validate the user input: + +```tsx title="pages/login/ui/RegisterPage.tsx" +import { registrationData } from "../model/registration-schema"; + +function validate(formData: FormData) { + const data = Object.fromEntries(formData.entries()); + try { + registrationData.parse(data); + } catch (error) { + // TODO: Show error message to the user + } +} + +export function RegisterPage() { + return ( +
validate(new FormData(e.target))}> + + + + + + + + +
+ ) +} +``` + +## How to send credentials to the backend + +Create a function that makes a request to your backend's login endpoint. This function can either be called directly in the component code using a mutation library (e.g. TanStack Query), or it can be called as a side effect in a state manager. As explained in the [guide for API requests][examples-api-requests], you can put your request either in `shared/api` or in the `api` segment of your login page. + +### Two-factor authentication + +If your app supports two-factor authentication (2FA), you might have to redirect to another page where a user can enter a one-time password. Usually your `POST /login` request would return the user object with a flag indicating that the user has 2FA enabled. If that flag is set, redirect the user to the 2FA page. + +Since this page is very related to logging in, you can also keep it in the same slice, `login` on the Pages layer. + +You would also need another request function, similar to `login()` that we created above. Place them together, either in Shared, or in the `api` segment of the `login` page. + +## How to store the token for authenticated requests \{#how-to-store-the-token-for-authenticated-requests\} + +Regardless of the authentication scheme you have, be it a simple login & password, OAuth, or two-factor authentication, at the end you will receive a token. This token should be stored so that subsequent requests can identify themselves. + +The ideal token storage for a web app is a **cookie** — it requires no manual token storage or handling. As such, cookie storage needs almost no consideration from the frontend architecture side. If your frontend framework has a server side (for example, [Remix][ext-remix]), then you should store the server-side cookie infrastructure in `shared/api`. There is an example in [the Authentication section of the tutorial][tutorial-authentication] of how to do that with Remix. + +Sometimes, however, cookie storage is not an option. In this case, you will have to store the token manually. Apart from storing the token, you may also need to set up logic for refreshing your token when it expires. With FSD, there are several places where you can store the token, as well as several ways to make it available for the rest of the app. + +### In Shared + +This approach plays well with an API client defined in `shared/api` because the token is freely available for other request functions that require authentication to succeed. You can make the API client hold state, either with a reactive store or simply a module-level variable, and update that state in your `login()`/`logout()` functions. + +Automatic token refresh can be implemented as a middleware in the API client — something that can execute every time you make any request. It can work like this: + +- Authenticate and store the access token as well as the refresh token +- Make any request that requires authentication +- If the request fails with a status code that indicates token expiration, and there is a token in the store, make a refresh request, store the new tokens, and retry the original request + +One of the drawbacks of this approach is that the logic of managing and refreshing the token doesn't have a dedicated place. This can be fine for some apps or teams, but if the token management logic is more complex, it may be preferable to separate responsibilities of making requests and managing tokens. You can do that by keeping your requests and API client in `shared/api`, but the token store and management logic in `shared/auth`. + +Another drawback of this approach is that if your backend returns an object of your current user's information along with the token, you have to store that somewhere or discard that information and request it again from an endpoint like `/me` or `/users/current`. + +### In Entities + +It's common for FSD projects to have an entity for a user and/or an entity for the current user. It can even be the same entity for both. + + + +To store the token in the User entity, create a reactive store in the `model` segment. That store can contain both the token and the user object. + +Since the API client is usually defined in `shared/api` or spreaded across the entities, the main challenge to this approach is making the token available to other requests that need it without breaking [the import rule on layers][import-rule-on-layers]: + +> A module (file) in a slice can only import other slices when they are located on layers strictly below. + +There are several solutions to this challenge: + +1. **Pass the token manually every time you make a request** + This is the simplest solution, but it quickly becomes cumbersome, and if you don't have type safety, it's easy to forget. It's also not compatible with middlewares pattern for the API client in Shared. +1. **Expose the token to the entire app with a context or a global store like `localStorage`** + The key to retrieve the token will be kept in `shared/api` so that the API client can access it. The reactive store of the token will be exported from the User entity, and the context provider (if needed) will be set up on the App layer. This gives more freedom for designing the API client, however, this creates an implicit dependency on higher layers to provide context. When following this approach, consider providing helpful error messages if the context or `localStorage` are not set up correctly. +1. **Inject the token into the API client every time it changes** + If your store is reactive, you can create a subscription that will update the API client's token store every time the store in the entity changes. This is similar to the previous solution in that they both create an implicit dependency on higher layers, but this one is more imperative ("push"), while the previous one is more declarative ("pull"). + +Once you overcome the challenge of exposing the token that is stored in the entity's model, you can encode more business logic related to token management. For example, the `model` segment can contain logic to invalidate the token after a certain period of time, or to refresh the token when it expires. To actually make requests to the backend, use the `api` segment of the User entity or `shared/api`. + +### In Pages/Widgets (not recommended) + +It is discouraged to store app-wide state like an access token in pages or widgets. Avoid placing your token store in the `model` segment of the login page, instead choose from the first two solutions, Shared or Entities. + +## Logout and token invalidation + +Usually, apps don't have an entire page for logging out, but the logout functionality is still very important. It consists of an authenticated request to the backend and an update to the token store. + +If you store all your requests in `shared/api`, keep the logout request function there, close to the login function. Otherwise, consider keeping the logout request function next to the button that triggers it. For example, if you have a header widget that appears on every page and contains the logout link, put that request in the `api` segment of that widget. + +The update to the token store will have to be triggered from the place of the logout button, like a header widget. You can combine the request and the store update in the `model` segment of that widget. + +### Automatic logout + +Don't forget to build failsafes for when a request to log out fails, or a request to refresh a login token fails. In both of these cases, you should clear the token store. If you keep your token in Entities, this code can be placed in the `model` segment as it is pure business logic. If you keep your token in Shared, placing this logic in `shared/api` might bloat the segment and dilute its purpose. If you're noticing that your API segment contains two several unrelated things, consider splitting out the token management logic into another segment, for example, `shared/auth`. + +[tutorial-authentication]: /docs/get-started/tutorial#authentication +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[examples-api-requests]: /docs/guides/examples/api-requests +[ext-remix]: https://remix.run +[ext-zod]: https://zod.dev diff --git a/src/content/docs/docs/guides/examples/page-layout.mdx b/src/content/docs/docs/guides/examples/page-layout.mdx new file mode 100644 index 0000000000..1e005abe2f --- /dev/null +++ b/src/content/docs/docs/guides/examples/page-layout.mdx @@ -0,0 +1,104 @@ +--- +title: Page Layouts +sidebar: + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + +This guide examines the abstraction of a _page layout_ — when several pages share the same overall structure, and differ only in the main content. + + + +## Simple layout + +The simplest layout can be seen on this page. It has a header with site navigation, two sidebars, and a footer with external links. There is no complicated business logic, and the only dynamic parts are sidebars and the switchers on the right side of the header. Such a layout can be placed entirely in `shared/ui` or in `app/layouts`, with props filling in the content for the sidebars: + +```tsx title="shared/ui/layout/Layout.tsx" +import { Link, Outlet } from "react-router-dom"; +import { useThemeSwitcher } from "./useThemeSwitcher"; + +export function Layout({ siblingPages, headings }) { + const [theme, toggleTheme] = useThemeSwitcher(); + + return ( +
+
+ + +
+
+ + {/* This is where the main content goes */} + +
+
+
    +
  • GitHub
  • +
  • Twitter
  • +
+
+
+ ); +} +``` + +```ts title="shared/ui/layout/useThemeSwitcher.ts" +export function useThemeSwitcher() { + const [theme, setTheme] = useState("light"); + + function toggleTheme() { + setTheme(theme === "light" ? "dark" : "light"); + } + + useEffect(() => { + document.body.classList.remove("light", "dark"); + document.body.classList.add(theme); + }, [theme]); + + return [theme, toggleTheme] as const; +} +``` + +The code of sidebars is left as an exercise for the reader 😉. + +## Using widgets in the layout + +Sometimes you want to include certain business logic in the layout, especially if you're using deeply nested routes with a router like [React Router][ext-react-router]. Then you can't store the layout in Shared or in Widgets due to [the import rule on layers][import-rule-on-layers]: + +> A module in a slice can only import other slices when they are located on layers strictly below. + +Before we discuss solutions, we need to discuss if it's even a problem in the first place. Do you _really need_ that layout, and if so, does it _really need_ to be a Widget? If the block of business logic in question is reused on 2-3 pages, and the layout is simply a small wrapper for that widget, consider one of these two options: + +1. **Write the layout inline on the App layer, where you configure the routing** + This is great for routers that support nesting, because you can group certain routes and apply the layout only to them. + +2. **Just copy-paste it** + The urge to abstract code is often very overrated. It is especially the case for layouts, which rarely change. At some point, if one of these pages will need to change, you can simply do the change without needlessly affecting other pages. If you're worried that someone might forget to update the other pages, you can always leave a comment that describes the relationship between the pages. + +If none of the above are applicable, there are two solutions to include a widget in the layout: + +1. **Use render props or slots** + Most frameworks allow you to pass a piece of UI externally. In React, it's called [render props][ext-render-props], in Vue it's called [slots][ext-vue-slots]. +2. **Move the layout to the App layer** + You can also store your layout on the App layer, for example, in `app/layouts`, and compose any widgets you want. + +## Further reading + +- There's an example of how to build a layout with authentication with React and Remix (equivalent to React Router) in the [tutorial][tutorial]. + +[tutorial]: /docs/get-started/tutorial +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-react-router]: https://reactrouter.com/ +[ext-render-props]: https://www.patterns.dev/react/render-props-pattern/ +[ext-vue-slots]: https://vuejs.org/guide/components/slots diff --git a/src/content/docs/docs/guides/examples/types.mdx b/src/content/docs/docs/guides/examples/types.mdx new file mode 100644 index 0000000000..b27c6eacba --- /dev/null +++ b/src/content/docs/docs/guides/examples/types.mdx @@ -0,0 +1,436 @@ +--- +title: Types +sidebar: + order: 2 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +This guide concerns data types from typed languages like TypeScript and describes where they fit within FSD. + + + +## Utility types + +Utility types are types that don't have much meaning on their own and are usually used with other types. For example: + +```ts +type ArrayValues = T[number]; +``` + +To make utility types available across your project, either install a library like [`type-fest`][ext-type-fest], or create your own library in `shared/lib`. Make sure to clearly indicate what new types _should_ be added to this library, and what types _don't belong_ there. For example, call it `shared/lib/utility-types` and add a README inside that describes what is a utility type in your team. + +Don't overestimate the potential reusability of a utility type. Just because it can be reused, doesn't mean it will be, and as such, not every utility type needs to be in Shared. Some utility types are fine right next to where they are needed: + + +- pages/ + - home/ + - api/ + - ArrayValues.ts utility type + - getMemoryUsageMetrics.ts uses the utility type + + + + +## Business entities and their cross-references + +Among the most important types in an app are the types of business entities, i.e. the real-world things that your app works with. For example, in a music streaming app, you might have business entities _Song_, _Album_, etc. + +Business entities often come from the backend, so the first step is to type the backend responses. It's convenient to have a function to make a request to every endpoint, and to type the response of this function. For extra type safety, you may want to run the response through a schema validation library like [Zod][ext-zod]. + +For example, if you keep all your requests in Shared, you could do it like this: + +```ts title="shared/api/songs.ts" +import type { Artist } from "./artists"; + +interface Song { + id: number; + title: string; + artists: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +You might notice that the `Song` type references a different entity, `Artist`. This is a benefit of storing your requests in Shared — real-world types are often intertwined. If we kept this function in `entities/song/api`, we wouldn't be able to simply import `Artist` from `entities/artist`, because FSD restricts cross-imports between slices with [the import rule on layers][import-rule-on-layers]: + +> A module in a slice can only import other slices when they are located on layers strictly below. + +There are two ways to deal with this issue: + +1. **Parametrize your types** + You can make your types accept type arguments as slots for connections with other entities, and even impose constraints on those slots. For example: + + ```ts title="entities/song/model/song.ts" + interface Song { + id: number; + title: string; + artists: Array; + } + ``` + + This works better for some types than others. A simple type like `Cart = { items: Array }` can easily be made to work with any type of product. More connected types, like `Country` and `City`, may not be as easy to separate. + +2. **Cross-import (but do it right)** + To make cross-imports between entities in FSD, you can use a special public API specifically for each slice that will be cross-importing. For example, if we have entities `song`, `artist`, and `playlist`, and the latter two need to reference `song`, we can make two special public APIs for both of them in the `song` entity with the `@x` notation: + + + - entities/ + - song/ + - @x/ + - artist.ts a public API for the `artist` entity to import from + - playlist.ts a public API for the `playlist` entity to import from + - index.ts regular public API + + + The contents of a file `📄 entities/song/@x/artist.ts` are similar to `📄 entities/song/index.ts`: + + ```ts title="entities/song/@x/artist.ts" + export type { Song } from "../model/song.ts"; + ``` + + Then the `📄 entities/artist/model/artist.ts` can import `Song` like this: + + ```ts title="entities/artist/model/artist.ts" + import type { Song } from "entities/song/@x/artist"; + + export interface Artist { + name: string; + songs: Array; + } + ``` + + By making explicit connections between entities, we stay on top of inter-dependencies and maintain a decent level of domain separation. + +## Data transfer objects and mappers \{#data-transfer-objects-and-mappers\} + +Data transfer objects, or DTOs, is a term that describes the shape of data that comes from the backend. Sometimes, the DTO is fine to use as is, but sometimes it's inconvenient for the frontend. That's where mappers come in — they transform a DTO into a more convenient shape. + +### Where to put DTOs + +If you have backend types in a separate package (for example, if you share code between the frontend and the backend), then just import your DTOs from there and you're done! If you don't share code between the backend and frontend, then you need to keep DTOs somewhere in your frontend codebase, and we will explore this case below. + +If you have your request functions in `shared/api`, that's where the DTOs should be, right next to the function that uses them: + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + artist_ids: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +As mentioned in the previous section, storing your requests and DTOs in Shared comes with the benefit of being able to reference other DTOs. + +### Where to put mappers + +Mappers are functions that accept a DTO for transformation, and as such, they should be located near the definition of the DTO. In practice this means that if your requests and DTOs are defined in `shared/api`, then the mappers should go there as well: + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} + +interface Song { + id: string; + title: string; + /** The full title of the song, including the disc number. */ + fullTitle: string; + artistIds: Array; +} + +function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +If your requests and stores are defined in entity slices, then all this code would go there, keeping in mind the limitations of cross-imports between slices: + +```ts title="entities/song/api/dto.ts" +import type { ArtistDTO } from "entities/artist/@x/song"; + +export interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} +``` + +```ts title="entities/song/api/mapper.ts" +import type { SongDTO } from "./dto"; + +export interface Song { + id: string; + title: string; + /** The full title of the song, including the disc number. */ + fullTitle: string; + artistIds: Array; +} + +export function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} +``` + +```ts title="entities/song/api/listSongs.ts" +import { adaptSongDTO } from "./mapper"; + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +```ts title="entities/song/model/songs.ts" +import { createSlice, createEntityAdapter } from "@reduxjs/toolkit"; + +import { listSongs } from "../api/listSongs"; + +export const fetchSongs = createAsyncThunk('songs/fetchSongs', listSongs); + +const songAdapter = createEntityAdapter(); +const songsSlice = createSlice({ + name: "songs", + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSongs.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload); + }) + }, +}); +``` + +### How to deal with nested DTOs + +The most problematic part is when a response from the backend contains several entities. For example, if the song included not just the authors' IDs, but the entire author objects. In this case, it is impossible for entities not to know about each other (unless we want to discard the data or have a firm conversation with the backend team). Instead of coming up with solutions for indirect connections between slices (such as a common middleware that would dispatch actions to other slices), prefer explicit cross-imports with the `@x` notation. Here is how we can implement it with Redux Toolkit: + +```ts title="entities/song/model/songs.ts" +import { + createSlice, + createEntityAdapter, + createAsyncThunk, + createSelector, +} from '@reduxjs/toolkit' +import { normalize, schema } from 'normalizr' + +import { getSong } from "../api/getSong"; + +// Define normalizr entity schemas +export const artistEntity = new schema.Entity('artists') +export const songEntity = new schema.Entity('songs', { + artists: [artistEntity], +}) + +const songAdapter = createEntityAdapter() + +export const fetchSong = createAsyncThunk( + 'songs/fetchSong', + async (id: string) => { + const data = await getSong(id) + // Normalize the data so reducers can load a predictable payload, like: + // `action.payload = { songs: {}, artists: {} }` + const normalized = normalize(data, songEntity) + return normalized.entities + } +) + +export const slice = createSlice({ + name: 'songs', + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload.songs) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +```ts title="entities/song/@x/artist.ts" +export { fetchSong } from "../model/songs"; +``` + +```ts title="entities/artist/model/artists.ts" +import { createSlice, createEntityAdapter } from '@reduxjs/toolkit' + +import { fetchSong } from 'entities/song/@x/artist' + +const artistAdapter = createEntityAdapter() + +export const slice = createSlice({ + name: 'users', + initialState: artistAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + // And handle the same fetch result by inserting the artists here + artistAdapter.upsertMany(state, action.payload.artists) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +This slightly limits the benefits of slice isolation, but it accurately represents a connection between these two entities that we have no control over. If these entities are to ever be refactored, they have to be refactored together. + +## Global types and Redux + +Global types are types that will be used across the whole application. There are two kinds of global types, based on what they need to know about: +1. Generic types that don't have any application specifics +2. Types that need to know about the whole application + +The first case is simple to resolve — place your types in Shared, in an appropriate segment. For example, if you have an interface for a global variable for analytics, you can put it in `shared/analytics`. + + + +The second case is commonly encountered in projects with Redux without RTK. Your final store type is only available once you add all the reducers together, but this store type needs to be available to selectors that you use across the app. For example, here's your typical store definition: + +```ts title="app/store/index.ts" +import { combineReducers, rootReducer } from "redux"; + +import { songReducer } from "entities/song"; +import { artistReducer } from "entities/artist"; + +const rootReducer = combineReducers(songReducer, artistReducer); + +const store = createStore(rootReducer); + +type RootState = ReturnType; +type AppDispatch = typeof store.dispatch; +``` + +It would be nice to have typed Redux hooks `useAppDispatch` and `useAppSelector` in `shared/store`, but they cannot import `RootState` and `AppDispatch` from the App layer due to the [import rule on layers][import-rule-on-layers]: + +> A module in a slice can only import other slices when they are located on layers strictly below. + +The recommended solution in this case is to create an implicit dependency between layers Shared and App. These two types, `RootState` and `AppDispatch` are unlikely to change, and they will be familiar to Redux developers, so we don't have to worry about them as much. + +In TypeScript, you can do it by declaring the types as global like this: + +```ts title="app/store/index.ts" +/* same content as in the code block before… */ + +declare type RootState = ReturnType; +declare type AppDispatch = typeof store.dispatch; +``` + +```ts title="shared/store/index.ts" +import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux"; + +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector: TypedUseSelectorHook = useSelector; +``` + +## Enums + +The general rule with enums is that they should be defined **as close to the usage locations as possible**. When an enum represents values specific to a single feature, it should be defined in that same feature. + +The choice of segment should be dictated by usage locations as well. If your enum contains, for example, positions of a toast on the screen, it should be placed in the `ui` segment. If it represents the loading state of a backend operation, it should be placed in the `api` segment. + +Some enums are truly common across the whole project, like general backend response statuses or design system tokens. In this case, you can place them in Shared, and choose the segment based on what the enum represents (`api` for response statuses, `ui` for design tokens, etc.). + +## Type validation schemas and Zod + +If you want to validate that your data conforms to a certain shape or constraints, you can define a validation schema. In TypeScript, a popular library for this job is [Zod][ext-zod]. Validation schemas should also be colocated with the code that uses them, as much as possible. + +Validation schemas are similar to mappers (as discussed in the [Data transfer objects and mappers](#data-transfer-objects-and-mappers) section) in the sense that they take a data transfer object and parse it, producing an error if the parsing fails. + +One of the most common cases of validation is for the data that comes from the backend. Typically, you want to fail the request when the data doesn't match the schema, so it makes sense to put the schema in the same place as the request function, which is usually the `api` segment. + +If your data comes through user input, like a form, the validation should happen as the data is being entered. You can place your schema in the `ui` segment, next to the form component, or in the `model` segment, if the `ui` segment is too crowded. + +## Typings of component props and context + +In general, it's best to keep the props or context interface in the same file as the component or context that uses them. If you have a framework with single-file components, like Vue or Svelte, and you can't define the props interface in the same file, or you want to share that interface between several components, create a separate file in the same folder, typically, the `ui` segment. + +Here's an example with JSX (React or Solid): + +```ts title="pages/home/ui/RecentActions.tsx" +interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} + +export function RecentActions({ actions }: RecentActionsProps) { + /* … */ +} +``` + +And here's an example with the interface stored in a separate file for Vue: + +```ts title="pages/home/ui/RecentActionsProps.ts" +export interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} +``` + +```html title="pages/home/ui/RecentActions.vue" + +``` + +## Ambient declaration files (`*.d.ts`) + +Some packages, for example, [Vite][ext-vite] or [ts-reset][ext-ts-reset], require ambient declaration files to work across your app. Usually, they aren't large or complicated, so they often don't require any architecting, it's fine to just throw them in the `src/` folder. To keep the `src` more organized, you can keep them on the App layer, in `app/ambient/`. + +Other packages simply don't have typings, and you might want to declare them as untyped or even write your own typings for them. A good place for those typings would be `shared/lib`, in a folder like `shared/lib/untyped-packages`. Create a `%LIBRARY_NAME%.d.ts` file there and declare the types you need: + +```ts title="shared/lib/untyped-packages/use-react-screenshot.d.ts" +// This library doesn't have typings, and we didn't want to bother writing our own. +declare module "use-react-screenshot"; +``` + +## Auto-generation of types + +It's common to generate types from external sources, for example, generating backend types from an OpenAPI schema. In this case, create a dedicated place in your codebase for these types, like `shared/api/openapi`. Ideally, you should also include a README in that folder that describes what these files are, how to regenerate them, etc. + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-type-fest]: https://github.com/sindresorhus/type-fest +[ext-zod]: https://zod.dev +[ext-vite]: https://vitejs.dev +[ext-ts-reset]: https://www.totaltypescript.com/ts-reset diff --git a/src/content/docs/docs/guides/issues/cross-imports.mdx b/src/content/docs/docs/guides/issues/cross-imports.mdx new file mode 100644 index 0000000000..f5e41c18b3 --- /dev/null +++ b/src/content/docs/docs/guides/issues/cross-imports.mdx @@ -0,0 +1,266 @@ +--- +title: Cross-imports +sidebar: + order: 4 +--- + +import { Aside } from '@astrojs/starlight/components'; + +A **cross-import** is an import **between different slices within the same layer**. + +For example: +- importing `features/product` from `features/cart` +- importing `widgets/sidebar` from `widgets/header` + +Cross-imports are a code smell: a warning sign that slices are becoming coupled. In some situations they may be hard to avoid, but they should always be deliberate and either documented or shared within the team/project. + + + +## Why is this a code smell? + +Cross-imports are not just a matter of style—they are generally considered a **code smell** because they blur the boundaries between domains and introduce implicit dependencies. + +Consider a case where the `cart` slice directly depends on `product` business logic. At first glance, this might seem convenient. However, this creates several problems: + +1. **Unclear ownership and responsibility.** When `cart` imports from `product`, it becomes unclear which slice "owns" the shared logic. If the `product` team changes their internal implementation, they might unknowingly break `cart`. This ambiguity makes it harder to reason about the codebase and assign responsibility for bugs or features. + +2. **Reduced isolation and testability.** One of the main benefits of sliced architecture is that each slice can be developed, tested, and deployed independently. Cross-imports break this isolation—testing `cart` now requires setting up `product` as well, and changes in one slice can cause unexpected test failures in another. + +3. **Increased cognitive load.** Working on `cart` also requires accounting for how `product` is structured and how it behaves. As cross-imports accumulate, tracing the impact of a change requires following more code across slice boundaries, and even small edits demand more context to be held in mind. + +4. **Path to circular dependencies.** Cross-imports often start as one-way dependencies but can evolve into bidirectional ones (A imports B, B imports A). This tends to lock slices together, making dependencies harder to untangle and increasing refactoring cost over time. + +The purpose of clear domain boundaries is to keep each slice focused and changeable within its own responsibility. When dependencies are loose, it becomes easier to predict the impact of a change and to keep review and testing scope contained. Cross-imports weaken this separation, expanding the impact of changes and increasing refactoring cost over time—this is why they are treated as a code smell worth addressing. + +In the sections below, we outline how these issues typically appear in real projects and what strategies you can use to address them. + +## Entities layer cross-imports + +Cross-imports in `entities` are often caused by splitting entities too granularly. Before reaching for `@x`, consider whether the boundaries should be merged instead. +Some teams use `@x` as a dedicated cross-import surface for `entities`, but it should be treated as a **last resort** — a **necessary compromise**, not a recommended approach. + +Think of `@x` as an explicit gateway for unavoidable domain references—not a general-purpose reuse mechanism. Overuse tends to lock entity boundaries together and makes refactoring more costly over time. + +For details about `@x`, see the [Public API documentation](/docs/reference/public-api). + +For concrete examples of cross-references between business entities, see: +- [Types guide — Business entities and their cross-references](/docs/guides/examples/types#business-entities-and-their-cross-references) +- [Layers reference — Entities](/docs/reference/layers#entities) + +## Features and widgets: Multiple strategies + +In the `features` and `widgets` layers, it's usually more realistic to say there are **multiple strategies** for handling cross-imports, rather than declaring them **always forbidden**. This section focuses less on code and more on the **patterns** you can choose from depending on your team and product context. + +### Strategy A: Slice merge + +If two slices are not truly independent and they are always changed together, merge them into a single larger slice. + +Example (before): +- `features/profile` +- `features/profileSettings` + +If these keep cross-importing each other and effectively move as one unit, they are likely one feature in practice. In that case, merging into `features/profile` is often the simpler and cleaner choice. + +### Strategy B: Push shared domain flows down into `entities` (domain-only) + +If multiple features share a domain-level flow, move that flow into a domain slice inside `entities` (for example, `entities/session`). + +Key principles: +- `entities` contains **domain types and domain logic only** +- UI remains in `features` / `widgets` +- features import and use the domain logic from `entities` + +For example, if both `features/auth` and `features/profile` need session validation, place session-related domain functions in `entities/session` and reuse them from both features. + +For more guidance, see [Layers reference — Entities](/docs/reference/layers#entities). + +### Strategy C: Compose from an upper layer (pages / app) + +Instead of connecting slices within the same layer via cross-imports, compose them at a higher level (`pages` / `app`). This approach uses **Inversion of Control (IoC)** patterns—rather than slices knowing about each other, an upper layer assembles and connects them. + +Common IoC techniques include: +- **Render props (React)**: Pass components or render functions as props +- **Slots (Vue)**: Use named slots to inject content from parent components +- **Dependency injection**: Pass dependencies through props or context + +#### Basic composition example (React): + +```tsx title="features/userProfile/index.ts" +export { UserProfilePanel } from './ui/UserProfilePanel'; +``` + +```tsx title="features/activityFeed/index.ts" +export { ActivityFeed } from './ui/ActivityFeed'; +``` + +```tsx title="pages/UserDashboardPage.tsx" +import React from 'react'; +import { UserProfilePanel } from '@/features/userProfile'; +import { ActivityFeed } from '@/features/activityFeed'; + +export function UserDashboardPage() { + return ( +
+ + +
+ ); +} +``` + +With this structure, `features/userProfile` and `features/activityFeed` do not know about each other. `pages/UserDashboardPage` composes them to build the full screen. + +#### Render props example (React): + +When one feature needs to render content from another, use render props to invert the dependency: + +```tsx title="features/commentList/ui/CommentList.tsx" +interface CommentListProps { + comments: Comment[]; + renderUserAvatar?: (userId: string) => React.ReactNode; +} + +export function CommentList({ comments, renderUserAvatar }: CommentListProps) { + return ( +
    + {comments.map(comment => ( +
  • + {renderUserAvatar?.(comment.userId)} + {comment.text} +
  • + ))} +
+ ); +} +``` + +```tsx title="pages/PostPage.tsx" +import { CommentList } from '@/features/commentList'; +import { UserAvatar } from '@/features/userProfile'; + +export function PostPage() { + return ( + } + /> + ); +} +``` + +Now `CommentList` doesn't import from `userProfile`—the page injects the avatar component. + +#### Slots example (Vue): + +Vue's slot system provides a natural way to compose features without cross-imports: + +```vue title="features/commentList/ui/CommentList.vue" + + + +``` + +```vue title="pages/PostPage.vue" + + + +``` + +The `CommentList` feature remains independent of `userProfile`. The page uses slots to compose them together. + +### Strategy D: Cross-feature reuse only via Public API + +If the above strategies don't fit your case and cross-feature reuse is truly unavoidable, allow it only through an explicit **Public API** (for example: exported hooks or UI components). Avoid directly accessing another slice's `store`/`model` or internal implementation details. + +Unlike strategies A-C which aim to eliminate cross-imports, this strategy accepts them while minimizing the risks through strict boundaries. + +#### Example code: + +```tsx title="features/auth/index.ts" + +export { useAuth } from './model/useAuth'; +export { AuthButton } from './ui/AuthButton'; +``` + +```tsx title="features/profile/ui/ProfileMenu.tsx" + +import React from 'react'; +import { useAuth, AuthButton } from '@/features/auth'; + +export function ProfileMenu() { + const { user } = useAuth(); + + if (!user) { + return ; + } + + return
{user.name}
; +} +``` + +For example, prevent `features/profile` from importing from paths like `features/auth/model/internal/*`. Restrict usage to only what `features/auth` explicitly exposes as its Public API. + +## When should cross-imports be treated as a problem? + +After reviewing these strategies, a natural question is: + +> When is a cross-import acceptable to keep, and when should it be treated as a code smell and refactored? + +Common warning signs: +- directly depending on another slice's store/model/business logic +- deep imports into another slice's internal files +- **bidirectional dependencies** (A imports B, and B imports A) +- changes in one slice frequently breaking another slice +- flows that should be composed in `pages` / `app`, but are forced into cross-imports within the same layer + +When you see these signals, treat the cross-import as a **code smell** and consider applying at least one of the strategies above. + +## How strict you are is a team/project decision + +How strictly to enforce these rules depends on the team and project. + +For example: +- In **early-stage products** with heavy experimentation, allowing some cross-imports may be a pragmatic speed trade-off. +- In **long-lived or regulated systems** (for example, fintech or large-scale services), stricter boundaries often pay off in maintainability and stability. + +Cross-imports are not an absolute prohibition here. They are dependencies that are **generally best avoided**, but sometimes used intentionally. + +If you do introduce a cross-import: +- treat it as a deliberate architectural choice +- document the reasoning +- revisit it periodically as the system evolves + +Teams should align on: +- what strictness level they want +- how to reflect it in lint rules, code review, and documentation +- when and how to reevaluate existing cross-imports over time + +## References + +- [(Thread) About the supposed inevitability of cross-ports](https://t.me/feature_sliced/4515) +- [(Thread) About resolving cross-ports in entities](https://t.me/feature_sliced/3678) +- [(Thread) About cross-imports and responsibility](https://t.me/feature_sliced/3287) +- [(Thread) About imports between segments](https://t.me/feature_sliced/4021) +- [(Thread) About cross-imports inside shared](https://t.me/feature_sliced/3618) diff --git a/src/content/docs/docs/guides/issues/desegmented.mdx b/src/content/docs/docs/guides/issues/desegmented.mdx new file mode 100644 index 0000000000..c9eed3c6e5 --- /dev/null +++ b/src/content/docs/docs/guides/issues/desegmented.mdx @@ -0,0 +1,147 @@ +--- +title: Desegmentation +sidebar: + order: 2 +--- + +import { Tabs, TabItem, FileTree } from '@astrojs/starlight/components'; + +Desegmentation (also known as horizontal slicing or packaging by layer) is a code organization pattern where files are grouped by their technical roles rather than by the business domains they serve. This means code with similar technical functions is stored in the same place, regardless of the business logic it handles. + +This approach is popular in meta-frameworks like Next and Nuxt due to its simplicity, as it's easy to get started and enables features like auto-imports and file-based routing: + + +- app + - components + - DeliveryCard.jsx + - DeliveryChoice.jsx + - RegionSelect.jsx + - UserAvatar.jsx + - actions + - delivery.js + - region.js + - user.js + - composables + - delivery.js + - region.js + - user.js + - constants + - delivery.js + - region.js + - user.js + - utils + - delivery.js + - region.js + - user.js + - stores + - delivery + - getters.js + - actions.js + + +This pattern also occurs in FSD codebases, in the form of generic folders: + + +- features + - delivery + - ui + - components ⚠️ +- entities + - recommendations + - utils ⚠️ + + +Files can also be a source of desegmentation. Files like `types.ts` can aggregate multiple domains, complicating navigation and future refactoring, especially in layers like `pages` or `widgets`: + + +- pages + - delivery + - index.ts + - ui + - DeliveryCard.tsx + - DeliveryChoice.tsx + - UserAvatar.tsx + - model + - types.ts ⚠️ + - utils.ts ⚠️ + - api + - endpoints.ts ⚠️ + + + + + +```ts title="pages/delivery/model/types.ts" +// ❌ Bad: Mixed business domains in generic file +export interface DeliveryOption { + id: string; + name: string; + price: number; +} + +export interface UserInfo { + id: string; + name: string; + avatar: string; +} +``` + + + +```ts title="pages/delivery/model/utils.ts" +// ❌ Bad: Mixed business domains in generic file +export function formatDeliveryPrice(price: number) { + return `$${price.toFixed(2)}`; +} + +export function getUserInitials(name: string) { + return name.split(' ').map(n => n[0]).join(''); +} +``` + + + +```ts title="pages/delivery/api/endpoints.ts" +// ❌ Bad: Mixed business domains in generic file +export async function fetchDeliveryOptions() { /* ... */ } +export async function fetchUserInfo() { /* ... */ } +``` + + + +## The Problem + +While this structure is easy to start with, it can lead to scalability issues in larger projects: + +- Low Cohesion: Modifying a single feature often requires editing files in multiple large folders, such as `pages`, `components`, and `stores`. + +- Tight Coupling: Components can have unexpected dependencies, leading to complex and tangled dependency chains. + +- Difficult Refactoring: It requires additional effort to manually extract code related to a specific domain. + +## Solution + +Group all code that relates to a specific domain in one place. + +Avoid generic folder names such as `types`, `components`, `utils`, as well as generic file names like `types.ts`, `utils.ts`, or `helpers.ts`. Instead, use names that directly reflect the domain they represent. + +Avoid generic file names like `types.ts` if possible, especially in slices with multiple domains: + + +- pages + - delivery + - index.tsx + - ui + - DeliveryPage.tsx + - DeliveryCard.tsx + - DeliveryChoice.tsx + - UserInfo.tsx + - model + - delivery.ts + - user.ts + + +## See Also + +* [(Article) About Low Coupling and High Cohesion clearly](https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/) +* [(Article) Low Coupling and High Cohesion. The Law of Demeter](https://medium.com/german-gorelkin/low-coupling-high-cohesion-d36369fb1be9) diff --git a/src/content/docs/docs/guides/issues/excessive-entities.mdx b/src/content/docs/docs/guides/issues/excessive-entities.mdx new file mode 100644 index 0000000000..dbab5b9b8e --- /dev/null +++ b/src/content/docs/docs/guides/issues/excessive-entities.mdx @@ -0,0 +1,120 @@ +--- +title: Excessive Entities +--- + +import { Aside, FileTree } from '@astrojs/starlight/components'; + +The `entities` layer in Feature-Sliced Design is one of the lower layers that's primarily for business logic. That makes it widely accessible — all layers except for `shared` can access it. However, its global nature means that changes to `entities` can have a widespread impact, requiring careful design to avoid costly refactors. + +Excessive entities can lead to ambiguity (what code belongs to this layer), coupling, and constant import dilemmas (code scattered across sibling entities). + +## How to keep `entities` layer clean + +### 0. Consider having no `entities` layer + +You might think that your application won't be Feature-Sliced if you don't include this layer, but it is completely fine for the application to have no `entities` layer. It doesn't break FSD in any way, on the contrary, it simplifies the architecture and keeps the `entities` layer available for future scaling. For example, if your application acts as a thin client, most likely it doesn't need `entities` layer. + + + +### 1. Avoid preemptive slicing + +In contrast to previous versions, FSD 2.1 encourages deferred decomposition of slices instead of preemptive, and this approach also extends to `entities` layer. At first, you can place all your code in the `model` segment of your page (widget, feature), and then consider refactoring it later, when business requirements are stable. + +Remember: the later you move code to the `entities` layer, the less dangerous your potential refactors will be — code in Entities may affect functionality of any slice on higher layers. + +### 2. Avoid Unnecessary Entities + +Do not create an entity for every piece of business logic. Instead, leverage types from `shared/api` and place logic in the `model` segment of a current slice. For reusable business logic, use the `model` segment within an entity slice while keeping data definitions in `shared/api`: + + +- entities + - order + - index.ts + - model + - apply-discount.ts Business logic using OrderDto from shared/api +- shared + - api + - index.ts + - endpoints + - order.ts + + +### 3. Exclude CRUD Operations from Entities + +CRUD operations, while essential, often involve boilerplate code without significant business logic. Including them in the `entities` layer can clutter it and obscure meaningful code. Instead, place CRUD operations in `shared/api`: + + +- shared + - api + - client.ts + - index.ts + - endpoints + - order.ts Contains all order-related CRUD operations + - products.ts + - cart.ts + + +For complex CRUD operations (e.g., atomic updates, rollbacks, or transactions), evaluate whether the `entities` layer is appropriate, but use it with caution. + +### 4. Store Authentication Data in `shared` + +Prefer `shared` layer to creating a `user` entity for authentication data, such as tokens or user DTOs returned from the backend. These are context-specific and unlikely to be reused outside authentication scope: + +- Authentication responses (e.g., tokens or DTOs) often lack fields needed for broader reuse or vary by context (e.g., private vs. public user profiles). +- Using entities for auth data can lead to cross-layer imports (e.g., `entities` into `shared`) or usage of `@x` notation, complicating the architecture. + +Instead, store authentication-related data in `shared/auth` or `shared/api`: + + +- shared + - auth + - use-auth.ts authenticated user info or token + - index.ts + - api + - client.ts + - index.ts + - endpoints + - order.ts + + +For more details on implementing authentication, see [the Authentication guide](/docs/guides/examples/auth). + +### 5. Minimize Cross-Imports + +FSD permits cross-imports via `@x` notation, but they can introduce technical issues like circular dependencies. To avoid this, design entities within isolated business contexts to eliminate the need for cross-imports: + +Non-Isolated Business Context (Avoid): + + +- entities + - order + - @x + - model + - order-item + - @x + - model + - order-customer-info + - @x + - model + + +Isolated Business Context (Preferred): + + +- entities + - order-info + - index.ts + - model + - order-info.ts + + +An isolated context encapsulates all related logic (e.g., order items and customer info) within a single module, reducing complexity and preventing external modifications to tightly coupled logic. diff --git a/src/content/docs/docs/guides/migration/from-custom.mdx b/src/content/docs/docs/guides/migration/from-custom.mdx new file mode 100644 index 0000000000..fc01e089e7 --- /dev/null +++ b/src/content/docs/docs/guides/migration/from-custom.mdx @@ -0,0 +1,234 @@ +--- +title: From a custom architecture +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +This guide describes an approach that might be helpful when migrating from a custom self-made architecture to Feature-Sliced Design. + +Here is the folder structure of a typical custom architecture. We will be using it as an example in this guide. +Click on the blue arrow to open the folder. + + +- src/ + - actions/ + - product/ + - order/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - routes/ + - products.jsx + - products.[id].jsx + - utils/ + - reducers/ + - selectors/ + - styles/ + - App.jsx + - index.jsx + + +## Before you start \{#before-you-start\} + +The most important question to ask your team when considering to switch to Feature-Sliced Design is — _do you really need it?_ We love Feature-Sliced Design, but even we recognize that some projects are perfectly fine without it. + +Here are some reasons to consider making the switch: + +1. New team members are complaining that it's hard to get to a productive level +2. Making modifications to one part of the code **often** causes another unrelated part to break +3. Adding new functionality is difficult due to the sheer amount of things you need to think about + +**Avoid switching to FSD against the will of your teammates**, even if you are the lead. +First, convince your teammates that the benefits outweigh the cost of migration and the cost of learning a new architecture instead of the established one. + +Also keep in mind that any kind of architectural changes are not immediately observable to the management. Make sure they are on board with the switch before starting and explain to them why it might benefit the project. + + + +--- + +If you made the decision to start migrating, then the first thing you want to do is to set up an alias for `📁 src`. It will be helpful later to refer to top-level folders. We will consider `@` as an alias for `./src` for the rest of this guide. + +## Step 1. Divide the code by pages \{#divide-code-by-pages\} + +Most custom architectures already have a division by pages, however small or large in logic. If you already have `📁 pages`, you may skip this step. + +If you only have `📁 routes`, create `📁 pages` and try to move as much component code from `📁 routes` as possible. Ideally, you would have a tiny route and a larger page. As you're moving code, create a folder for each page and add an index file: + + + +Route file: + +```js title="src/routes/products.[id].js" +export { ProductPage as default } from "@/pages/product" +``` + +Page index file: + +```js title="src/pages/product/index.js" +export { ProductPage } from "./ProductPage.jsx" +``` + +Page component file: + +```jsx title="src/pages/product/ProductPage.jsx" +export function ProductPage(props) { + return
; +} +``` + +## Step 2. Separate everything else from the pages \{#separate-everything-else-from-pages\} + +Create a folder `📁 src/shared` and move everything that doesn't import from `📁 pages` or `📁 routes` there. Create a folder `📁 src/app` and move everything that does import the pages or routes there, including the routes themselves. + +Remember that the Shared layer doesn't have slices, so it's fine if segments import from each other. + +You should end up with a file structure like this: + + +- src/ + - app/ + - routes/ + - products.jsx + - products.[id].jsx + - App.jsx + - index.js + - pages/ + - product/ + - index.js + - ui/ + - ProductPage.jsx + - catalog/ + - shared/ + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## Step 3. Tackle cross-imports between pages \{#tackle-cross-imports-between-pages\} + +Find all instances where one page is importing from the other and do one of the two things: + +1. Copy-paste the imported code into the depending page to remove the dependency +2. Move the code to a proper segment in Shared: + - if it's a part of the UI kit, move it to `📁 shared/ui`; + - if it's a configuration constant, move it to `📁 shared/config`; + - if it's a backend interaction, move it to `📁 shared/api`. + + + +## Step 4. Unpack the Shared layer \{#unpack-shared-layer\} + +You might have a lot of stuff in the Shared layer on this step, and you generally want to avoid that. The reason is that the Shared layer may be a dependency for any other layer in your codebase, so making changes to that code is automatically more prone to unintended consequences. + +Find all the objects that are only used on one page and move it to the slice of that page. And yes, _that applies to actions, reducers, and selectors, too_. There is no benefit in grouping all actions together, but there is benefit in colocating relevant actions close to their usage. + +You should end up with a file structure like this: + + +- src/ + - app/ + - ... + - pages/ + - product/ + - actions/ + - reducers/ + - selectors/ + - ui/ + - Component.jsx + - Container.jsx + - ProductPage.jsx + - index.js + - catalog/ + - shared/ only objects that are reused + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## Step 5. Organize code by technical purpose \{#organize-by-technical-purpose\} + +In FSD, division by technical purpose is done with _segments_. There are a few common ones: + +- `ui` — everything related to UI display: UI components, date formatters, styles, etc. +- `api` — backend interactions: request functions, data types, mappers, etc. +- `model` — the data model: schemas, interfaces, stores, and business logic. +- `lib` — library code that other modules on this slice need. +- `config` — configuration files and feature flags. + +You can create your own segments, too, if you need. Make sure not to create segments that group code by what it is, like `components`, `actions`, `types`, `utils`. Instead, group the code by what it's for. + +Reorganize your pages to separate code by segments. You should already have a `ui` segment, now it's time to create other segments, like `model` for your actions, reducers, and selectors, or `api` for your thunks and mutations. + +Also reorganize the Shared layer to remove these folders: +- `📁 components`, `📁 containers` — most of it should become `📁 shared/ui`; +- `📁 helpers`, `📁 utils` — if there are some reused helpers left, group them together by function, like dates or type conversions, and move theses groups to `📁 shared/lib`; +- `📁 constants` — again, group by function and move to `📁 shared/config`. + +## Optional steps \{#optional-steps\} + +### Step 6. Form entities/features from Redux slices that are used on several pages \{#form-entities-features-from-redux\} + +Usually, these reused Redux slices will describe something relevant to the business, for example, products or users, so these can be moved to the Entities layer, one entity per one folder. If the Redux slice is related to an action that your users want to do in your app, like comments, then you can move it to the Features layer. + +Entities and features are meant to be independent from each other. If your business domain contains inherent connections between entities, refer to the [guide on business entities][business-entities-cross-relations] for advice on how to organize these connections. + +The API functions related to these slices can stay in `📁 shared/api`. + +### Step 7. Refactor your modules \{#refactor-your-modules\} + +The `📁 modules` folder is commonly used for business logic, so it's already pretty similar in nature to the Features layer from FSD. Some modules might also be describe large chunks of the UI, like an app header. In that case, you should migrate them to the Widgets layer. + +### Step 8. Form a clean UI foundation in `shared/ui` \{#form-clean-ui-foundation\} + +`📁 shared/ui` should ideally contain a set of UI elements that don't have any business logic encoded in them. They should also be highly reusable. + +Refactor the UI components that used to be in `📁 components` and `📁 containers` to separate out the business logic. Move that business logic to the higher layers. If it's not used in too many places, you could even consider copy-pasting. + +## See also \{#see-also\} + +- [(Talk in Russian) Ilya Klimov — Крысиные бега бесконечного рефакторинга: как не дать техническому долгу убить мотивацию и продукт](https://youtu.be/aOiJ3k2UvO4) + +[ext-steiger]: https://github.com/feature-sliced/steiger +[business-entities-cross-relations]: /docs/guides/examples/types#business-entities-and-their-cross-references diff --git a/src/content/docs/docs/guides/migration/from-v1.mdx b/src/content/docs/docs/guides/migration/from-v1.mdx new file mode 100644 index 0000000000..1e5dc9e6f2 --- /dev/null +++ b/src/content/docs/docs/guides/migration/from-v1.mdx @@ -0,0 +1,175 @@ +--- +title: Migration from v1 to v2 +sidebar: + order: 2 +--- + +import { Aside } from '@astrojs/starlight/components'; + +## Why v2? + +The original concept of **feature-slices** [was announced][ext-tg-spb] in 2018. + +Since then, many transformations of the methodology have taken place, but at the same time **[the basic principles were preserved][ext-v1]**: + +- Using a *standardized* frontend project structure +- Splitting the application in the first place-according to *business logic* +- Use of *isolated features* to prevent implicit side effects and cyclic dependencies +- Using the *Public API* with a ban on climbing "into the insides" of the module + +At the same time, in the previous version of the methodology, there were still **weak points** that + +- Sometimes it leads to boilerplate code +- Sometimes it leads to excessive complication of the code base and non-obvious rules between abstractions +- Sometimes it leads to implicit architectural solutions, which prevented the project from being pulled up and new people from onboarding + +The new version of the methodology ([v2][ext-v2]) is designed **to eliminate these shortcomings, while preserving the existing advantages** of the approach. + +Since 2018, [has also developed][ext-fdd-issues] another similar methodology - [**feature-driven**][ext-fdd], which was first announced by [Oleg Isonen][ext-kof]. + +After merging of the two approaches, we have **improved and refined existing practices** - towards greater flexibility, clarity and efficiency in application. + +> As a result, this has even affected the name of the methodology - *"feature-slice**d**"* + +## Why does it make sense to migrate the project to v2? + + + +#### 🔍 More transparent and simple architecture + +The methodology (v2) offers **more intuitive and more common abstractions and ways of separating logic among developers.** + +All this has an extremely positive effect on attracting new people, as well as studying the current state of the project, and distributing the business logic of the application. + +#### 📦 More flexible and honest modularity + +The methodology (v2) allows **to distribute logic in a more flexible way:** + +- With the ability to refactor isolated parts from scratch +- With the ability to rely on the same abstractions, but without unnecessary interweaving of dependencies +- With simpler requirements for the location of the new module *(layer => slice => segment)* + +#### 🚀 More specifications, plans, community + +At the moment, the `core-team` is actively working on the latest (v2) version of the methodology + +So it is for her: + +- there will be more described cases / problems +- there will be more guides on the application +- there will be more real examples +- in general, there will be more documentation for onboarding new people and studying the concepts of the methodology +- the toolkit will be developed in the future to comply with the concepts and conventions on architecture + +> Of course, there will be user support for the first version as well - but the latest version is still a priority for us +> +> In the future, with the next major updates, you will still have access to the current version (v2) of the methodology, **without risks for your teams and projects** + +## Changelog + +### `BREAKING` Layers + +Now the methodology assumes explicit allocation of layers at the top level + +- `/app` > `/processes` > **`/pages`** > **`/features`** > `/entities` > `/shared` +- *That is, not everything is now treated as features/pages* +- This approach allows you to [explicitly set rules for layers][ext-tg-v2-draft]: +- The **higher the layer** of the module is located , the more **context** it has + + *(in other words-each module of the layer - can import only the modules of the underlying layers, but not higher)* + +- The **lower the layer of the** module is located , the more **danger and responsibility** to make changes to it + + *(because it is usually the underlying layers that are more overused)* + +### `BREAKING` Shared + +The infrastructure abstractions `/ui`, `/lib`, `/api`, which used to lie in the src root of the project, are now separated by a separate directory `/src/shared` + +- `shared/ui` - Still the same general uikit of the application (optional) + - *At the same time, no one forbids using `Atomic Design` here as before* +- `shared/lib` - A set of auxiliary libraries for implementing logic + - *Still - without a dump of helpers* +- `shared/api` - A common entry point for accessing the API + - *Can also be registered locally in each feature / page - but it is not recommended* +- As before - there should be no explicit binding to business logic in `shared` + - *If necessary, you need to take this relationship to the `entities` level or even higher* + +### `NEW` Entities, Processes + +In v2 **, other new abstractions** have been added to eliminate the problems of logic complexity and high coupling. + +- `/entities` - layer **business entities** containing slices that are related directly to the business models or synthetic entities required only on frontend + - *Examples: `user`, `i18n`, `order`, `blog`* +- `/processes` - layer **business processes**, penetrating app + - **The layer is optional**, it is usually recommended to use it when *the logic grows and begins to blur in several pages* + - *Examples: `payment`, `auth`, `quick-tour`* + +### `BREAKING` Abstractions & Naming + +Now specific abstractions and [clear recommendations for naming them][refs-adaptability]are defined + +[disc-process]: https://github.com/feature-sliced/documentation/discussions/20 +[disc-features]: https://github.com/feature-sliced/documentation/discussions/23 +[disc-entities]: https://github.com/feature-sliced/documentation/discussions/18#discussioncomment-422649 +[disc-shared]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453020 + +[disc-ui]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453132 +[disc-model]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-472645 +[disc-api]: https://github.com/feature-sliced/documentation/discussions/66 + +#### Layers + +- `/app` — **application initialization layer** + - *Previous versions: `app`, `core`,`init`, `src/index` (and this happens)* +- `/processes` — [**business process layer**][disc-process] + - *Previous versions: `processes`, `flows`, `workflows`* +- `/pages` — **application page layer** + - *Previous versions: `pages`, `screens`, `views`, `layouts`, `components`, `containers`* +- `/features` — [**functionality parts layer**][disc-features] + - *Previous versions: `features`, `components`, `containers`* +- `/entities` — [**business entity layer**][disc-entities] + - *Previous versions: `entities`, `models`, `shared`* +- `/shared` — [**layer of reused infrastructure code**][disc-shared] 🔥 + - *Previous versions: `shared`, `common`, `lib`* + +#### Segments + +- `/ui` — [**UI segment**][disc-ui] 🔥 + - *Previous versions: `ui`, `components`, `view`* +- `/model` — [**BL-segment**][disc-model] 🔥 + - *Previous versions: `model`, `store`, `state`, `services`, `controller`* +- `/lib` — segment **of auxiliary code** + - *Previous versions: `lib`, `libs`, `utils`, `helpers`* +- `/api` — [**API segment**][disc-api] + - *Previous versions: `api`, `service`, `requests`, `queries`* +- `/config` — **application configuration segment** + - *Previous versions: `config`, `env`, `get-env`* + +### `REFINED` Low coupling + +Now it is much easier to [observe the principle of low coupling][refs-low-coupling] between modules, thanks to the new layers. + +*At the same time, it is still recommended to avoid as much as possible cases where it is extremely difficult to "uncouple" modules* + +## See also + +- [Notes from the report "React SPB Meetup #1"][ext-tg-spb] +- [React Berlin Talk - Oleg Isonen "Feature Driven Architecture"][ext-kof-fdd] +- [Comparison with v1 (community-chat)](https://t.me/feature_sliced/493) +- [New ideas v2 with explanations (atomicdesign-chat)][ext-tg-v2-draft] +- [Discussion of abstractions and naming for the new version of the methodology (v2)](https://github.com/feature-sliced/documentation/discussions/31) + +[refs-low-coupling]: /docs/reference/slices-segments#zero-coupling-high-cohesion +[refs-adaptability]: /docs/about/understanding/naming + +[ext-v1]: https://feature-sliced.github.io/featureslices.dev/v1.0.html +[ext-tg-spb]: https://t.me/feature_slices +[ext-fdd]: https://github.com/feature-sliced/documentation/tree/rc/feature-driven +[ext-fdd-issues]: https://github.com/kof/feature-driven-architecture/issues +[ext-v2]: https://github.com/feature-sliced/documentation +[ext-kof]: https://github.com/kof +[ext-kof-fdd]: https://www.youtube.com/watch?v=BWAeYuWFHhs +[ext-tg-v2-draft]: https://t.me/atomicdesign/18708 diff --git a/src/content/docs/docs/guides/migration/from-v2-0.mdx b/src/content/docs/docs/guides/migration/from-v2-0.mdx new file mode 100644 index 0000000000..aeef2f5046 --- /dev/null +++ b/src/content/docs/docs/guides/migration/from-v2-0.mdx @@ -0,0 +1,45 @@ +--- +title: Migration from v2.0 to v2.1 +sidebar: + order: 3 +--- + +The main change in v2.1 is the new mental model for decomposing an interface — pages first. + +In v2.0, FSD would recommend identifying entities and features in your interface, considering even the smallest bits of entity representation and interactivity for decomposition. Then you would build widgets and pages from entities and features. In this model of decomposition, most of the logic was in entities and features, and pages were just compositional layers that didn't have much significance on their own. + +In v2.1, we recommend starting with pages, and possibly even stopping there. Most people already know how to separate the app into individual pages, and pages are also a common starting point when trying to locate a component in the codebase. In this new model of decomposition, you keep most of the UI and logic in each individual page, maintaining a reusable foundation in Shared. If a need arises to reuse business logic across several pages, you can move it to a layer below. + +Another addition to Feature-Sliced Design is the standardization of cross-imports between entities with the `@x`-notation. + +## How to migrate \{#how-to-migrate\} + +There are no breaking changes in v2.1, which means that a project written with FSD v2.0 is also a valid project in FSD v2.1. However, we believe that the new mental model is more beneficial for teams and especially onboarding new developers, so we recommend making minor adjustments to your decomposition. + +### Merge slices + +A simple way to start is by running our linter, [Steiger][steiger], on the project. Steiger is built with the new mental model, and the most helpful rules will be: + +- [`insignificant-slice`][insignificant-slice] — if an entity or feature is only used in one page, this rule will suggest merging that entity or feature into the page entirely. +- [`excessive-slicing`][excessive-slicing] — if a layer has too many slices, it's usually a sign that the decomposition is too fine-grained. This rule will suggest merging or grouping some slices to help project navigation. + +```bash +npx steiger src +``` + +This will help you identify which slices are only used once, so that you could reconsider if they are really necessary. In such considerations, keep in mind that a layer forms some kind of global namespace for all the slices inside of it. Just as you wouldn't pollute the global namespace with variables that are only used once, you should treat a place in the namespace of a layer as valuable, to be used sparingly. + +### Standardize cross-imports + +If you had cross-imports between in your project before (we don't judge!), you may now take advantage of a new notation for cross-importing in Feature-Sliced Design — the `@x`-notation. It looks like this: + +```ts title="entities/B/some/file.ts" +import type { EntityA } from "entities/A/@x/B"; +``` + +For more details, check out the [Public API for cross-imports][public-api-for-cross-imports] section in the reference. + +[insignificant-slice]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice +[steiger]: https://github.com/feature-sliced/steiger +[excessive-slicing]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/docs/guides/tech/with-electron.mdx b/src/content/docs/docs/guides/tech/with-electron.mdx new file mode 100644 index 0000000000..1b1dff7daf --- /dev/null +++ b/src/content/docs/docs/guides/tech/with-electron.mdx @@ -0,0 +1,137 @@ +--- +title: Usage with Electron +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +Electron applications have a special architecture consisting of multiple processes with different responsibilities. Applying FSD in such a context requires adapting the structure to the Electron specifics. + + +- src + - app Common app layer + - main Main process + - index.ts Main process entry point + - preload Preload script and Context Bridge + - index.ts Preload entry point + - renderer Renderer process + - index.html Renderer process entry point + - main + - features + - user + - ipc + - get-user.ts + - send-user.ts + - entities + - shared + - renderer + - pages + - settings + - ipc + - get-user.ts + - save-user.ts + - ui + - user.tsx + - index.ts + - home + - ui + - home.tsx + - index.ts + - widgets + - features + - entities + - shared + - shared Common code between main and renderer + - ipc IPC description (event names, contracts) + + +## Public API rules +Each process must have its own public API. For example, you can't import modules from `main` to `renderer`. +Only the `src/shared` folder is public for both processes. +It's also necessary for describing contracts for process interaction. + +## Additional changes to the standard structure +It's suggested to use a new `ipc` segment, where interaction between processes takes place. +The `pages` and `widgets` layers, based on their names, should not be present in `src/main`. You can use `features`, `entities` and `shared`. +The `app` layer in `src` contains entry points for `main` and `renderer`, as well as the IPC. +It's not desirable for segments in the `app` layer to have intersection points + +## Interaction example + +```typescript title="src/shared/ipc/channels.ts" +export const CHANNELS = { + GET_USER_DATA: 'GET_USER_DATA', + SAVE_USER: 'SAVE_USER', +} as const; + +export type TChannelKeys = keyof typeof CHANNELS; +``` + +```typescript title="src/shared/ipc/events.ts" +import { CHANNELS } from './channels'; + +export interface IEvents { + [CHANNELS.GET_USER_DATA]: { + args: void, + response?: { name: string; email: string; }; + }; + [CHANNELS.SAVE_USER]: { + args: { name: string; }; + response: void; + }; +} +``` + +```typescript title="src/shared/ipc/preload.ts" +import { CHANNELS } from './channels'; +import type { IEvents } from './events'; + +type TOptionalArgs = T extends void ? [] : [args: T]; + +export type TElectronAPI = { + [K in keyof typeof CHANNELS]: (...args: TOptionalArgs) => IEvents[typeof CHANNELS[K]]['response']; +}; +``` + +```typescript title="src/app/preload/index.ts" +import { contextBridge, ipcRenderer } from 'electron'; +import { CHANNELS, type TElectronAPI } from 'shared/ipc'; + +const API: TElectronAPI = { + [CHANNELS.GET_USER_DATA]: () => ipcRenderer.sendSync(CHANNELS.GET_USER_DATA), + [CHANNELS.SAVE_USER]: args => ipcRenderer.invoke(CHANNELS.SAVE_USER, args), +} as const; + +contextBridge.exposeInMainWorld('electron', API); +``` + +```typescript title="src/main/features/user/ipc/send-user.ts" +import { ipcMain } from 'electron'; +import { CHANNELS } from 'shared/ipc'; + +export const sendUser = () => { + ipcMain.on(CHANNELS.GET_USER_DATA, ev => { + ev.returnValue = { + name: 'John Doe', + email: 'john.doe@example.com', + }; + }); +}; +``` + +```typescript title="src/renderer/pages/user-settings/ipc/get-user.ts" +import { CHANNELS } from 'shared/ipc'; + +export const getUser = () => { + const user = window.electron[CHANNELS.GET_USER_DATA](); + + return user ?? { name: 'John Donte', email: 'john.donte@example.com' }; +}; +``` + +## See also +- [Process Model Documentation](https://www.electronjs.org/docs/latest/tutorial/process-model) +- [Context Isolation Documentation](https://www.electronjs.org/docs/latest/tutorial/context-isolation) +- [Inter-Process Communication Documentation](https://www.electronjs.org/docs/latest/tutorial/ipc) +- [Example](https://github.com/feature-sliced/examples/tree/master/examples/electron) diff --git a/src/content/docs/docs/guides/tech/with-nextjs.mdx b/src/content/docs/docs/guides/tech/with-nextjs.mdx new file mode 100644 index 0000000000..c2e6f088d1 --- /dev/null +++ b/src/content/docs/docs/guides/tech/with-nextjs.mdx @@ -0,0 +1,196 @@ +--- +title: Usage with Next.js +sidebar: + order: 1 +--- + +import { Tabs, TabItem, FileTree } from '@astrojs/starlight/components'; + +FSD is compatible with Next.js in both the App Router version and the Pages Router version if you solve the main conflict — the `app` and `pages` folders. + +## App Router \{#app-router\} + +### Conflict between FSD and Next.js in the `app` layer \{#conflict-between-fsd-and-nextjs-in-the-app-layer\} + +Next.js suggests using the `app` folder to define application routes. It expects files in the `app` folder to correspond to pathnames. This routing mechanism **does not align** with the FSD concept, as it's not possible to maintain a flat slice structure. + +The solution is to move the Next.js `app` folder to the project root and import FSD pages from `src`, where the FSD layers are, into the Next.js `app` folder. + +You will also need to add a `pages` folder to the project root, otherwise Next.js will try to use `src/pages` as the Pages Router even if you use the App Router, which will break the build. It's also a good idea to put a `README.md` file inside this root `pages` folder describing why it is necessary, even though it's empty. + + +- app App folder (Next.js) + - api + - get-example + - route.ts + - example + - page.tsx +- pages Empty pages folder (Next.js) + - README.md +- src + - app + - api-routes API routes + - pages + - example + - index.ts + - ui + - example.tsx + - widgets/ + - features/ + - entities/ + - shared/ + + +Example of re-exporting a page from `src/pages` in the Next.js `app`: + +```tsx title="app/example/page.tsx" +export { ExamplePage as default, metadata } from '@/pages/example'; +``` + +### Middleware \{#middleware\} + +If you use middleware in your project, it must be located in the project root alongside the Next.js `app` and `pages` folders. + +### Instrumentation \{#instrumentation\} + +The `instrumentation.js` file allows you to monitor the performance and behavior of your application. If you use it, it must be located in the project root, similar to `middleware.js`. + +## Pages Router \{#pages-router\} + +### Conflict between FSD and Next.js in the `pages` layer \{#conflict-between-fsd-and-nextjs-in-the-pages-layer\} + +Routes should be placed in the `pages` folder in the root of the project, similar to `app` folder for the App Router. The structure inside `src` where the layer folders are located remains unchanged. + + +- pages Pages folder (Next.js) + - _app.tsx + - api + - example.ts API route re-export + - example + - index.tsx +- src + - app + - custom-app + - custom-app.tsx Custom App component + - api-routes + - get-example-data.ts API route + - pages + - example + - index.ts + - ui + - example.tsx + - widgets/ + - features/ + - entities/ + - shared/ + + +Example of re-exporting a page from `src/pages` in the Next.js `pages`: + +```tsx title="pages/example/index.tsx" +export { Example as default } from '@/pages/example'; +``` + +### Custom `_app` component \{#custom-_app-component\} + +You can place your Custom App component in `src/app/_app` or `src/app/custom-app`: + +```tsx title="src/app/custom-app/custom-app.tsx" +import type { AppProps } from 'next/app'; + +export const MyApp = ({ Component, pageProps }: AppProps) => { + return ( + <> +

My Custom App component

+ + + ); +}; +``` + +```tsx title="pages/_app.tsx" +export { App as default } from '@/app/custom-app'; +``` + +## Route Handlers (API routes) \{#route-handlers-api-routes\} + +Use the `api-routes` segment in the `app` layer to work with Route Handlers. + +Be mindful when writing backend code in the FSD structure — FSD is primarily intended for frontends, meaning that's what people will expect to find. +If you need a lot of endpoints, consider separating them into a different package in a monorepo. + + + + + +```tsx title="src/app/api-routes/get-example-data.ts" +import { getExamplesList } from '@/shared/db'; + +export const getExampleData = () => { + try { + const examplesList = getExamplesList(); + + return Response.json({ examplesList }); + } catch { + return Response.json(null, { + status: 500, + statusText: 'Ouch, something went wrong', + }); + } +}; +``` + +```tsx title="app/api/example/route.ts" +export { getExampleData as GET } from '@/app/api-routes'; +``` + + + + + +```tsx title="src/app/api-routes/get-example-data.ts" +import type { NextApiRequest, NextApiResponse } from 'next'; + +const config = { + api: { + bodyParser: { + sizeLimit: '1mb', + }, + }, + maxDuration: 5, +}; + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + res.status(200).json({ message: 'Hello from FSD' }); +}; + +export const getExampleData = { config, handler } as const; +``` + +```tsx title="src/app/api-routes/index.ts" +export { getExampleData } from './get-example-data'; +``` + +```tsx title="app/api/example.ts" +import { getExampleData } from '@/app/api-routes'; + +export const config = getExampleData.config; +export default getExampleData.handler; +``` + + + + + +## Additional recommendations \{#additional-recommendations\} + +- Use the `db` segment in the `shared` layer to describe database queries and their further use in higher layers. +- Caching and revalidating queries logic is better kept in the same place as the queries themselves. + +## See also \{#see-also\} + +- [Next.js Project Structure](https://nextjs.org/docs/app/getting-started/project-structure) +- [Next.js Page Layouts](https://nextjs.org/docs/app/getting-started/layouts-and-pages) + +[project-knowledge]: /docs/about/understanding/knowledge-types +[ext-app-router-stackblitz]: https://stackblitz.com/edit/stackblitz-starters-aiez55?file=README.md diff --git a/src/content/docs/docs/guides/tech/with-nuxtjs.mdx b/src/content/docs/docs/guides/tech/with-nuxtjs.mdx new file mode 100644 index 0000000000..0a8c0113a0 --- /dev/null +++ b/src/content/docs/docs/guides/tech/with-nuxtjs.mdx @@ -0,0 +1,181 @@ +--- +title: Usage with NuxtJS +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +It is possible to implement FSD in a NuxtJS project, but conflicts arise due to the differences between NuxtJS project structure requirements and FSD principles: + +- Initially, NuxtJS offers a project file structure without a `src` folder, i.e. in the root of the project. +- The file routing is in the `pages` folder, while in FSD this folder is reserved for the flat slice structure. + + +## Adding an alias for the `src` directory + +Add an `alias` object to your config: +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Not FSD related, enabled at project startup + alias: { + "@": '../src' + }, +}) +``` +## Choose how to configure the router + +In NuxtJS, there are two ways to customize the routing - using a config and using a file structure. +In the case of file-based routing, you will create index.vue files in folders inside the app/routes directory, and in the case of configure, you will configure the routers in the `router.options.ts` file. + + +### Routing using config + +In the `app` layer, create a `router.options.ts` file, and export a config object from it: +```ts title="app/router.options.ts" +import type { RouterConfig } from '@nuxt/schema'; + +export default { + routes: (_routes) => [], +}; + +``` + +To add a `Home` page to your project, you need to do the following steps: +- Add a page slice inside the `pages` layer +- Add the appropriate route to the `app/router.config.ts` config + + +To create a page slice, let's use the [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +Create a ``home-page.vue`` file inside the ui segment, access it using the Public API + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +Thus, the file structure will look like this: + + +- src + - app + - router.config.ts + - pages + - home + - ui + - home-page.vue + - index.ts + +Finally, let's add a route to the config: + +```ts title="app/router.config.ts" +import type { RouterConfig } from '@nuxt/schema' + +export default { + routes: (_routes) => [ + { + name: 'home', + path: '/', + component: () => import('@/pages/home.vue').then(r => r.default || r) + } + ], +} +``` + +### File Routing + +First of all, create a `src` directory in the root of your project, and create app and pages layers inside this directory and a routes folder inside the app layer. +Thus, your file structure should look like this: + + +- src + - app + - routes + - pages Pages folder, related to FSD + + +In order for NuxtJS to use the routes folder inside the `app` layer for file routing, you need to modify `nuxt.config.ts` as follows: +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Not FSD related, enabled at project startup + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes' + } +}) +``` + +Now, you can create routes for pages within `app` and connect pages from `pages` to them. + +For example, to add a `Home` page to your project, you need to do the following steps: +- Add a page slice inside the `pages` layer +- Add the corresponding route inside the `app` layer +- Connect the page from the slice with the route + +To create a page slice, let's use the [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +Create a ``home-page.vue`` file inside the ui segment, access it using the Public API + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +Create a route for this page inside the `app` layer: + + +- src + - app + - routes + - index.vue + - pages + - home + - ui + - home-page.vue + - index.ts + + +Add your page component inside the `index.vue` file: + +```html title="src/app/routes/index.vue" + + + +``` + +## What to do with `layouts`? + +You can place layouts inside the `app` layer, to do this you need to modify the config as follows: + +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Not related to FSD, enabled at project startup + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes', + layouts: './src/app/layouts' + } +}) +``` + + +## See also + +- [Documentation on changing directory config in NuxtJS](https://nuxt.com/docs/api/nuxt-config#dir) +- [Documentation on changing router config in NuxtJS](https://nuxt.com/docs/guide/recipes/custom-routing#router-config) +- [Documentation on changing aliases in NuxtJS](https://nuxt.com/docs/api/nuxt-config#alias) diff --git a/src/content/docs/docs/guides/tech/with-react-query.mdx b/src/content/docs/docs/guides/tech/with-react-query.mdx new file mode 100644 index 0000000000..e1d0d24318 --- /dev/null +++ b/src/content/docs/docs/guides/tech/with-react-query.mdx @@ -0,0 +1,440 @@ +--- +title: Usage with React Query +sidebar: + order: 10 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## The problem of “where to put the keys” + +### Solution — break down by entities + +If the project already has a division into entities, and each request corresponds to a single entity, +the purest division will be by entity. In this case, we suggest using the following structure: + +- src/ + - app/ + - ... + - pages/ + - ... + - entities/ + - \{entity\}/ + - ... + - api/ + - `{entity}.query` Query-factory where are the keys and functions + - `get-{entity}` Entity getter function + - `create-{entity}` Entity creation function + - `update-{entity}` Entity update function + - `delete-{entity}` Entity delete function + - ... + - features/ + - ... + - widgets/ + - ... + - shared/ + - ... + + +If there are connections between the entities (for example, the Country entity has a field-list of City entities), +then you can use the [public API for cross-imports][public-api-for-cross-imports] or consider the alternative solution below. + +### Alternative solution — keep it in shared + +In cases where entity separation is not appropriate, the following structure can be considered: + + +- src/ + - ... + - shared/ + - api/ + - ... + - queries Query-factories + - document.ts + - background-jobs.ts + - ... + - index.ts + + +Then in `@/shared/api/index.ts`: + +```ts title="@/shared/api/index.ts" +export { documentQueries } from "./queries/document"; +``` + +## The problem of “Where to insert mutations?” + +It is not recommended to mix mutations with queries. There are two options: + +### 1. Define a custom hook in the `api` segment near the place of use + +```tsx title="@/features/update-post/api/use-update-title.ts" +export const useUpdateTitle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, newTitle }) => + apiClient + .patch(`/posts/${id}`, { title: newTitle }) + .then((data) => console.log(data)), + + onSuccess: (newPost) => { + queryClient.setQueryData(postsQueries.ids(id), newPost); + }, + }); +}; +``` + +### 2. Define a mutation function somewhere else (Shared or Entities) and use `useMutation` directly in the component + +```tsx +const { mutateAsync, isPending } = useMutation({ + mutationFn: postApi.createPost, +}); +``` + +```tsx title="@/pages/post-create/ui/post-create-page.tsx" +export const CreatePost = () => { + const { classes } = useStyles(); + const [title, setTitle] = useState(""); + + const { mutate, isPending } = useMutation({ + mutationFn: postApi.createPost, + }); + + const handleChange = (e: ChangeEvent) => + setTitle(e.target.value); + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutate({ title, userId: DEFAULT_USER_ID }); + }; + + return ( +
+ + + Create + + + ); +}; +``` + +## Organization of requests + +### Query factory + +A query factory is an object where the key values are functions that return a list of query keys. Here's how to use it: + +```tsx +const keyFactory = { + all: () => ["entity"], + lists: () => [...postQueries.all(), "list"], +}; +``` + + + +### 1. Creating a Query Factory + +```tsx title="@/entities/post/api/post.queries.ts" +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; +import { getDetailPost } from "./get-detail-post"; +import { PostDetailQuery } from "./query/post.query"; + +export const postQueries = { + all: () => ["posts"], + + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), + + details: () => [...postQueries.all(), "detail"], + detail: (query?: PostDetailQuery) => + queryOptions({ + queryKey: [...postQueries.details(), query?.id], + queryFn: () => getDetailPost({ id: query?.id }), + staleTime: 5000, + }), +}; +``` + +### 2. Using Query Factory in application code +```tsx +import { useParams } from "react-router-dom"; +import { postApi } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; + +type Params = { + postId: string; +}; + +export const PostPage = () => { + const { postId } = useParams(); + const id = parseInt(postId || ""); + const { + data: post, + error, + isLoading, + isError, + } = useQuery(postApi.postQueries.detail({ id })); + + if (isLoading) { + return
Loading...
; + } + + if (isError || !post) { + return <>{error?.message}; + } + + return ( +
+

Post id: {post.id}

+
+

{post.title}

+
+

{post.body}

+
+
+
Owner: {post.userId}
+
+ ); +}; +``` + +### Benefits of using a Query Factory +- **Request structuring:** A factory allows you to organize all API requests in one place, making your code more readable and maintainable. +- **Convenient access to queries and keys:** The factory provides convenient methods for accessing different types of queries and their keys. +- **Query Refetching Ability:** The factory allows easy refetching without the need to change query keys in different parts of the application. + +## Pagination + +In this section, we'll look at an example of the `getPosts` function, which makes an API request to retrieve post entities using pagination. + +### 1. Creating a function `getPosts` +The getPosts function is located in the `get-posts.ts` file, which is located in the `api` segment + +```tsx title="@/pages/post-feed/api/get-posts.ts" +import { apiClient } from "@/shared/api/base"; + +import { PostWithPaginationDto } from "./dto/post-with-pagination.dto"; +import { PostQuery } from "./query/post.query"; +import { mapPost } from "./mapper/map-post"; +import { PostWithPagination } from "../model/post-with-pagination"; + +const calculatePostPage = (totalCount: number, limit: number) => + Math.floor(totalCount / limit); + +export const getPosts = async ( + page: number, + limit: number, +): Promise => { + const skip = page * limit; + const query: PostQuery = { skip, limit }; + const result = await apiClient.get("/posts", query); + + return { + posts: result.posts.map((post) => mapPost(post)), + limit: result.limit, + skip: result.skip, + total: result.total, + totalPages: calculatePostPage(result.total, limit), + }; +}; +``` + +### 2. Query factory for pagination +The `postQueries` query factory defines various query options for working with posts, +including requesting a list of posts with a specific page and limit. + +```tsx +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; + +export const postQueries = { + all: () => ["posts"], + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), +}; +``` + + +### 3. Use in application code + +```tsx title="@/pages/home/ui/index.tsx" +export const HomePage = () => { + const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN; + const [page, setPage] = usePageParam(DEFAULT_PAGE); + const { data, isFetching, isLoading } = useQuery( + postApi.postQueries.list(page, itemsOnScreen), + ); + return ( + <> + setPage(page)} + page={page} + count={data?.totalPages} + variant="outlined" + color="primary" + /> + + + ); +}; +``` + + +## `QueryProvider` for managing queries +In this guide, we will look at how to organize a `QueryProvider`. + +### 1. Creating a `QueryProvider` +The file `query-provider.tsx` is located at the path `@/app/providers/query-provider.tsx`. + +```tsx title="@/app/providers/query-provider.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; + client: QueryClient; +}; + +export const QueryProvider = ({ client, children }: Props) => { + return ( + + {children} + + + ); +}; +``` + +### 2. Creating a `QueryClient` +`QueryClient` is an instance used to manage API requests. +The `query-client.ts` file is located at `@/shared/api/query-client.ts`. +`QueryClient` is created with certain settings for query caching. + +```tsx title="@/shared/api/query-client.ts" +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }, + }, +}); +``` + +## Code generation + +There are tools that can generate API code for you, but they are less flexible than the manual approach described above. +If your Swagger file is well-structured, +and you're using one of these tools, it might make sense to generate all the code in the `@/shared/api` directory. + + +## Additional advice for organizing RQ +### API Client + +Using a custom API client class in the shared layer, +you can standardize the configuration and work with the API in the project. +This allows you to manage logging, +headers and data exchange format (such as JSON or XML) from one place. +This approach makes it easier to maintain and develop the project because it simplifies changes and updates to interactions with the API. + +```tsx title="@/shared/api/api-client.ts" +import { API_URL } from "@/shared/config"; + +export class ApiClient { + private baseUrl: string; + + constructor(url: string) { + this.baseUrl = url; + } + + async handleResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + try { + return await response.json(); + } catch (error) { + throw new Error("Error parsing JSON response"); + } + } + + public async get( + endpoint: string, + queryParams?: Record, + ): Promise { + const url = new URL(endpoint, this.baseUrl); + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.append(key, value.toString()); + }); + } + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + return this.handleResponse(response); + } + + public async post>( + endpoint: string, + body: TData, + ): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return this.handleResponse(response); + } +} + +export const apiClient = new ApiClient(API_URL); +``` + +## See also \{#see-also\} + +- [(GitHub) Sample Project](https://github.com/ruslan4432013/fsd-react-query-example) +- [(CodeSandbox) Sample Project](https://codesandbox.io/p/github/ruslan4432013/fsd-react-query-example/main) +- [About the query factory](https://tkdodo.eu/blog/the-query-options-api) + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/docs/guides/tech/with-sveltekit.mdx b/src/content/docs/docs/guides/tech/with-sveltekit.mdx new file mode 100644 index 0000000000..231825dab0 --- /dev/null +++ b/src/content/docs/docs/guides/tech/with-sveltekit.mdx @@ -0,0 +1,100 @@ +--- +title: Usage with SvelteKit +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +It is possible to implement FSD in a SvelteKit project, but conflicts arise due to the differences between the structure requirements of a SvelteKit project and the principles of FSD: + +- Initially, SvelteKit offers a file structure inside the `src/routes` folder, while in FSD the routing must be part of the `app` layer. +- SvelteKit suggests putting everything not related to routing in the `src/lib` folder. + + +## Let's set up the config + +```ts title="svelte.config.ts" +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config}*/ +const config = { + preprocess: [vitePreprocess()], + kit: { + adapter: adapter(), + files: { + routes: 'src/app/routes', // move routing inside the app layer + lib: 'src', + appTemplate: 'src/app/index.html', // Move the application entry point inside the app layer + assets: 'public' + }, + alias: { + '@/*': 'src/*' // Create an alias for the src directory + } + } +}; +export default config; +``` + +## Move file routing to `src/app`. + +Let's create an app layer, move the app's entry point `index.html` into it, and create a routes folder. +Thus, your file structure should look like this: + + +- src + - app + - index.html + - routes + - pages FSD Pages folder + + +Now, you can create routes for pages within `app` and connect pages from `pages` to them. + +For example, to add a home page to your project, you need to do the following steps: +- Add a page slice inside the `pages` layer +- Add the corresponding rooute to the `routes` folder from the `app` layer +- Align the page from the slice with the rooute + +To create a page slice, let's use the [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +Create a ``home-page.svelte`` file inside the ui segment, access it using the Public API + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page.svelte'; +``` + +Create a route for this page inside the `app` layer: + + +- src + - app + - routes + - +page.svelte + - index.html + - pages + - home + - ui + - home-page.svelte + - index.ts + + +Add your page component inside the `+page.svelte` file: + +```html title="src/app/routes/+page.svelte" + + + + +``` + +## See also. + +- [Documentation on changing directory config in SvelteKit](https://kit.svelte.dev/docs/configuration#files) diff --git a/src/content/docs/docs/llms.mdx b/src/content/docs/docs/llms.mdx new file mode 100644 index 0000000000..2d7af028fa --- /dev/null +++ b/src/content/docs/docs/llms.mdx @@ -0,0 +1,18 @@ +--- +title: Docs for LLMs +--- + +This page provides links and guidance for LLM crawlers. + +- Spec: https://llmstxt.org/ + +### Files + +- llms.txt +- llms-small.txt +- llms-full.txt + +### Notes + +- Files are served from the site root, regardless of the current page path. +- In deployments with a non-root base URL (e.g., `/documentation/`), the links above are automatically prefixed. diff --git a/src/content/docs/docs/reference/layers.mdx b/src/content/docs/docs/reference/layers.mdx new file mode 100644 index 0000000000..8744224291 --- /dev/null +++ b/src/content/docs/docs/reference/layers.mdx @@ -0,0 +1,153 @@ +--- +title: Layers +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +Layers are the first level of organisational hierarchy in Feature-Sliced Design. Their purpose is to separate code based on how much responsibility it needs and how many other modules in the app it depends on. Every layer carries special semantic meaning to help you determine how much responsibility you should allocate to your code. + +There are **7 layers** in total, arranged from most responsibility and dependency to least: + +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. + +1. App +2. Processes (deprecated) +3. Pages +4. Widgets +5. Features +6. Entities +7. Shared + +You don't have to use every layer in your project — only add them if you think it brings value to your project. Typically, most frontend projects will have at least the Shared, Pages, and App layers. + +In practice, layers are folders with lowercase names (for example, `📁 shared`, `📁 pages`, `📁 app`). Adding new layers is _not recommended_ because their semantics are standardized. + +## Import rule on layers + +Layers are made up of _slices_ — highly cohesive groups of modules. Dependencies between slices are regulated by **the import rule on layers**: + +> _A module (file) in a slice can only import other slices when they are located on layers strictly below._ + +For example, the folder `📁 ~/features/aaa` is a slice with the name "aaa". A file inside of it, `~/features/aaa/api/request.ts`, cannot import code from any file in `📁 ~/features/bbb`, but can import code from `📁 ~/entities` and `📁 ~/shared`, as well as any sibling code from `📁 ~/features/aaa`, for example, `~/features/aaa/lib/cache.ts`. + +Layers App and Shared are **exceptions** to this rule — they are both a layer and a slice at the same time. Slices partition code by business domain, and these two layers are exceptions because Shared does not have business domains, and App combines all business domains. + +In practice, this means that layers App and Shared are made up of segments, and segments can import each other freely. + +## Layer definitions + +This section describes the semantic meaning of each layer to create an intuition for what kind of code belongs there. + +### Shared + +This layer forms a foundation for the rest of the app. It's a place to create connections with the external world, for example, backends, third-party libraries, the environment. It is also a place to define your own highly contained libraries. + +This layer, like the App layer, _does not contain slices_. Slices are intended to divide the layer into business domains, but business domains do not exist in Shared. This means that all files in Shared can reference and import from each other. + +Here are the segments that you can typically find in this layer: + +- `📁 api` — the API client and potentially also functions to make requests to specific backend endpoints. +- `📁 ui` — the application's UI kit. + Components on this layer should not contain business logic, but it's okay for them to be business-themed. For example, you can put the company logo and page layout here. Components with UI logic are also allowed (for example, autocomplete or a search bar). +- `📁 lib` — a collection of internal libraries. + This folder should not be treated as helpers or utilities ([read here why these folders often turn into a dump][ext-sova-utility-dump]). Instead, every library in this folder should have one area of focus, for example, dates, colors, text manipulation, etc. That area of focus should be documented in a README file. The developers in your team should know what can and cannot be added to these libraries. +- `📁 config` — environment variables, global feature flags and other global configuration for your app. +- `📁 routes` — route constants or patterns for matching routes. +- `📁 i18n` — setup code for translations, global translation strings. + +You are free to add more segments, but make sure that the name of these segments describes the purpose of the content, not its essence. For example, `components`, `hooks`, and `types` are bad segment names because they aren't that helpful when you're looking for code. + +### Entities + +Slices on this layer represent concepts from the real world that the project is working with. Commonly, they are the terms that the business uses to describe the product. For example, a social network might work with business entities like User, Post, and Group. + +An entity slice might contain the data storage (`📁 model`), data validation schemas (`📁 model`), entity-related API request functions (`📁 api`), as well as the visual representation of this entity in the interface (`📁 ui`). The visual representation doesn't have to produce a complete UI block — it is primarily meant to reuse the same appearance across several pages in the app, and different business logic may be attached to it through props or slots. + +#### Entity relationships + +Entities in FSD are slices, and by default, slices cannot know about each other. In real life, however, entities often interact with each other, and sometimes one entity owns or contains other entities. Because of that, the business logic of these interactions is preferably kept in higher layers, like Features or Pages. + +When one entity's data object contains other data objects, usually it's a good idea to make the connection between the entities explicit and side-step the slice isolation by making a cross-reference API with the `@x` notation. The reason is that connected entities need to be refactored together, so it's best to make the connection impossible to miss. + +For example: + +```ts title="entities/artist/model/artist.ts" +import type { Song } from "entities/song/@x/artist"; + +export interface Artist { + name: string; + songs: Array; +} +``` + +```ts title="entities/song/@x/artist.ts" +export type { Song } from "../model/song.ts"; +``` + +Learn more about the `@x` notation in the [Public API for cross-imports][public-api-for-cross-imports] section. + +### Features + +This layer is for the main interactions in your app, things that your users care to do. These interactions often involve business entities, because that's what the app is about. + +A crucial principle for using the Features layer effectively is: **not everything needs to be a feature**. A good indicator that something needs to be a feature is the fact that it is reused on several pages. + +For example, if the app has several editors, and all of them have comments, then comments are a reused feature. Remember that slices are a mechanism for finding code quickly, and if there are too many features, the important ones are drowned out. + +Ideally, when you arrive in a new project, you would discover its functionality by looking through the pages and features. When deciding on what should be a feature, optimize for the experience of a newcomer to the project to quickly discover large important areas of code. + +A feature slice might contain the UI to perform the interaction like a form (`📁 ui`), the API calls needed to make the action (`📁 api`), validation and internal state (`📁 model`), feature flags (`📁 config`). + +### Widgets + +The Widgets layer is intended for large self-sufficient blocks of UI. Widgets are most useful when they are reused across multiple pages, or when the page that they belong to has multiple large independent blocks, and this is one of them. + +If a block of UI makes up most of the interesting content on a page, and is never reused, it **should not be a widget**, and instead it should be placed directly inside that page. + + + +### Pages + +Pages are what makes up websites and applications (also known as screens or activities). One page usually corresponds to one slice, however, if there are several very similar pages, they can be grouped into one slice, for example, registration and login forms. + +There's no limit to how much code you can place in a page slice as long as your team still finds it easy to navigate. If a UI block on a page is not reused, it's perfectly fine to keep it inside the page slice. + +In a page slice you can typically find the page's UI as well as loading states and error boundaries (`📁 ui`) and the data fetching and mutating requests (`📁 api`). It's not common for a page to have a dedicated data model, and tiny bits of state can be kept in the components themselves. + +### Processes + + + +Processes are escape hatches for multi-page interactions. + +This layer is deliberately left undefined. Most applications should not use this layer, and keep router-level and server-level logic on the App layer. Consider using this layer only when the App layer grows large enough to become unmaintainable and needs unloading. + +### App + +All kinds of app-wide matters, both in the technical sense (e.g., context providers) and in the business sense (e.g., analytics). + +This layer usually doesn't contain slices, as well as Shared, instead having segments directly. + +Here are the segments that you can typically find in this layer: + +- `📁 routes` — the router configuration +- `📁 store` — global store configuration +- `📁 styles` — global styles +- `📁 entrypoint` — the entrypoint to the application code, framework-specific + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports +[ext-remix]: https://remix.run +[ext-sova-utility-dump]: https://dev.to/sergeysova/why-utils-helpers-is-a-dump-45fo diff --git a/src/content/docs/docs/reference/public-api.mdx b/src/content/docs/docs/reference/public-api.mdx new file mode 100644 index 0000000000..ac8ca73c02 --- /dev/null +++ b/src/content/docs/docs/reference/public-api.mdx @@ -0,0 +1,157 @@ +--- +title: Public API +sidebar: + order: 3 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +A public API is a _contract_ between a group of modules, like a slice, and the code that uses it. It also acts as a gate, only allowing access to certain objects, and only through that public API. + +In practice, it's usually implemented as an index file with re-exports: + +```js title="pages/auth/index.js" +export { LoginPage } from "./ui/LoginPage"; +export { RegisterPage } from "./ui/RegisterPage"; +``` + +## What makes a good public API? + +A good public API makes using and integrating into other code a slice convenient and reliable. It can be achieved by setting these three goals: + +1. The rest of the application must be protected from structural changes to the slice, like a refactoring +1. Significant changes in the behavior of the slice that break the previous expectations should cause changes in the public API +1. Only the necessary parts of the slice should be exposed + +The last goal has some important practical implications. It may be tempting to create wildcard re-exports of everything, especially in early development of the slice, because any new objects you export from your files are also automatically exported from the slice: + +```js title="Bad practice, features/comments/index.js" +// ❌ BAD CODE BELOW, DON'T DO THIS +export * from "./ui/Comment"; // 👎 don't try this at home +export * from "./model/comments"; // 💩 this is bad practice +``` + +This hurts the discoverability of a slice because you can't easily tell what the interface of this slice is. Not knowing the interface means that you have to dig deep into the code of a slice to understand how to integrate it. Another problem is that you might accidentally expose the module internals, which will make refactoring difficult if someone starts depending on them. + +## Public API for cross-imports \{#public-api-for-cross-imports\} + +Cross-imports are a situation when one slice imports from another slice on the same layer. Usually that is prohibited by the [import rule on layers][import-rule-on-layers], but often there are legitimate reasons to cross-import. For example, business entities often reference each other in the real world, and it's best to reflect these relationships in the code instead of working around them. + +For this purpose, there's a special kind of public API, also known as the `@x`-notation. If you have entities A and B, and entity B needs to import from entity A, then entity A can declare a separate public API just for entity B. + + +- entities + - A + - @x + - B.ts a special public API just for code inside `entities/B/` + - index.ts the regular public API + + +Then the code inside `entities/B/` can import from `entities/A/@x/B`: + +```ts +import type { EntityA } from "entities/A/@x/B"; +``` + +The notation `A/@x/B` is meant to be read as "A crossed with B". + + + +## Issues with index files + +Index files like `index.js`, also known as barrel files, are the most common way to define a public API. They are easy to make, but they are known to cause problems with certain bundlers and frameworks. + +### Circular imports + +Circular import is when two or more files import each other in a circle. + +
+ Three files importing each other in a circle + Three files importing each other in a circle +
+ Pictured above: three files, `fileA.js`, `fileB.js`, and `fileC.js`, importing each other in a circle. +
+
+ +These situations are often difficult for bundlers to deal with, and in some cases they might even lead to runtime errors that might be difficult to debug. + +Circular imports can occur without index files, but having an index file presents a clear opportunity to accidentally create a circular import. It often happens when you have two objects exposed in the public API of a slice, for example, `HomePage` and `loadUserStatistics`, and the `HomePage` needs to access `loadUserStatistics`, but it does it like this: + +```jsx title="pages/home/ui/HomePage.jsx" +import { loadUserStatistics } from "../"; // importing from pages/home/index.js + +export function HomePage() { /* … */ } +``` + +```js title="pages/home/index.js" +export { HomePage } from "./ui/HomePage"; +export { loadUserStatistics } from "./api/loadUserStatistics"; +``` + +This situation creates a circular import, because `index.js` imports `ui/HomePage.jsx`, but `ui/HomePage.jsx` imports `index.js`. + +To prevent this issue, consider these two principles. If you have two files, and one imports from the other: +- When they are in the same slice, always use _relative_ imports and write the full import path +- When they are in different slices, always use _absolute_ imports, for example, with an alias + +### Large bundles and broken tree-shaking in Shared \{#large-bundles\} + +Some bundlers might have a hard time tree-shaking (removing code that isn't imported) when you have an index file that re-exports everything. + +Usually this isn't a problem for public APIs, because the contents of a module are usually quite closely related, so you would rarely need to import one thing and tree-shake away the other. However, there are two very common cases when the normal rules of public API in FSD may lead to issues — `shared/ui` and `shared/lib`. + +These two folders are both collections of unrelated things that often aren't all needed in one place. For example, `shared/ui` might have modules for every component in the UI library: + + +- shared/ + - ui/ + - button/ + - text-field/ + - carousel/ + - accordion/ + + +This problem is made worse when one of these modules has a heavy dependency, like a syntax highlighter or a drag'n'drop library. You don't want to pull those into every page that uses something from `shared/ui`, for example, a button. + +If your bundles grow undesirably due to a single public API in `shared/ui` or `shared/lib`, it's recommended to instead have a separate index file for each component or library: + + +- shared/ + - ui/ + - button/ + - index.js + - text-field/ + - index.js + + +Then the consumers of these components can import them directly like this: + +```js title="pages/sign-in/ui/SignInPage.jsx" +import { Button } from '@/shared/ui/button'; +import { TextField } from '@/shared/ui/text-field'; +``` + +### No real protection against side-stepping the public API + +When you create an index file for a slice, you don't actually forbid anyone from not using it and importing directly. This is especially a problem for auto-imports, because there are several places from which an object can be imported, so the IDE has to decide that for you. Sometimes it might choose to import directly, breaking the public API rule on slices. + +To catch these issues automatically, we recommend using [Steiger][ext-steiger], an architectural linter with a ruleset for Feature-Sliced Design. + +### Worse performance of bundlers on large projects + +Having a large amount of index files in a project can slow down the development server, as noted by TkDodo in [his article "Please Stop Using Barrel Files"][ext-please-stop-using-barrel-files]. + +There are several things you can do to tackle this issue: +1. The same advice as in ["Large bundles and broken tree-shaking in Shared" issue](#large-bundles) — have separate index files for each component/library in `shared/ui` and `shared/lib` instead of one big one +2. Avoid having index files in segments on layers that have slices. + For example, if you have an index for the feature "comments", `📄 features/comments/index.js`, there's no reason to have another index for the `ui` segment of that feature, `📄 features/comments/ui/index.js`. +3. If you have a very big project, there's a good chance that your application can be split into several big chunks. + For example, Google Docs has very different responsibilities for the document editor and for the file browser. You can create a monorepo setup where each package is a separate FSD root, with its own set of layers. Some packages can only have the Shared and Entities layers, others might only have Pages and App, others still might include their own small Shared, but still use the big one from another package too. + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-please-stop-using-barrel-files]: https://tkdodo.eu/blog/please-stop-using-barrel-files diff --git a/src/content/docs/docs/reference/slices-segments.mdx b/src/content/docs/docs/reference/slices-segments.mdx new file mode 100644 index 0000000000..fbe61f7e33 --- /dev/null +++ b/src/content/docs/docs/reference/slices-segments.mdx @@ -0,0 +1,69 @@ +--- +title: Slices and segments +sidebar: + order: 2 +--- + +## Slices + +Slices are the second level in the organizational hierarchy of Feature-Sliced Design. Their main purpose is to group code by its meaning for the product, business, or just the application. + +The names of slices are not standardized because they are directly determined by the business domain of your application. For example, a photo gallery might have slices `photo`, `effects`, `gallery-page`. A social network would require different slices, for example, `post`, `comments`, `news-feed`. + +The layers Shared and App don't contain slices. That is because Shared should contain no business logic at all, hence has no meaning for the product, and App should contain only code that concerns the entire application, so no splitting is necessary. + +### Zero coupling and high cohesion \{#zero-coupling-high-cohesion\} + +Slices are meant to be independent and highly cohesive groups of code files. The graphic below might help to visualize the tricky concepts of _cohesion_ and _coupling_: + +
+ + +
+ Image inspired by https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/ +
+
+ +An ideal slice is independent from other slices on its layer (zero coupling) and contains most of the code related to its primary goal (high cohesion). + +The independence of slices is enforced by the [import rule on layers][layers--import-rule]: + +> _A module (file) in a slice can only import other slices when they are located on layers strictly below._ + +### Public API rule on slices + +Inside a slice, the code could be organized in any way that you want. That doesn't pose any issues as long as the slice provides a good public API for other slices to use it. This is enforced with the **public API rule on slices**: + +> _Every slice (and segment on layers that don't have slices) must contain a public API definition._ +> +> _Modules outside of this slice/segment can only reference the public API, not the internal file structure of the slice/segment._ + +Read more about the rationale of public APIs and the best practices on creating one in the [Public API reference][ref-public-api]. + +### Slice groups + +Closely related slices can be structurally grouped in a folder, but they should exercise the same isolation rules as other slices — there should be **no code sharing** in that folder. + +![Features "compose", "like" and "delete" grouped in a folder "post". In that folder there is also a file "some-shared-code.ts" that is crossed out to imply that it's not allowed.](../../../../../static/img/graphic-nested-slices.svg) + +## Segments + +Segments are the third and final level in the organizational hierarchy, and their purpose is to group code by its technical nature. + +There a few standardized segment names: + +- `ui` — everything related to UI display: UI components, date formatters, styles, etc. +- `api` — backend interactions: request functions, data types, mappers, etc. +- `model` — the data model: schemas, interfaces, stores, and business logic. +- `lib` — library code that other modules on this slice need. +- `config` — configuration files and feature flags. + +See the [Layers page][layers--layer-definitions] for examples of what each of these segments might be used for on different layers. + +You can also create custom segments. The most common places for custom segments are the App layer and the Shared layer, where slices don't make sense. + +Make sure that the name of these segments describes the purpose of the content, not its essence. For example, `components`, `hooks`, and `types` are bad segment names because they aren't that helpful when you're looking for code. + +[layers--layer-definitions]: /docs/reference/layers#layer-definitions +[layers--import-rule]: /docs/reference/layers#import-rule-on-layers +[ref-public-api]: /docs/reference/public-api diff --git a/src/content/docs/index.mdx b/src/content/docs/index.mdx new file mode 100644 index 0000000000..465017db2a --- /dev/null +++ b/src/content/docs/index.mdx @@ -0,0 +1,12 @@ +--- +title: Feature-Sliced Design +template: splash +hero: + tagline: + Architectural methodology for frontend projects + actions: + - text: Get started + link: docs/get-started/overview + icon: right-arrow + variant: primary +--- diff --git a/src/content/docs/ja/docs/about/alternatives.mdx b/src/content/docs/ja/docs/about/alternatives.mdx new file mode 100644 index 0000000000..36002faef1 --- /dev/null +++ b/src/content/docs/ja/docs/about/alternatives.mdx @@ -0,0 +1,88 @@ +--- +title: 代替案 +sidebar: + order: 3 + badge: + text: WIP + variant: caution +--- + +import { FileTree } from '@astrojs/starlight/components'; + +## ビッグボールオブマッド + +- [(記事) DDD - Big Ball of mud](https://thedomaindrivendesign.io/big-ball-of-mud/) + + +## スマート&ダムコンポーネント + +- [(記事) Dan Abramov - Presentational and Container Components (TLDR: 非推奨)](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) + + +## デザイン原則 + +## DDD + +## 参照 \{#see-also} + +- [(記事) DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) + +## クリーンアーキテクチャ + +- [(記事) DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) + +## フレームワーク + +- [(記事) FSDの作成理由 (フレームワークに関する断片)](/docs/about/motivation) + + +## Atomic Design + +### これは何か? + +アトミックデザインでは、責任の範囲が標準化された層に分かれています。 + +アトミックデザインは**5つの層**に分かれます(上から下へ)。 + +1. `pages` - FSDの`pages`層と同様の目的を持つ。 +2. `templates` - コンテンツに依存しないページの構造を定義するコンポーネント。 +3. `organisms` - ビジネスロジックを持つ分子から構成されるモジュール。 +4. `molecules` - 通常、ビジネスロジックを持たないより複雑なコンポーネント。 +5. `atoms` - ビジネスロジックを持たないUIコンポーネント。 + +同じ層のモジュールは、FSDのように下の層のモジュールとだけ相互作用しています。 +つまり、分子(molecule)は原子(atom)から構築され、生命体(organism)は分子から、テンプレート(template)は生命体から、ページ(page)はテンプレートから構築されます。 +また、アトミックデザインはモジュール内での**公開API**の使用を前提としています。 + +### フロントエンドでの適用性 +アトミックデザインはプロジェクトで比較的よく見られます。アトミックデザインは、開発者の間というより、ウェブデザイナーの間で人気です。ウェブデザイナーは、スケーラブルでメンテナンスしやすいデザインを作成するためにアトミックデザインをよく使用しています。 +開発では、アトミックデザインは他のアーキテクチャ設計方法論と混合されることがよくあります。 + +しかし、アトミックデザインはUIコンポーネントとその構成に焦点を当てているため、 +アーキテクチャ内でビジネスロジックを実装する問題が発生してしまいます。 + +問題は、アトミックデザインがビジネスロジックのための明確な責任レベルを提供していないため、 +ビジネスロジックがさまざまなコンポーネントやレベルに分散され、メンテナンスやテストが複雑になることです。 +ビジネスロジックは曖昧になり、責任の明確な分離が困難になり、コードがモジュール化されず再利用可能でなくなります。 + +### FSDとの統合 +FSDの文脈では、アトミックデザインのいくつかの要素を使用して柔軟でスケーラブルなUIコンポーネントを作成することができます。 `atoms`と`molecules`の層は、FSDの`shared/ui`に実装でき、基本的なUI要素の再利用とメンテナンスを簡素化しています。 + + +- shared/ + - ui/ + - atoms/ + - molecules/ + + +FSDとアトミックデザインの比較は、両方の設計方法論がモジュール性と再利用性を目指していることを示していますが、 +異なる側面に焦点を当てています。アトミックデザインは視覚的コンポーネントとその構成に焦点を当てています。 +FSDはアプリケーションの機能を独立したモジュールに分割し、それらの相互関係に焦点を当てています。 + +- [Atomic Design](https://atomicdesign.bradfrost.com/table-of-contents/) +- [(動画) Atomic Design: What is it and why is it important?](https://youtu.be/Yi-A20x2dcA) + +## Feature Driven + +- [(講演) Feature Driven Architecture - Oleg Isonen](https://youtu.be/BWAeYuWFHhs) +- [Feature Driven-Short specification (from the point of view of FSD)](https://github.com/feature-sliced/documentation/tree/rc/feature-driven) diff --git a/src/content/docs/ja/docs/about/mission.mdx b/src/content/docs/ja/docs/about/mission.mdx new file mode 100644 index 0000000000..da38867b03 --- /dev/null +++ b/src/content/docs/ja/docs/about/mission.mdx @@ -0,0 +1,50 @@ +--- +title: ミッション +sidebar: + order: 1 +--- + +ここでは、私たちがFSD方法論を開発する際に従う方法論適用の制限と目標について説明します。 + +- 私たちは、目標をイデオロギーとシンプルさのバランスとして考えている +- 私たちは、すべての人に適した銀の弾丸を作ることはできない + +**それでも、FSD方法論が広範な開発者にとって近く、アクセス可能であることを望んでいます。** + +## 目標 \{#goals} + +### 幅広い開発者に対する直感的な明確さ \{#intuitive-clarity-for-a-wide-range-of-developers} + +FSD方法論は、プロジェクトチームの大部分にとってアクセス可能であるべきです。 + +*なぜなら、将来のすべてのツールがあっても、FSD方法論を理解できるのは経験豊富なシニアやリーダーだけでは不十分だからである* + +### 日常的な問題の解決 \{#solving-everyday-problems} + +FSD方法論には、プロジェクト開発における日常的な問題の理由と解決策が示されるべきです。 + +また、開発者が*コミュニティーの経験に基づいた*アプローチを使用できるようにし、長年のアーキテクチャや開発の問題を回避できるようにするには、**FSD方法論はこれに関連するツール(CLI、リンター)を提供することも必要です。** + + +> *@sergeysova: 想像してみてください。開発者が方法論に基づいてコードを書いているとき、開発者の直面している問題は10倍少なく発生しています。それは他の人々が多くの問題の解決策を考え出したから、可能になったのです。* + +## 制限 \{#limitations} + +私たちは*自分たちの見解を押し付けたくありません*が、同時に*多くの開発者の習慣が日々の開発の妨げになっていることを理解しています。* + +すべての開発者にはシステム設計と開発経験レベルが異なるため、**次のことを理解することが重要です。** + +- FSD方法論は、すべての開発者にとって、同時に非常にシンプルで、非常に明確にするのは不可能 + > *@sergeysova: 一部の概念は、問題に直面し、解決に数年を費やさない限り、直感的に理解することはできない。* + > + > - *数学の例 — グラフ理論。* + > - *物理の例 — 量子力学。* + > - *プログラミングの例 — アプリケーションのアーキテクチャ。* + > +- シンプルさ、拡張性は、実現可能であって望ましい + +## 参照 \{#see-also} + +- [アーキテクチャの問題][refs-architecture--problems] + +[refs-architecture--problems]: /docs/about/understanding/architecture#problems diff --git a/src/content/docs/ja/docs/about/motivation.mdx b/src/content/docs/ja/docs/about/motivation.mdx new file mode 100644 index 0000000000..4a639002cf --- /dev/null +++ b/src/content/docs/ja/docs/about/motivation.mdx @@ -0,0 +1,132 @@ +--- +title: モチベーション +sidebar: + order: 1 +--- + +**Feature-Sliced Design**の主なアイデアは、さまざまな開発者の経験を議論し、研究結果を統合することに基づいて、複雑で発展するプロジェクトの開発を容易にし、開発コストを削減することです。 + +明らかに、これは銀の弾丸ではなく、当然ながら、FSDには独自の[適用範囲の限界][refs-mission]があります。 + +## 既存の解決策が不足している理由 \{#intuitive-clarity-for-a-wide-range-of-developers} +> 通常、次のような議論があります。 +> +> - *「SOLID」、「KISS」、「YAGNI」、「DDD」、「GRASP」、「DRY」など、すでに確立された設計原則があるのに、なぜ新しい方法論が必要なのか?」* +> - *「プロジェクトのすべての問題は、良いドキュメント、テスト、確立されたプロセスで解決できる」* +> - *「すべての問題は、すべての開発者が上記のすべてに従えば解決される」* +> - *「すでにすべてが考案されているから、あなたはそれを利用できないだけだ」* +> - *\{FRAMEWORK_NAME\}を使えば、すべてが解決される」* + +### 原則だけでは不十分 \{#principles-alone-are-not-enough} + +**良いアーキテクチャを設計するためには、原則の存在だけでは不十分です。** + +すべての人が原則を完全に理解しているわけではありません。正しく原則を理解し、適用できる人はさらに少ないです。 + +*設計原則はあまりにも一般的であり、「スケーラブルで柔軟なアプリケーションの構造とアーキテクチャをどのように設計するか?」という具体的な質問に対する答えを提供していません。* + +### プロセスは常に機能するわけではない \{#processes-dont-always-work} + +*ドキュメント/テスト/プロセス*を使用するのは、確かに良いことですが、残念ながら、それに多くのコストをかけても、**アーキテクチャの問題や新しい人をプロジェクトに導入する問題を解決することは常にできるわけではありません。** + +- ドキュメントは、しばしば膨大で古くなってしまうので、各開発者のプロジェクトへの参加時間はあまり短縮されない。 +- 誰もが同じようにアーキテクチャを理解しているかを常に監視することは、膨大なリソースを必要とする。 +- bus-factorも忘れないようにしましょう。 + +### 既存のフレームワークはどこでも適用できるわけではない \{#existing-frameworks-cannot-be-applied-everywhere} + +- 既存の解決策は通常、高い参入障壁があるため、新しい開発者を見つけるのが難しい。 +- ほとんどの場合、技術の選択はプロジェクトの深刻な問題が発生する前に決定されているため、**技術に依存せずに**、すでにあるもので作業をすることができなければならない。 + +> Q: *「私のプロジェクトでは`React/Vue/Redux/Effector/Mobx/{YOUR_TECH}`を使っていますが、エンティティの構造とそれらの間の関係をどのように構築すればよいでしょうか?」* + +### 結果として \{#as-a-result} + +「雪の結晶」のようにユニークなプロジェクトが得られ、それぞれが従業員の長期的な関与を必要とし、他のプロジェクトではほとんど適用できない知識を必要とします。 + +> @sergeysova: *これは、現在のフロントエンド開発の状況そのものであり、各リーダーがさまざまなアーキテクチャやプロジェクトの構造を考案しているが、これらの構造が時間の試練に耐えるかどうかは不明であり、最終的にはリーダー以外の人がプロジェクトを発展させることができるのは最大で2人であり、新しい開発者を再び入れる必要がある。* + +## 開発者にとっての方法論の必要性 \{#why-do-developers-need-the-methodology} + +### アーキテクチャの問題ではなくビジネス機能に集中するため \{#focus-on-business-features-not-on-architecture-problems} + +FSDは、スケーラブルで柔軟なアーキテクチャの設計にかかるリソースを節約し、開発者の注意を主要な機能開発に向けることを可能にしています。同時に、プロジェクトごとにアーキテクチャの解決策も標準化されます。 + +*別の問題は、FSDがコミュニティの信頼を得る必要があることです。そうすれば、開発者は自分のプロジェクトの問題を解決する際に、与えられた時間内にFSDを理解し、信頼することができます。* + +### 経験に基づく解決策 \{#an-experience-proven-solution} + +FSDは、*複雑なビジネスロジックの設計における経験に基づく解決策を目指す開発者*を対象としています。 + +*ただし、FSDは、全体としてベストプラクティスのセット、または特定の問題やケースに関する記事一覧です。したがって、開発や設計の問題に直面する他の開発者にも役立てます。* + +### プロジェクトの健康 \{#project-health} + +FSDは、*プロジェクトの問題を事前に解決し、追跡することを可能にし、膨大なリソースを必要としません。* + +**技術的負債は通常、時間とともに蓄積され、その解決の責任はリーダーとチームの両方にあります。** + +FSDは、*スケーリングやプロジェクトの発展における潜在的な問題を事前に警告することを可能にしています。* + +## ビジネスにとってのFSD方法論の必要性 \{#why-does-a-business-need-a-methodology} + +### 迅速なオンボーディング \{#fast-onboarding} + +FSDを使用すると、**すでにこのアプローチに慣れている人をプロジェクトに雇うことができ、再教育する必要がありません。** + +*人々はより早くプロジェクトに慣れ、貢献し始め、次のプロジェクトのイテレーションで人を見つけるための追加の保証が得られます。* + +### 経験に基づく解決策 \{#an-experience-proven-solution-1} + +ビジネスは、プロジェクトの発展における大部分の問題を解決するフレームワーク/解決策を得たいと考えています。FSDにより、ビジネスは*システムの開発中に発生するほとんどの問題に対する解決策を得ることができます。* + +### プロジェクトのさまざまな段階への適用性 \{#applicability-for-different-stages-of-the-project} + +FSDは、*プロジェクトのサポートと発展の段階でも、MVPの段階でもプロジェクトに利益をもたらすことができます。* + +はい、MVPでは通常、機能が重要であり、将来のアーキテクチャは重要ではありません。しかし、限られた時間の中で、方法論のベストプラクティスを知っていることで、少ないコストで済むことができ、MVPバージョンのシステムを設計する際に合理的な妥協を見つけることができます(無計画に機能を追加するよりも)。 + +*テストについても同じことが言えます。* + +## 私たちの方法論が必要ない場合 \{#when-is-our-methodology-not-needed} + +- プロジェクトが短期間しか存続しない場合 +- プロジェクトがサポートされるアーキテクチャを必要としない場合 +- ビジネスがコードベースと機能の提供速度の関連性を認識しない場合 +- ビジネスができるだけ早く注文を完了することを重視し、さらなるサポートを求めない場合 + +### ビジネスの規模 \{#business-size} + +- **小規模ビジネス** - 通常、迅速で即効性のある解決策を必要とします。ビジネスは、成長する(少なくとも中規模に達する)と、顧客が継続的にサービスなどを利用するためには、開発される解決策の品質と安定性に時間をかける必要があることを理解し始めます。 +- **中規模ビジネス** - 通常、開発のすべての問題を理解しており、たとえ機能をできるだけ早くリリースしたい場合でも、品質の改善、リファクタリング、テスト(そしてもちろん、拡張可能なアーキテクチャ)に時間をかけます。 +- **大規模ビジネス** - 通常、すでに広範なオーディエンスを持ち、従業員の数も多く、独自のプラクティスのセットを持っているため、他のアプローチを採用するアイデアはあまり浮かびません。 + +## 目標 \{#plans} + +主要な目標の大部分は[ここに記載されています][refs-mission--goals]が、今後のFSD方法論に対する私たちの期待についても話しておく必要があります。 + +### 経験の統合 \{#combining-experience} + +現在、私たちは`core-team`のさまざまな経験を統合し、実践に基づいた方法論を得ることを目指しています。 + +もちろん、最終的にはAngular 3.0のようなものを得るかもしれませんが、ここで最も重要なのは、**複雑なシステムのアーキテクチャ設計の問題を探求することです。** + +*そして、現在のFSD方法論のバージョンに対して不満があることは確かですが、私たちはコミュニティの経験も考慮しながら、共通の努力で統一的かつ最適な解決策に到達したいと考えています。* + +### 仕様外の生活 \{#life-outside-the-specification} + +すべてがうまくいけば、FSDは仕様やツールキットに限定されることはありません。 + +- 講演や記事があるかもしれない。 +- FSD方法論に従って書かれたプロジェクトの他の技術への移行のための`CODE_MODEs`があるかもしれない。 +- 最終的には、大規模な技術的解決策のメンテイナーに到達できるかもしれない。 + - *特にReactに関しては、他のフレームワークと比較して、これは主な問題である。なぜなら、特定の問題を解決する方法を示さないからである。* + +## 参照 \{#see-also} + +- [方法論の使命について:目標と制限][refs-mission] +- [プロジェクトにおける知識の種類][refs-knowledge] + +[refs-mission]: /docs/about/mission +[refs-mission--goals]: /docs/about/mission#goals +[refs-knowledge]: /docs/about/understanding/knowledge-types diff --git a/src/content/docs/ja/docs/about/promote/integration.mdx b/src/content/docs/ja/docs/about/promote/integration.mdx new file mode 100644 index 0000000000..a1cb1d174f --- /dev/null +++ b/src/content/docs/ja/docs/about/promote/integration.mdx @@ -0,0 +1,18 @@ +--- +title: 統合の側面 +sidebar: + order: 1 +--- + +# 統合の側面 + +**利点**: +- [概要](/docs/get-started/overview#advantages) +- コードレビュー +- オンボーディング + +**欠点**: +- メンタル的な複雑さ +- 高い参入障壁 +- 「レイヤー地獄」 +- 機能ベースのアプローチにおける典型的な問題 diff --git a/src/content/docs/ja/docs/about/understanding/abstractions.mdx b/src/content/docs/ja/docs/about/understanding/abstractions.mdx new file mode 100644 index 0000000000..218829e83d --- /dev/null +++ b/src/content/docs/ja/docs/about/understanding/abstractions.mdx @@ -0,0 +1,16 @@ +--- +title: 抽象化 +sidebar: + order: 6 + badge: + text: WIP + variant: caution +--- + +## 漏れのある抽象化の法則 \{#the-law-of-leaky-abstractions} + +## なぜこんなに多くの抽象化があるのか \{#why-are-there-so-many-abstractions} + +> 抽象化はプロジェクトの複雑さに対処するのに役立ちます。問題は、これらの抽象化がこのプロジェクトに特有のものになるのか、それともフロントエンドの特性に基づいて一般的な抽象化を導き出そうとするのかということです。 + +> アーキテクチャとアプリケーション全体は元々複雑であり、問題はその複雑さをどのように分配し、記述するかだけです。 diff --git a/src/content/docs/ja/docs/about/understanding/architecture.mdx b/src/content/docs/ja/docs/about/understanding/architecture.mdx new file mode 100644 index 0000000000..b327f1099b --- /dev/null +++ b/src/content/docs/ja/docs/about/understanding/architecture.mdx @@ -0,0 +1,96 @@ +--- +title: アーキテクチャ +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +## 問題 \{#problems} + +通常、アーキテクチャについての議論は、プロジェクトの開発が何らかの問題で停滞しているときに持ち上がります。 + +### バスファクターとオンボーディング + +プロジェクトとそのアーキテクチャを理解しているのは限られた人々だけです。 + +**例:** + +- *「新しい人を開発に加えるのが難しい」* +- *「問題があるたびに、各自が異なる回避策を持っている」* +- *「この大きなモノリスの中で何が起こっているのか理解できない」* + +### 暗黙的かつ制御されていない結果 \{#implicit-and-uncontrolled-consequences} + +開発やリファクタリングにおいて多くの暗黙的な副作用が発生してしまいます(「すべてがすべてに依存している」)。 + +**例:** + +- *「フィーチャーが他のフィーチャーをインポートしている」* +- *「あるページのストアを更新したら、別のページのフィーチャーが壊れた」* +- *「ロジックがアプリ全体に散らばっていて、どこが始まりでどこが終わりかわからない」* + +### 制御されていないロジックの再利用 \{#uncontrolled-reuse-of-logic} + +既存のロジックを再利用したり修正したりするのが難しいです。 + +通常、2つの極端なケースがあります。 + +- 各モジュールごとにロジックを完全にゼロから書く(既存のコードベースに重複が生じる可能性がある) +- すべてのモジュールを`shared`フォルダーに移動し、大きなモジュールの「ごみ屋敷」を作る(ほとんどが一箇所でしか使用されない) + +**例:** + +- *「プロジェクトに同じビジネスロジックの複数の実装があって、毎日その影響を受けている」* +- *「プロジェクトには6つの異なるボタン/ポップアップコンポーネントがある」* +- *「ヘルパー関数の「ごみ屋敷」」* + +## 要件 \{#requirements} + +したがって、理想的なアーキテクチャに対する要求を提示するのは、理にかなっています。 + + + +### 明示性 + +- チームがプロジェクトとそのアーキテクチャを**簡単に習得し、説明できる**ようにする必要がある +- 構造はプロジェクトの**ビジネス価値**を反映するべきである +- **副作用と抽象化間の関係**が明示されるべきである +- ユニークな実装を妨げず、**ロジックの重複を簡単に発見できる**ようにする必要がある +- プロジェクト全体に**ロジックが散らばってはいけない** +- 良好なアーキテクチャのために**あまりにも多くの異なる抽象化やルールが存在してはならない** + +### 制御 + +- 良好なアーキテクチャは**課題の解決や機能の導入を加速する**べきである +- プロジェクトの開発を**制御**できる必要がある +- コードを**拡張、修正、削除するのが簡単である**べきである +- 機能の**分解と孤立性**が守られるべきである +- システムの各コンポーネントは**簡単に交換可能で削除可能**であるべきである + - *未来を予測することはできないから、[変更に最適化する必要はない][ext-kof-not-modification]* + - *既存のコンテキストに基づいて、[削除に最適化する方が良い][ext-kof-but-removing]* + +### 適応性 + +- 良好なアーキテクチャは、ほとんどのプロジェクトに適用可能であるべきである + - *既存のインフラソリューションと共に* + - *どの発展段階でも* +- フレームワークやプラットフォームに依存してはいけない +- プロジェクトとチームを簡単にスケールアップでき、開発の並行処理が可能である必要がある +- 変化する要件や状況に適応するのが簡単であるべきである + +## 関連情報 \{#see-also} + +- [(React Berlin Talk) Oleg Isonen - Feature Driven Architecture][ext-kof] +- [(記事) プロジェクトのモジュール化について][ext-medium] +- [(記事) 関心の分離と機能に基づく構造について][ext-ryanlanciaux] + +[ext-kof-not-modification]: https://youtu.be/BWAeYuWFHhs?t=1631 +[ext-kof-but-removing]: https://youtu.be/BWAeYuWFHhs?t=1666 +[ext-kof]: https://youtu.be/BWAeYuWFHhs +[ext-medium]: https://alexmngn.medium.com/why-react-developers-should-modularize-their-applications-d26d381854c1 +[ext-ryanlanciaux]: https://ryanlanciaux.com/blog/2017/08/20/a-feature-based-approach-to-react-development/ diff --git a/src/content/docs/ja/docs/about/understanding/knowledge-types.mdx b/src/content/docs/ja/docs/about/understanding/knowledge-types.mdx new file mode 100644 index 0000000000..40b775860d --- /dev/null +++ b/src/content/docs/ja/docs/about/understanding/knowledge-types.mdx @@ -0,0 +1,25 @@ +--- +title: プロジェクトにおける知識の種類 +sidebar: + label: 知識の種類 + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + +どのプロジェクトにも以下の「知識の種類」が存在します。 + +* **基礎知識** + 時間とともにあまり変わらない知識。例えばアルゴリズム、コンピュータサイエンス、プログラミング言語やそのAPIの動作メカニズムなど。 + +* **技術スタック** + プロジェクトで使用される技術的解決策のセットに関する知識。プログラミング言語、フレームワーク、ライブラリを含む。 + +* **プロジェクト知識** + 現在のプロジェクトに特有であり、他のプロジェクトでは役に立たない知識。この知識は新しいチームメンバーが効果的にプロジェクトに貢献するために必要である。 + + diff --git a/src/content/docs/ja/docs/about/understanding/naming.mdx b/src/content/docs/ja/docs/about/understanding/naming.mdx new file mode 100644 index 0000000000..c0fc0e7922 --- /dev/null +++ b/src/content/docs/ja/docs/about/understanding/naming.mdx @@ -0,0 +1,36 @@ +--- +title: ネーミング +sidebar: + order: 4 +--- + +異なる開発者は異なる経験と背景を持っているため、同じエンティティが異なる名前で呼ばれることによって、チーム内で誤解が生じる可能性があります。例えば + +- 表示用のコンポーネントは「ui」、「components」、「ui-kit」、「views」などと呼ばれることがある。 +- アプリケーション全体で再利用されるコードは「core」、「shared」、「app」などと呼ばれることがある。 +- ビジネスロジックのコードは「store」、「model」、「state」などと呼ばれることがある。 + +## Feature-Sliced Designにおけるネーミング \{#naming-in-fsd} + +FSD設計方法論では、以下のような特定の用語が使用されます。 + +- 「app」、「process」、「page」、「feature」、「entity」、「shared」といった層の名前、 +- 「ui」、「model」、「lib」、「api」、「config」といったセグメントの名前。 + +これらの用語を遵守することは、チームメンバーやプロジェクトに新しく参加する開発者の混乱を防ぐために非常に重要です。標準的な名称を使用することは、コミュニティに助けを求める際にも役立ちます。 + +## 名前衝突 \{#when-can-naming-interfere} + +名前衝突は、FSD設計方法論で使用される用語がビジネスで使用される用語と重なっている場合に発生する可能性があります。例えば + +- `FSD#process`と、アプリケーション内でモデル化されたプロセス、 +- `FSD#page`と、マガジンのページ、 +- `FSD#model`と、自動車モデル。 + +開発者がコード内で「プロセス」という言葉を見た場合、どのプロセスが指されているのかを理解するのに余分な時間を費やすことになってしまいます。このような**衝突は開発プロセスを妨げる場合があります**。 + +プロジェクトの用語集にFSD特有の用語が含まれている場合、これらの用語をチームや技術的に関心のない関係者と議論する際には特に注意が必要です。 + +チームとの効果的なコミュニケーションのためには、用語の前に「FSD」という略語を付けることをお勧めします。例えば、プロセスについて話すときは、「このプロセスをFSDのフィーチャー層に置くことができる」と言うことができます。 + +逆に、技術的でない関係者とのコミュニケーションでは、FSDの用語の使用を制限し、コードベースの内部構造に言及しない方が良いでしょう。 diff --git a/src/content/docs/ja/docs/about/understanding/needs-driven.mdx b/src/content/docs/ja/docs/about/understanding/needs-driven.mdx new file mode 100644 index 0000000000..d5ac67e847 --- /dev/null +++ b/src/content/docs/ja/docs/about/understanding/needs-driven.mdx @@ -0,0 +1,152 @@ +--- +title: ニーズの理解と課題の定義について +sidebar: + order: 2 +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +## なぜ? \{#why} + +エンティティの明確な名前を選び、その構成要素を理解するためには、**コードが解決する課題を明確に理解する必要があります。** + +> _@sergeysova: 開発中、私たちは各エンティティや機能に、その意図や意味を明確に反映する名前を付けようとしている。_ + +_課題を理解しなければ、重要なケースをカバーする正しいテストを書くことも、ユーザーに適切な場所でエラーを表示することもできず、単純にユーザーのフローを中断することにもなってしまいます。_ + +## どのような課題についての話? \{#what-tasks-are-we-talking-about} + +フロントエンドは、エンドユーザーのためのアプリケーションやインターフェースを開発しているため、私たち開発者はその消費者の課題を解決しています。 + +私たちのもとに誰かが来るとき、**その人は自分の悩みを解決したり、ニーズを満たしたりしてほしいのです。** + +_マネージャーとアナリストの仕事はこのニーズを定義することです。開発者の仕事はウェブ開発の特性(接続の喪失、バックエンドのエラー、タイプミス、カーソルや指の操作ミス)を考慮して、そのニーズを実現することです。_ + +**ユーザーが持ってきた目的こそが、開発者の課題です。** + +> _小さな解決された課題が、Feature-Sliced Designの設計方法論におけるfeatureではあります。プロジェクト課題のスコープを小さな目標に分割する必要があります。_ + +## これが開発にどのように影響するのか? \{#how-does-this-affect-development} + +### 課題(タスク)の分解 \{#task-decomposition} + +開発者がタスクを実装し始めるとき、理解の簡素化とコードメンテナンスのために、**タスクを段階に分けます**。 + +- まずは、上位レベルのエンティティに分けて、それを実装する +- 次に、これらのエンティティをより小さく分ける +- そしてさらに続ける + +_エンティティを分解する過程で、開発者はそれに明確に意図を反映した名前を付ける必要があり、エンティティの一覧表を読む際にそのコードが解決する課題を理解するのに役立ちます。_ + +この際、ユーザーの悩みを軽減したり、ニーズを実現したりするユーザーへの手助けをすることを忘れないように心がけましょう。 + +### 課題の本質を理解する \{#understanding-the-essence-of-the-task} + +エンティティに明確な名前を付けるためには、**開発者はその目的について十分に理解する必要があります。** + +- エンティティをどのように使用するつもりなのか +- エンティティがユーザーの課題のどの部分を実現するのか、他にどこでこのエンティティを使用できるのか +- などなど + +結論を出すのは難しくありません。**開発者がFSD枠内でのエンティティの名前を考えているとき、コードを書く前に不十分に定義された課題を見つけることができます。** + +> どのようにエンティティに名前を付けるのか、もしそのエンティティが解決できる課題をよく理解していない場合、そもそもどうやって課題をエンティティに分解できるのか? + +## どのように定義するのか? \{#how-to-formulate-it} + +機能によって解決される課題を定義するためには、その課題自体を理解する必要があります。これはプロジェクトマネージャーやアナリストの責任範囲です。 + +_FSD設計方法論は、開発者に対して、プロダクトマネージャーが注目すべき課題を示唆することしかできません。_ + +> _@sergeysova: フロントエンドは、まず情報を表示するものである。どのコンポーネントも、まず何かを表示する。したがって、「ユーザーに何かを見せる」というタスクには実用的な価値がない。_ + +基本的なニーズや悩みを見つけたら、**あなたのプロダクトやサービスがどのようにユーザーの目標をサポートすることができるのかを考えます。** + +タスクトラッカーの新しいタスクは、ビジネスの課題を解決することを目的としており、ビジネスは同時にユーザーの課題を解決し、利益を上げようとしています。したがって、説明文に明記されていなくても、各タスクには特定の目標が含まれています。 + +開発者は、特定のタスクが追求する目的をはっきりと把握しておくべきです。しかし、すべての会社がプロセスを完璧に構築できるわけではありません。 + +## その利益は何か? \{#and-what-is-the-benefit} + +では、プロセス全体を最初から最後まで見てみましょう。 + +### 1. ユーザーの課題を理解する \{#1-understanding-user-tasks} + +開発者は、ユーザーの悩みとビジネスがその悩みをどのように解決するかを理解すると、ウェブ開発の特性によりビジネスには提供できない解決策を提案することができます。 + +> しかしもちろん、これは開発者が自分の行動や目的に無関心でない限り機能します。さもなければ、そもそもなぜFSDやアプローチが必要なのか?という疑問になってしまいます。 + +### 2. 構造化と整理 \{#2-structuring-and-ordering} + +課題を理解することで、**頭とコードの中で明確な構造が得られます。** + +### 3. 機能とその構成要素を理解する \{#3-understanding-the-feature-and-its-components} + +**1つの機能は、ユーザーにとって1つの有用な機能性です。** + +- 1つの機能に複数の機能性が実装されている場合、それは**境界の侵害**である。 +- 機能は分割不可能で成長可能になる場合があるが、**それは悪くない。** +- **悪い**のは、機能が「ユーザーにとってのビジネス価値は何か?」という質問に答えられないことである。 + - 「オフィスの地図」という機能は存在できない。 + - しかし、「地図上の会議室の予約」、「従業員の検索」、「作業場所の変更」は**存在可能である。** + +> _@sergeysova: 機能には、直接的にその機能を実現するコードだけが含まれるべきであり、余計な詳細や内部の解決策は含まれないべきである(理想的には)。_ + +> *機能のコードを開くと、**そのタスクに関連するものだけが見える**。それ以上は必要ない。* + +### 4. Profit \{#4-profit} + +ビジネスはその方針を極めて稀にしか根本的に変えないため、**ビジネスのタスクをフロントエンドアプリケーションのコードに反映することは非常に大きな利点になれます。** + +_そうすれば、チームの新しいメンバーにそのコードが何をするのか、なぜ追加されたのかを説明する必要がなくなります。**すべては、すでにコードに反映されているビジネスのタスクを通じて説明されているからです。**_ + +> [Domain Driven Developmentにおける「ビジネス言語」][ext-ubiq-lang] + +--- + +## 現実に戻りましょう \{#back-to-reality} + +ビジネスプロセスが明確な意味を持ち、設計段階で良い名前が付けられている場合、_その理解と論理をコードに移すことはそれほど問題ではありません。_ + +しかし実際には、タスクや機能性は通常「過度に」反復的に進化し、(または)デザインを考える時間がありません。 + +**その結果、今日、機能は意味を持っていますが、1か月後にその機能を拡張する際には、プロジェクト全体を再構築する必要があるかもしれません。** + +> *開発者は未来の要望を考慮しながら2〜3ステップ先を考えようとしますが、自分の経験に行き詰まってしまいます。* + +> _経験豊富なエンジニアは通常、すぐに10ステップ先を見て、どの機能を分割するか、どの機能を他の機能と統合するかを理解しています。_ + +> _しかし、経験上遭遇したことのないタスクが来ることもあり、その場合、どのように機能を適切に分解し、将来的に悲惨な結果を最小限に抑えるかを理解する手段がありません。_ + +## FSDの役割 \{#the-role-of-methodology} + +**FSDは、開発者の問題を解決する手助けをし、ユーザーの問題を解決するのを容易にしています。** + +開発者のためだけに課題を解決することはありません。 + +しかし、開発者が自分の課題を解決するためには、**ユーザーの課題を理解する必要があります**。逆は成り立ちません。 + +### FSDに対する要件 \{#methodology-requirements} + +明らかになるのは、**Feature-Sliced Design**のために少なくとも2つの要件を定義する必要があるということです。 + +1. FSD方法論は**フィーチャー、プロセス、エンティティを作成する方法**を説明する必要がある。 + - つまり、それらの間でコードをどのように分割するかを明確に説明する必要がある。これによりこれらのエンティティの命名も仕様に組み込まれるべきである。 +2. FSD方法論は、アーキテクチャがプロジェクトの変わりゆく要件にスムーズに対応できるようにするべきである。 + +## 関連情報 \{#see-also} + +- [(記事) "How to better organize your applications"][ext-medium] + +[refs-arch--adaptability]: architecture#adaptability + +[ext-medium]: https://alexmngn.medium.com/how-to-better-organize-your-react-applications-2fd3ea1920f1 +[ext-ubiq-lang]: https://thedomaindrivendesign.io/developing-the-ubiquitous-language diff --git a/src/content/docs/ja/docs/branding.mdx b/src/content/docs/ja/docs/branding.mdx new file mode 100644 index 0000000000..221a3be2e1 --- /dev/null +++ b/src/content/docs/ja/docs/branding.mdx @@ -0,0 +1,80 @@ +--- +title: ブランドガイドライン +--- + +import { Aside } from '@astrojs/starlight/components'; + +FSDのビジュアルアイデンティティは、そのコアコンセプトである `Layered`、`Sliced self-contained parts`、`Parts & Compose`、`Segmented` に基づいています。しかし、私たちはFSDの哲学を反映し、簡単に認識できる美しいアイデンティティを目指しています。 + +**FSDのアイデンティティを「そのまま」変更せずに、私たちのアセットを使って快適にご利用ください。** このブランドガイドは、FSDのアイデンティティを正しく使用する手助けをします。 + + + +## 名前 \{#title} + +- ✅ **正しい:** `Feature-Sliced Design`、`FSD` +- ❌ **間違っている:** `Feature-Sliced`、`Feature Sliced`、`FeatureSliced`、`feature-sliced`、`feature sliced`、`FS` + +## 絵文字 \{#emojii} + +ケーキのイメージ 🍰 はFSDの主要なコンセプトをよく反映しているため、私たちのブランド絵文字として選ばれました。 + +> 例: *"🍰 フロントエンド用ののアーキテクチャデザイン設計方法論"* + +## ロゴとカラーパレット \{#logo--palettte} + +FSDには異なるコンテキスト用のいくつかのロゴバリエーションがありますが、**primary**の使用が推奨されます。 + + + + + + + + + + + + + + + + + + + + + + + + + + + +
テーマロゴ (Ctrl/Cmd + Clickでダウンロード)使用法
primary
(#29BEDC, #517AED)
logo-primaryほとんどの場合に推奨されます
flat
(#3193FF)
logo-flat単色コンテキスト用
monochrome
(#FFF)
logo-monochrome白黒コンテキスト用
square
(#3193FF)
logo-square正方形サイズ用
+ +## バナーとスキーム \{#banners--schemes} + +banner-primary +banner-monochrome + +## ソーシャルプレビュー + +作業中... + +## プレゼンテーションテンプレート \{#presentation-template} + +作業中... + +## 参照 \{#see-also} + +- [ディスカッション (github)](https://github.com/feature-sliced/documentation/discussions/399) +- [リブランディングの歴史と参考資料 (figma)](https://www.figma.com/file/RPphccpoeasVB0lMpZwPVR/FSD-Brand?node-id=0%3A1) diff --git a/src/content/docs/ja/docs/get-started/faq.mdx b/src/content/docs/ja/docs/get-started/faq.mdx new file mode 100644 index 0000000000..f8c12cb24d --- /dev/null +++ b/src/content/docs/ja/docs/get-started/faq.mdx @@ -0,0 +1,67 @@ +--- +title: FAQ +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +### ツールキットやリンターはありますか? \{#is-there-a-toolkit-or-a-linter} + +はい!CLI または IDE を通じてプロジェクトのアーキテクチャと [フォルダー ジェネレーター][ext-tools] をチェックするための [Steiger][ext-steiger] というリンターがあります。 + +### ページのレイアウト/テンプレートはどこに保存すればよいですか? \{#where-to-store-the-layouttemplate-of-pages} + +シンプルなレイアウトテンプレートが必要な場合は、`shared/ui`に保存できます。より上層のレイヤーを使用する必要がある場合、いくつかのオプションがあります。 + +- レイアウトが本当に必要ですか?レイアウトが数行で構成されている場合、各ページにコードを重複させる方が合理的です。 +- レイアウトが必要な場合は、個別のウィジェットやページとして保存し、App層のルーター設定にそれらを組み合わせることができます。ネストされたルーティングも一つのオプションです。 + +### フィーチャーとエンティティの違いは何ですか? \{#what-is-the-difference-between-feature-and-entity} + +エンティティはアプリケーションが扱う現実世界の概念です。フィーチャーはユーザーに実際の価値を提供するインタラクションであり、ユーザーがエンティティで行いたいことです。 + +詳細および例については、参考書セクションの[スライスについてのページ][reference-entities]を参照してください。 + +### ページ/フィーチャー/エンティティを相互に埋め込むことはできますか? \{#can-i-embed-pagesfeaturesentities-into-each-other} + +はい、しかし、この埋め込みはより上層のレイヤーで行う必要があります。例えば、ウィジェット内で両方のフィーチャーをインポートし、プロップス/子要素として一方のフィーチャーを他方に挿入することができます。 + +一方のフィーチャーを他方のフィーチャーからインポートすることはできません。これは[**レイヤーのインポートルール**][import-rule-layers]で禁止されています。 + +### Atomic Designはどうですか? \{#what-about-atomic-design} + +現在、アトミックデザインをFeature-Sliced Designと一緒に使用することを義務付けていませんが、禁止もしていません。 + +アトミックデザインは、モジュールの`ui`セグメントにうまく適用できます。 + +### FSDに関する有用なリソース/記事などはありますか? \{#are-there-any-useful-resourcesarticlesetc-about-fsd} + +はい! https://github.com/feature-sliced/awesome + +### なぜFeature-Sliced Designが必要なのですか? \{#why-do-i-need-feature-sliced-design} + +FSDは、プロジェクトの主要な価値を提供するコンポーネントの観点から、あなたとあなたのチームが迅速にプロジェクトを把握するのに役立ちます。標準化されたアーキテクチャは、オンボーディングを迅速化し、コード構造に関する議論を解決するのに役立ちます。FSDが作成された理由については、[モチベーション][motivation]のページを参照してください。 + +### 初心者の開発者にFSDのアーキテクチャ/設計方法論は必要ですか? \{#does-a-novice-developer-need-an-architecturemethodology} + +おそらく必要です。 + +*通常、一人でプロジェクトを設計・開発する場合、すべてが順調に進みます。しかし、開発に中断が生じたり、新しい開発者がチームに加わると問題が発生します。* + +### 認証コンテキストをどのように扱えばよいですか? \{#how-do-i-work-with-the-authorization-context} + +[こちら](/docs/guides/examples/auth)で回答しています。 + +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[import-rule-layers]: /docs/reference/layers#import-rule-on-layers +[reference-entities]: /docs/reference/layers#entities +[motivation]: /docs/about/motivation +[telegram]: https://t.me/feature_sliced +[discord]: https://discord.gg/S8MzWTUsmp +[github-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/ja/docs/get-started/overview.mdx b/src/content/docs/ja/docs/get-started/overview.mdx new file mode 100644 index 0000000000..8032d6eaef --- /dev/null +++ b/src/content/docs/ja/docs/get-started/overview.mdx @@ -0,0 +1,142 @@ +--- +title: 概要 +sidebar: + order: 1 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +**Feature-Sliced Design** (FSD) とは、フロントエンドアプリケーションの設計方法論です。簡単に言えば、コードを整理するためのルールと規約の集大成です。FSDの主な目的は、ビジネス要件が絶えず変化する中で、プロジェクトをより理解しやすく、構造化されたものにすることです。 + +ルールのセットに加えて、FSDはツールチェーンでもあります。プロジェクトのアーキテクチャをチェックするための[リンター][ext-steiger]、CLIやIDEを通じた[フォルダージェネレーター][ext-tools]、および豊富な[実装例のコレクション][examples]があります。 + +## FSDは私のプロジェクトに適しているのか? \{#is-it-right-for-me} + +FSDは、あらゆる規模のプロジェクトやチームに導入できます。以下の場合、あなたのプロジェクトに適しています。 + +- **フロントエンド**開発での使用(ウェブサイト、モバイル/デスクトップアプリケーションのインターフェース作成など) +- **アプリケーション**開発での使用(ライブラリ開発ではない) + +これだけです!使用するプログラミング言語、フレームワーク、状態管理ライブラリには制限がありません。尚、FSDを段階的に導入したり、モノレポで使用したり、アプリケーションをパッケージに分割し、それぞれにFSDを個別に導入することもできます! + +既存のアーキテクチャからFSDに移行することを検討している場合は、現在のアーキテクチャがチームに**支障をきたしている**かどうかを確認してください。例えば、プロジェクトが大きくなりすぎて新機能の開発が効率的に行えない場合や、多くの新しいメンバーがチームに加わることが予想される場合です。現在のアーキテクチャが正常に機能している場合、変更する必要はないかもしれません。しかし、移行を決定した場合は、[移行セクション][migration]の推奨事項を確認してください。 + +## 基本的な例 \{#basic-example} + +以下は、FSDを実装したシンプルなプロジェクトです。 + + +- app/ +- pages/ +- shared/ + + +これらのトップレベルのフォルダーは*レイヤー*と呼ばれます。詳しく見てみましょう。 + + +- app/ + - routes/ + - analytics/ +- pages/ + - home/ + - article-reader/ + - ui/ + - api/ + - settings/ +- shared/ + - ui/ + - api/ + + +`📂 pages`内のフォルダーは*スライス*と呼ばれます。スライスはドメイン(この場合はページ)ごとにレイヤーを分割します。 + +`📂 app`、`📂 shared`、および`📂 pages/article-reader`内のフォルダーは*セグメント*と呼ばれ、スライス(またはレイヤー)を技術的な目的に応じて分割します。 + +## 概念 \{#concepts} + +レイヤー、スライス、セグメントは、以下の図に示されるように階層を形成します。 + +
+ ![FSDの概念の階層、以下に説明](../../../../../../static/img/visual_schema.jpg) + +
+

上の図には、左から右に「レイヤー」、「スライス」、「セグメント」とラベル付けされた3つの列があります。

+

「レイヤー」列には、上から下に「app」、「processes」、「pages」、「widgets」、「features」、「entities」、「shared」とラベル付けされた7つの区分があります。「processes」区分は取り消し線が引かれています。「entities」区分は2番目の列「スライス」と接続されていて、2番目の列が「entities」の内容であることを示しています。

+

「スライス」列には、上から下に「user」、「post」、「comment」とラベル付けされた3つの区分があります。「post」区分は「セグメント」列と同様に接続されていて、「post」の内容であることを示しています。

+

「セグメント」列には、上から下に「ui」、「model」、「api」とラベル付けされた3つの区分があります。

+
+
+ +### レイヤー \{#layers} + +レイヤーはすべてのFSDプロジェクトで標準化されています。すべてのレイヤーを使用する必要はありませんが、ネーミングは重要です。現在、7つのレイヤーが存在しています(上から下へ)。 + +1. App*(アップ) — アプリケーションの起動に必要なすべてのもの(ルーティング、エントリーポイント、グローバルスタイル、プロバイダーなど) +2. Processes(プロセス、非推奨) — 複雑なページ間のシナリオ +3. Pages(ページ) — ページ全体、またはネストされたルーティングの場合、ページの大部分 +4. Widgets(ウィジェット) — 大きな自己完結型の機能部分、またはインターフェースの大部分。通常はユーザーシナリオ全体を実装する +5. Features(フィーチャー) — プロダクト機能の再利用可能な実装、つまりユーザーにビジネス価値をもたらすアクション +6. Entities(エンティティ) — プロジェクトが扱うビジネスエンティティ、例えば`user`や`product` +7. Shared*(シェアード) — 再利用可能なコード。特にプロジェクト/ビジネスの詳細から切り離されたもの + +_* — App層とShared層のレイヤーは他のレイヤーとは異なり、スライスを持たず、直接セグメントで構成されています。_ + +レイヤーの特徴は、レイヤーのモジュールは、下層のレイヤーモジュールのみを知ることができ、その結果、レイヤーが下層のレイヤーからのみモジュールをインポートできることです。 + +### スライス \{#slices} + +次にスライスがあり、レイヤーをドメインごとに分割します。スライスの名前は自由に付けることができ、いくつでも作成できます。スライスは、意味的に関連するコードをグループ化することで、プロジェクト内のナビゲーションをしやすくします。 + +スライスは同じレイヤーの他のスライスを使用できないため、スライス内のコードの強い結合とスライス間の弱い結合が保証されます。 + +### セグメント \{#segments} + +スライス、およびApp層とShared層のレイヤーはセグメントで構成され、セグメントはその目的に応じてコードをグループ化します。セグメントの名前は標準で固定されていませんが、最も一般的な目的のためにいくつかの共通の名前があります。 + +- `ui` — 表示に関連するすべて: UIコンポーネント、日付フォーマッター、スタイルなど +- `api` — バックエンドとのやり取り: リクエスト関数、データ型、マッパー +- `model` — データモデル: バリデーションスキーマ、インターフェース、ストレージ、ビジネスロジック +- `lib` — 他のモジュールが必要とするライブラリコード +- `config` — 設定ファイルとフィーチャーフラグ + +通常、これらのセグメントはほとんどのレイヤーに十分であるため、独自のセグメントはShared層やApp層でのみ作成されることが多いです。しかし、これは厳格なルールではありません。 + +## 利点 \{#advantages} + +- **一貫性** + 構造が標準化されているため、プロジェクトがより一貫性を持ち、新しいメンバーのチームへの参加が容易になります。 + +- **変更とリファクタリングへの耐性** + レイヤーのモジュールは、同じレイヤーや上層レイヤーの他のモジュールを使用できないため、アプリケーションの他の部分に予期しない影響を与えることなく、分離された変更を加えることができます。 + +- **ロジックの再利用制御** + レベルに応じて、コードを非常に再利用可能にすることも、非常にローカルにすることもできます。 + これにより、**DRY**原則と実用性のバランスが保たれます。 + +- **ビジネスとユーザーのニーズに焦点を当てる** + アプリケーションはビジネスドメインに分割され、命名にはビジネス用語の使用が奨励されるため、プロジェクトの他の無関係な部分に完全に精通することなく、プロダクトで有用な作業を行うことができます。 + +## 段階的な導入 \{#incremental-adoption} + +既存のコードベースをFSDに移行したい場合は、以下の戦略をお勧めします。私たち自身の移行経験から、この方法は非常に効果的であることが分かりました。 + +1. App層とShared層のレイヤーを徐々に形成し、基盤を作る。 + +2. 既存のすべてのインターフェースコードをウィジェットとページに分散させる。FSDのルールに違反する依存関係があっても良い。 + +3. インポートのルール違反を徐々に修正しながら、エンティティやフィーチャーを抽出する。 + +リファクタリング中に新しい大きなエンティティを追加することや、部分的なリファクタリングは避けることをお勧めします。 + +## 次のステップ \{#next-steps} + +- **FSDの考え方を理解したい?** [チュートリアル][tutorial]を読んでください。 +- **例を見て学びたい?** [実装例セクション][examples]にたくさんあります。 +- **質問がある?** [Discordチャンネル][ext-discord]にアクセスして、コミュニティに質問してください。 + +[tutorial]: /docs/get-started/tutorial +[examples]: /examples +[migration]: /docs/guides/migration/from-custom +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[ext-discord]: https://discord.com/invite/S8MzWTUsmp diff --git a/src/content/docs/ja/docs/get-started/tutorial.mdx b/src/content/docs/ja/docs/get-started/tutorial.mdx new file mode 100644 index 0000000000..c74ca6464a --- /dev/null +++ b/src/content/docs/ja/docs/get-started/tutorial.mdx @@ -0,0 +1,2266 @@ +--- +title: チュートリアル +sidebar: + order: 2 +--- + +import { Aside, FileTree } from '@astrojs/starlight/components'; + +## 第1章 紙の上で + +このガイドでは、Real World Appとしても知られるアプリケーションを見ていきます。Conduitは、[Medium](https://medium.com/)の簡略版であり、ブログ記事を読み書きし、他の人の記事にコメントすることができます。 + +![Conduitのホームページ](../../../../../../static/img/tutorial/realworld-feed-anonymous.jpg) + +これはかなり小さなアプリケーションなので、過度に分解することなく開発を進めます。おそらく、アプリケーション全体は3つの層に収まります: **App層**、**Pages層**、**Shared層**。もしそうでなければ、進行に応じて追加の層を導入しましょう。準備はいいですか? + +### ページの列挙から始める + +上のスクリーンショットを見てみると、少なくとも次のページがあると推測できます。 + +- ホーム(記事のフィード) +- ログインと登録 +- 記事の閲覧 +- 記事の編集 +- ユーザープロフィールの閲覧 +- プロフィールの編集(設定) + +これらの各ページは、*Pages*層の個別*スライス*になります。概要のセクションから思い出してください。スライスは単に層内のフォルダーであり、層は事前に定義された名前のフォルダーだけです。例えば、`pages`のようです。 + +したがって、私たちのPagesフォルダーは次のようになります。 + + +- pages + - feed/ フィード + - sign-in/ ログイン/登録 + - article-read/ 記事の閲覧 + - article-edit/ 記事の編集 + - profile/ プロフィール + - settings/ 設定 + + +Feature-Sliced Designの特徴は、ページが互いに依存できないことです。つまり、1つのページが他のページのコードをインポートすることはできません。これは**層のインポートルール**によって禁じられています。 + +*スライス内のモジュール(ファイル)は、下層にあるスライスのみをインポートできる。* + +この場合、ページはスライスであるため、そのページ内のモジュール(ファイル)は、他のページではなく、下層からのみコードをインポートできます。 + +### フィードを詳しく見てみると + +
+ ![匿名訪問者の視点](../../../../../../static/img/tutorial/realworld-feed-anonymous.jpg) +
+ _匿名訪問者の視点_ +
+
+ +
+ ![認証されたユーザーの視点](../../../../../../static/img/tutorial/realworld-feed-authenticated.jpg) +
+ _認証されたユーザーの視点_ +
+
+ +フィードページには3つの動的領域があります。 + +1. 認証状態を示すログインリンク +2. フィードをフィルタリングするタグ一覧 +3. 1つ、または2つのフィード記事。各記事にはいいねボタンがある + +ログインリンクは、すべてのページで共通のヘッダーの一部であるため、一旦保留にしましょう。 + +#### タグ一覧 + +タグ一覧を作成するには、すべての利用可能なタグを取得し、各タグをチップ([chip](https://m3.material.io/components/chips/overview))として表示し、選択されたタグをクライアント側のストレージに保存する必要があります。これらの操作は、「APIとのインタラクション」、「ユーザーインターフェース」、「データストレージ」のカテゴリに関連しています。Feature-Sliced Designでは、コードは目的に応じて*セグメント*に分けられます。セグメントはスライス内のフォルダーであり、目的を説明する任意の名前を持つことができます。いくつかの目的は非常に一般的であるため、いくつかの一般的な名前があります。 + +- 📂 `api/` バックエンドとのインタラクション +- 📂 `ui/` 表示と外観を担当するコード +- 📂 `model/` データとビジネスロジックのストレージ +- 📂 `config/` フィーチャーフラグ、環境変数、その他の設定形式 + +タグを取得するコードは`api`に、タグコンポーネントは`ui`に、ストレージとのインタラクションは`model`に配置します。 + +#### 記事 + +同じ論理に従って、記事のフィードを同じ3つのセグメントに分けることができます。 + +- 📂 `api/`: ページごとの記事一覧を取得したり、いいねを残したりする +- 📂 `ui/`: + - タグを選択したときに追加のタブを表示できるタブ一覧 + - 個別の記事 + - ページネーション +- 📂 `model/`: クライアントのストレージに保存された読み込まれた投稿と現在のページ(必要に応じて) + +### 共通コードの再利用 + +アプリケーションのページは通常、目的によって非常に異なりますが、全体で共通するものもあります。例えば、デザイン言語に対応するUIキットや、すべてが特定の認証メソッドを介してREST APIを通じて行われるというバックエンドにおける取り決めです。スライスは隔離されている必要があるため、コードの再利用は下層の**Shared層**を介して行われます。 + +Shared層は他の層とは異なり、スライスではなくセグメントを含むため、Shared層はレイヤーとスライスのハイブリッドです。 + +通常、Shared層内のコードは事前に作成されず、開発の過程で抽出されます。なぜなら、どの部分のコードが実際に再利用されるかが開発中に明らかになるからです。それでも、Shared層にどんなコードを保持するかを念頭に置いておくことは重要です。 + +- 📂 `ui/` — ビジネスロジックなしのUIキット。例えば、ボタン、ダイアログ、フォームフィールド。 +- 📂 `api/` — バックエンドへのリクエスト用の便利なラッパー(例えば、ウェブの場合は、`fetch()`のラッパー) +- 📂 `config/` — 環境変数の処理 +- 📂 `i18n/` — 多言語対応の設定 +- 📂 `router/` — ルーティングのプリミティブと定数 + +これらはShared層内のセグメントの例に過ぎません。これらのいずれかを省略したり、自分自身のセグメントを作成したりできます。新しいセグメントを作成する際に覚えておくべき唯一のことは、セグメントの名前は内容の本質(何)ではなく、目的(なぜ)を説明するものでなければなりません。`components`、`hooks`、`modals`のような名前は使用しない方が良いです。なぜなら、これらはファイルが本質的に何を含んでいるかを説明するものであり、コードが書かれた目的を説明するものではないからです。このようなネーミングの結果、チームは必要なものを見つけるためにフォルダーを掘り下げなければならず、さらに無関係なコードが隣接しているため、リファクタリング時にアプリケーションの大部分に影響を与え、レビューやテストが難しくなってしまいます。 + +### 公開APIを定義する + +Feature-Sliced Designの文脈において、*公開API*という用語は、スライス、またはセグメントが、プロジェクト内の他のモジュールがインポートできるものを宣言することを意味します。例えば、JavaScriptでは、他のファイルからオブジェクトを再エクスポートする`index.js`ファイルがこれに該当します。これにより、外部との契約(つまり、公開API)が変更されない限り、スライス内でのリファクタリングを自由にできます。 + +Shared層にはスライスがないため、通常、セグメントレベルで公開API(インデックス)を定義する方が便利です。そうすることで、Shared層からのインポートは自然に目的に応じて整理されます。他のレイヤーにはスライスがあるため、通常は1つのインデックスをスライスに定義し、スライス自身が内部のセグメントのセットを制御する方が実用的です。なぜなら、他のレイヤーは通常、エクスポートがはるかに少なく、リファクタリングが頻繁に行われるからです。 + +私たちのスライス/セグメントは次のようになるでしょう。 + + +- pages/ + - feed/ + - index + - sign-in/ + - index + - article-read/ + - index + - ... +- shared/ + - ui/ + - index + - api/ + - index + - ... + + +`pages/feed`や`shared/ui`のようなフォルダー内にあるものは、これらのフォルダーにのみ知られており、これらのフォルダーの内容に関する保証はありません。 + +### 大きな再利用可能なUIブロック + +以前、再利用可能なアプリケーションのヘッダーのところに戻りますが、各ページでヘッダーを再構築するのは非効率的なので、再利用します。再利用するコードには、すでにShared層がありますが、Shared層内の大きなUIブロックには注意が必要です。Shared層は上層のレイヤーについて何も知らないべきです。 + +Shared層とPages層の間には、Entities層、Features層、Widgets層の3つの他のレイヤーがあります。他のプロジェクトでは、これらのレイヤーに大きな再利用可能なブロックで使用したいものがあるかもしれません。その場合、そのブロックをShared層に置くことはできません。なぜなら、上層からインポートしなければならず、それは禁止されているからです。ここでWidgets層が役立ちます。これはShared層、Entities層、Features層の上に位置しているため、すべてを使用できます。 + +私たちの場合、ヘッダーは非常にシンプルです。静的なロゴと上部ナビゲーションしかありません。ナビゲーションはAPIに現在のユーザーが認証されているかどうかを尋ねる必要がありますが、これは`api`セグメントからの単純なインポートで解決できます。したがって、ヘッダーはShared層に残します。 + +### フォームページに着目 + +記事を読むだけでなく、編集することもできるページも見てみましょう。例えば、記事編集者のページです。 + +![Conduitの記事編集者](../../../../../../static/img/tutorial/realworld-editor-authenticated.jpg) + +見た目は単純ですが、私たちがまだ調べていないアプリケーション開発のいくつかの側面を含んでいます。フォームのバリデーション、エラー状態、データの永続的な保存のようなものです。 + +このページを作成するには、Shared層からいくつかのフィールドとボタンを取り、それらをこのページの`ui`セグメントにあるフォームにまとめます。次に、`api`セグメントで、バックエンドに記事を作成するための変更リクエストを定義します。 + +リクエストを送信する前にリクエストをバリデーションするために、バリデーションスキーマが必要です。バリデーションスキーマはデータモデルであるため、`model`セグメントに入れるのがちょうど良いです。そこでエラーメッセージを生成し、`ui`セグメントの別のコンポーネントを使用してエラーメッセージを表示します。 + +UXを向上させるために、ブラウザを閉じたときに偶発的なデータ損失を防ぐために、入力データを永続的に保存することもできます。これも`model`セグメントに適しています。 + +### まとめ + +いくつかのページに着目し、アプリケーションの基本的な構造を決めることができました。 + +1. Shared層 + 1. `ui` には再利用可能なUIキットが含まれる + 2. `api` にはバックエンドとのインタラクションのためのプリミティブが含まれる + 3. 残りはコードを書く過程で整理する +2. Pages層 — 各ページに対して個別のスライスを作成 + 1. `ui` にはページ自体とその構成要素が含まれる + 2. `api` には`shared/api`を使用するデータ取得のためのより専用的な関数が含まれる + 3. `model` には表示するデータのクライアントストレージなどが含まれる + +これでこのアプリケーションを作りましょう! + +## 第2章 コードの中で + +計画ができたので、実現していきましょう。Reactと[Remix](https://remix.run/)を使用します。 + +このプロジェクトにはすでにテンプレートが用意されているので、GitHubからクローンして作成を始めてください。 + +[https://github.com/feature-sliced/tutorial-conduit/tree/clean](https://github.com/feature-sliced/tutorial-conduit/tree/clean) + +依存関係を`npm install`でインストールし、`npm run dev`でサーバーを起動します。[http://localhost:3000](http://localhost:3000/)を開くと、空のアプリケーションが表示されます。 + +### ページごとに整理する + +すべてのページのために空のコンポーネントを作成することから始めましょう。ターミナルで次のコマンドを実行します。 + +```bash +npx fsd pages feed sign-in article-read article-edit profile settings --segments ui +``` + +これにより、`pages/feed/ui/`のようなフォルダーと、各ページのインデックスファイル`pages/feed/index.ts`が作成されます。 + +### フィードページを接続する + +アプリケーションのルート(`/`)をフィードページに接続しましょう。`pages/feed/ui`に`FeedPage.tsx`コンポーネントを作成し、次の内容を入れます。 + +```tsx title="pages/feed/ui/FeedPage.tsx" +export function FeedPage() { + return ( +
+
+
+

conduit

+

知識を共有する場

+
+
+
+ ); +} +``` + +次に、このコンポーネントをフィードページの公開APIに再エクスポートします。 + +```tsx title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +``` + +次に、ルートに接続します。Remixでは、ルーティングはファイルに基づいていて、ルートファイルは`app/routes`フォルダーにあります。これはFeature-Sliced Designとよく組み合っています。 + +`app/routes/_index.tsx`で`FeedPage`コンポーネントを使用します。 + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +これで、devサーバーを起動し、アプリケーションを開くと、Conduitのバナーが表示されるはずです! + +![Conduitのバナー](/img/tutorial/conduit-banner.jpg) + +### APIクライアント + +RealWorldのバックエンドと通信するために、Shared層内に便利なAPIクライアントを作成しましょう。クライアント用の`api`セグメントと、バックエンドの基本URLなどの変数用の`config`セグメントを作成します。 + +```bash +npx fsd shared --segments api config +``` + +次に、`shared/config/backend.ts`を作成します。 + +```tsx title="shared/config/backend.ts" +export { mockBackendUrl as backendBaseUrl } from "mocks/handlers"; +``` + +```tsx title="shared/config/index.ts" +export { backendBaseUrl } from "./backend"; +``` + +RealWorldプロジェクトは[OpenAPI仕様](https://github.com/gothinkster/realworld/blob/main/api/openapi.yml)を提供しているため、APIクライアントの型を自動的に生成できます。私たちは[`openapi-fetch`パッケージ](https://openapi-ts.pages.dev/openapi-fetch/)を使用します。このパッケージにはTypeScriptの型を自動生成するツールも含まれています。 + +次のコマンドを実行して、APIの最新の型を生成しましょう。 + +```bash +npm run generate-api-types +``` + +その結果、`shared/api/v1.d.ts`ファイルが作成されます。このファイルを使用して、`shared/api/client.ts`で型付きAPIクライアントを作成します。 + +```tsx title="shared/api/client.ts" +import createClient from "openapi-fetch"; + +import { backendBaseUrl } from "shared/config"; +import type { paths } from "./v1"; + +export const { GET, POST, PUT, DELETE } = createClient({ baseUrl: backendBaseUrl }); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; +``` + +### フィード内の実データ + +これで、バックエンドから記事を取得し、フィードに追加できます。まず、記事プレビューコンポーネントを実装しましょう。 + +`pages/feed/ui/ArticlePreview.tsx`を作成し、次の内容を記述します。 + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +export function ArticlePreview({ article }) { /* TODO */ } +``` + +私たちはTypeScriptを使っているので、型付きのArticleオブジェクトを持つと良いでしょう。生成された`v1.d.ts`を調べると、Articleオブジェクトは`components["schemas"]["Article"]`を介して利用可能であることがわかります。これでShared層内にデータモデルを持つファイルを作成し、モデルをエクスポートしましょう。 + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; +``` + +これで、記事プレビューコンポーネントに戻って、データでマークアップを埋めることができます。次の内容をコンポーネントに追加します。 + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+ +
+ +

{article.title}

+

{article.description}

+ 続きを読む... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +「いいね」ボタンはまだ機能していません。それは記事の読み取りページに移動して、「いいね」機能を実装するときに修正します。 + +これで、記事を取得して、たくさんのプレビューカードを表示できます。Remixでは、データの取得は*ローダー*を使用して行われます。ローダーは、ページに必要なデータを収集するサーバー関数です。ローダーはページの代わりにAPIとやり取りをするため、`api`セグメントに配置します。 + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; + +import { GET } from "shared/api"; + +export const loader = async () => { + const { data: articles, error, response } = await GET("/articles"); + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return json({ articles }); +}; +``` + +これをページに接続するには、ルートファイルから`loader`としてエクスポートする必要があります。 + +```tsx title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +export { loader } from "./api/loader"; +``` + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export { loader } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +最後のステップは、これらのカードをフィードに表示することです。`FeedPage`を次のコードで更新します。 + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

知識を共有する場

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+
+
+
+ ); +} +``` + +### タグによるフィルタリング + +タグに関しては、バックエンドから取得し、ユーザーが選択したタグを記憶する必要があります。私たちはバックエンドからの取得方法はすでに知っています。これはローダー関数からの別のリクエストです。すでにインストールされている`remix-utils`パッケージの便利な`promiseHash`関数を使用します。 + +`pages/feed/api/loader.ts`のローダーを次のコードで更新します。 + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async () => { + return json( + await promiseHash({ + articles: throwAnyErrors(GET("/articles")), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +エラー処理を共通の`throwAnyErrors`関数に移したことに気付いたでしょうか。それはかなり使えそうに見えるので、後で再利用するかもしれません。 + +タグ一覧をインタラクティブにする必要があります。タグをクリックすると、そのタグが選択されるようにします。Remixの伝統に従い、選択されたタグのストレージとしてURLのクエリパラメータを使用します。ブラウザにストレージを任せ、私たちはより重要なことに集中しましょう。 + +`pages/feed/ui/FeedPage.tsx`を次のコードで更新します。 + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles, tags } = useLoaderData(); + + return ( +
+
+
+

conduit

+

知識を共有する場

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+ +
+
+

人気のタグ

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +次に、タグの検索パラメータをローダーで使用する必要があります。`pages/feed/api/loader.ts`の`loader`関数を次のように変更します。 + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { params: { query: { tag: selectedTag } } }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +以上です。最終的に`model`セグメントは必要ありませんでした。Remixはすごいですよね。 + +### ページネーション + +同様に、ページネーションを実装できます。自分で実装してみても、以下のコードをコピーしても良いです。 + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +/** 1ページあたりの記事の数。 */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const [searchParams] = useSearchParams(); + const { articles, tags } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+
+
+

conduit

+

知識を共有する場

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} + +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ +
+ +
+
+

人気のタグ

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +よし、これも実現しました。タグ一覧も同様に実装できますが、認証を実装するまで待ちましょう。ところで、認証についてですが! + +### 認証 + +認証には、ログイン用のページと登録用のページの2つがあります。これらは主に非常に似ているため、必要に応じてコードを再利用できるように、1つの`sign-in`セグメントに保持するのが理にかなっています。 + +`pages/sign-in`の`ui`セグメントに`RegisterPage.tsx`を作成し、次の内容を配置します。 + +```tsx title="pages/sign-in/ui/RegisterPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { register } from "../api/register"; + +export function RegisterPage() { + const registerData = useActionData(); + + return ( +
+
+
+
+

登録

+

+ アカウントをお持ちですか? +

+ + {registerData?.error && ( +
    + {registerData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +これからは壊れたインポートを修正する必要があります。インポートが新しいセグメントにアクセスしているため、次のコマンドでそのセグメントを作成しましょう。 + +```bash +npx fsd pages sign-in -s api +``` + +ただし、登録のバックエンド部分を実装する前に、Remixのセッション処理のためのインフラコードが必要です。これは他のページでも必要になる可能性があるため、Shared層に配置します。 + +次のコードを`shared/api/auth.server.ts`に配置しましょう。このコードはRemixに特有のものであり、すべてが理解できなくても心配しないでください。単にコピーして貼り付けてください。 + +```tsx title="shared/api/auth.server.ts" +import { createCookieSessionStorage, redirect } from "@remix-run/node"; +import invariant from "tiny-invariant"; + +import type { User } from "./models"; + +invariant( + process.env.SESSION_SECRET, + "SESSION_SECRET must be set for authentication to work", +); + +const sessionStorage = createCookieSessionStorage<{ + user: User; +}>({ + cookie: { + name: "__session", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [process.env.SESSION_SECRET], + secure: process.env.NODE_ENV === "production", + }, +}); + +export async function createUserSession({ + request, + user, + redirectTo, +}: { + request: Request; + user: User; + redirectTo: string; +}) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.set("user", user); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 7, // 7日間 + }), + }, + }); +} + +export async function getUserFromSession(request: Request) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + return session.get("user") ?? null; +} + +export async function requireUser(request: Request) { + const user = await getUserFromSession(request); + + if (user === null) { + throw redirect("/login"); + } + + return user; +} +``` + +また、`models.ts`ファイルから`User`モデルをエクスポートしてください。 + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +export type User = components["schemas"]["User"]; +``` + +このコードが動作する前に、`SESSION_SECRET`環境変数を設定する必要があります。プロジェクトのルートに`.env`ファイルを作成し、`SESSION_SECRET=`を記述してから、適当にランダムな文字列を記入します。次のようになります。 + +```bash title=".env" +SESSION_SECRET=これをコピーしないでください +``` + +最後に、公開APIにいくつかのエクスポートを追加します。 + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +``` + +これで、RealWorldのバックエンドと通信するコードを書くことができます。これを`pages/sign-in/api`に保存します。`register.ts`ファイルを作成して、中に次のコードを配置しましょう。 + +```tsx title="pages/sign-in/api/register.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const register = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const username = formData.get("username")?.toString() ?? ""; + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users", { + body: { user: { email, password, username } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +``` + +ほぼ完成です!残りの部分は、`/register`ルートにアクションとページを接続することだけです。`app/routes`で`register.tsx`を作成します。 + +```tsx title="app/routes/register.tsx" +import { RegisterPage, register } from "pages/sign-in"; + +export { register as action }; + +export default RegisterPage; +``` + +これで、[http://localhost:3000/register](http://localhost:3000/register)にアクセスすると、ユーザーを作成できます!アプリケーションの残りの部分は、まだ反応しませんが、近々対処します。 + +同様に、ログインページを実装することもできます。自分で実装してみるか、下記のコードをコピペするか、次に進みましょう。 + +```tsx title="pages/sign-in/api/sign-in.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const signIn = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users/login", { + body: { user: { email, password } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/ui/SignInPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { signIn } from "../api/sign-in"; + +export function SignInPage() { + const signInData = useActionData(); + + return ( +
+
+
+
+

サインイン

+

+ アカウントが必要ですか? +

+ + {signInData?.error && ( +
    + {signInData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +export { SignInPage } from './ui/SignInPage'; +export { signIn } from './api/sign-in'; +``` + +```tsx title="app/routes/login.tsx" +import { SignInPage, signIn } from "pages/sign-in"; + +export { signIn as action }; + +export default SignInPage; +``` + +これで、ユーザーがこれらのページにアクセスできるようになりました。 + +### ヘッダー + +前章で説明されたように、アプリケーションのヘッダーは通常Widgets層、またはShared層に配置されます。ヘッダーは非常にシンプルで、すべてのビジネスロジックを外部に保持できるので、Shared層に配置しましょう。ヘッダー用のフォルダーを作成します。 + +```bash +npx fsd shared ui +``` + +次に、`shared/ui/Header.tsx`を作成し、次の内容を配置します。 + +```tsx title="shared/ui/Header.tsx" +import { useContext } from "react"; +import { Link, useLocation } from "@remix-run/react"; + +import { CurrentUser } from "../api/currentUser"; + +export function Header() { + const currentUser = useContext(CurrentUser); + const { pathname } = useLocation(); + + return ( + + ); +} +``` + +このコンポーネントを`shared/ui`からエクスポートします。 + +```tsx title="shared/ui/index.ts" +export { Header } from "./Header"; +``` + +ヘッダーで`shared/api`にあるコンテキストを使っているので、それを作成しましょう。 + +```tsx title="shared/api/currentUser.ts" +import { createContext } from "react"; + +import type { User } from "./models"; + +export const CurrentUser = createContext(null); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +export { CurrentUser } from "./currentUser"; +``` + +これで、ヘッダーをページに追加できます。すべてのページに表示されるように、ルートに追加し、アウトレット(ページがレンダリングされる場所)を`CurrentUser`のコンテキストプロバイダーで包みます。これにより、ヘッダーを含むアプリ全体が現在のユーザーオブジェクトにアクセスできるようになります。また、クッキーから現在のユーザーオブジェクトを取得するためのローダーも追加します。次のコードを`app/root.tsx`に追加しましょう。 + +```tsx title="app/root.tsx" +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from "@remix-run/react"; + +import { Header } from "shared/ui"; +import { getUserFromSession, CurrentUser } from "shared/api"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export const loader = ({ request }: LoaderFunctionArgs) => + getUserFromSession(request); + +export default function App() { + const user = useLoaderData(); + + return ( + + + + + + + + + + + + + +
+ + + + + + + + ); +} +``` + +最終的に、ホームページは次のようになります。 + +
+ ![ヘッダー、フィード、タグがあるConduitのフィードページ。タブはまだありません。](../../../../../../static/img/tutorial/realworld-feed-without-tabs.jpg) + +
ヘッダー、フィード、タグがあるConduitのフィードページ。タブはまだない。
+
+ +### タブ + +これで認証状態を判断できるようになったので、タブと「いいね」ボタンをフィードページに実装しましょう。新しいフォームを作る必要がありますが、このページファイルはすでに大きすぎるので、これらのフォームを隣接するファイルに移動しましょう。`Tabs.tsx`、`PopularTags.tsx`、`Pagination.tsx`を作成し、次の内容を配置します。 + +```tsx title="pages/feed/ui/Tabs.tsx" +import { useContext } from "react"; +import { Form, useSearchParams } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; + +export function Tabs() { + const [searchParams] = useSearchParams(); + const currentUser = useContext(CurrentUser); + + return ( +
+
+
    + {currentUser !== null && ( +
  • + +
  • + )} +
  • + +
  • + {searchParams.has("tag") && ( +
  • + + {searchParams.get("tag")} + +
  • + )} +
+
+
+ ); +} +``` + +```tsx title="pages/feed/ui/PopularTags.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; + +export function PopularTags() { + const { tags } = useLoaderData(); + + return ( +
+

人気のタグ

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+ ); +} +``` + +```tsx title="pages/feed/ui/Pagination.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; + +export function Pagination() { + const [searchParams] = useSearchParams(); + const { articles } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ + ); +} +``` + +これで、フィードページを大幅に簡素化できます。 + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; +import { Tabs } from "./Tabs"; +import { PopularTags } from "./PopularTags"; +import { Pagination } from "./Pagination"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

知識を共有する場

+
+
+ +
+
+
+ + + {articles.articles.map((article) => ( + + ))} + + +
+ +
+ +
+
+
+
+ ); +} +``` + +ローダー関数にも新しいタブを考慮する必要があります。 + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + /* そのまま */ +} + +/** 1ページあたりの記事数。 */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + if (url.searchParams.get("source") === "my-feed") { + const userSession = await requireUser(request); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles/feed", { + params: { + query: { + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + headers: { Authorization: `Token ${userSession.token}` }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); + } + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +フィードページを一旦置いておく前に、投稿へのいいねを処理するコードを追加しましょう。`ArticlePreview.tsx`を次のように変更します。 + + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Form, Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+
+ +
+
+ +

{article.title}

+

{article.description}

+ もっと読む... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +このコードは、`/article/:slug`にPOSTリクエストを送信し、`_action=favorite`を使用して記事をお気に入りにします。今は機能していませんが、記事リーダーの作成を始めると、これも実装します。 + +これで、フィードの作成が完了しました!やったね! + +### 記事リーダー + +まず、データが必要です。ローダーを作成しましょう。 + +```bash +npx fsd pages article-read -s api +``` + +```tsx title="pages/article-read/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import invariant from "tiny-invariant"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, getUserFromSession } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + invariant(params.slug, "スラッグパラメータが必要です"); + const currentUser = await getUserFromSession(request); + const authorization = currentUser + ? { Authorization: `Token ${currentUser.token}` } + : undefined; + + return json( + await promiseHash({ + article: throwAnyErrors( + GET("/articles/{slug}", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + comments: throwAnyErrors( + GET("/articles/{slug}/comments", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + }), + ); +}; +``` + +```tsx title="pages/article-read/index.ts" +export { loader } from "./api/loader"; +``` + +これで、`/article/:slug`ルートに接続できます。`article.$slug.tsx`というルートファイルを作成します。 + +```tsx title="app/routes/article.$slug.tsx" +export { loader } from "pages/article-read"; +``` + +ページ自体は、記事のタイトルとアクション、記事の本文、コメントセクションの3つの主要なブロックで構成されています。下記はページのマークアップで、特に興味深いものはありません。 + +```tsx title="pages/article-read/ui/ArticleReadPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticleMeta } from "./ArticleMeta"; +import { Comments } from "./Comments"; + +export function ArticleReadPage() { + const { article } = useLoaderData(); + + return ( +
+
+
+

{article.article.title}

+ + +
+
+ +
+
+
+

{article.article.body}

+
    + {article.article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} +``` + +興味深いのは`ArticleMeta`と`Comments`です。これらは、記事を「いいね」したり、コメントを残したりするための操作を含んでいます。これらが機能するためには、まずバックエンド部分を実装する必要があります。このページの`api`セグメントに`action.ts`ファイルを作成します。 + +```tsx title="pages/article-read/api/action.ts" +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { namedAction } from "remix-utils/named-action"; +import { redirectBack } from "remix-utils/redirect-back"; +import invariant from "tiny-invariant"; + +import { DELETE, POST, requireUser } from "shared/api"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const currentUser = await requireUser(request); + + const authorization = { Authorization: `Token ${currentUser.token}` }; + + const formData = await request.formData(); + + return namedAction(formData, { + async delete() { + invariant(params.slug, "スラッグパラメータが必要です"); + await DELETE("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirect("/"); + }, + async favorite() { + invariant(params.slug, "スラッグパラメータが必要です"); + await POST("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfavorite() { + invariant(params.slug, "スラッグパラメータが必要です"); + await DELETE("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async createComment() { + invariant(params.slug, "スラッグパラメータが必要です"); + const comment = formData.get("comment"); + invariant(typeof comment === "string", "コメントパラメータが必要です"); + await POST("/articles/{slug}/comments", { + params: { path: { slug: params.slug } }, + headers: { ...authorization, "Content-Type": "application/json" }, + body: { comment: { body: comment } }, + }); + return redirectBack(request, { fallback: "/" }); + }, + async deleteComment() { + invariant(params.slug, "スラッグパラメータが必要です"); + const commentId = formData.get("id"); + invariant(typeof commentId === "string", "idパラメータが必要です"); + const commentIdNumeric = parseInt(commentId, 10); + invariant( + !Number.isNaN(commentIdNumeric), + "数値のidパラメータが必要です", + ); + await DELETE("/articles/{slug}/comments/{id}", { + params: { path: { slug: params.slug, id: commentIdNumeric } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async followAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "ユーザーネームパラメータが必要です", + ); + await POST("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfollowAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "ユーザーネームパラメータが必要です", + ); + await DELETE("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + }); +}; +``` + +これをスライスから再エクスポートし、ルートから再エクスポートします。ここにいる間に、ページ自体も接続しましょう。 + +```tsx title="pages/article-read/index.ts" +export { ArticleReadPage } from "./ui/ArticleReadPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/article.$slug.tsx" +import { ArticleReadPage } from "pages/article-read"; + +export { loader, action } from "pages/article-read"; + +export default ArticleReadPage; +``` + +これで、記事リーダーの「いいね」ボタンはまだ実装されていないにも関わらず、フィードの「いいね」ボタンは機能し始めます!それは、フィードの「いいね」ボタンもそのルートにリクエストを送っているからです。何かを「いいね」してみてください! + +`ArticleMeta`と`Comments`は、単なるフォームです。以前にこれを行ったので、コードをコピペして先に進みましょう。 + +```tsx title="pages/article-read/ui/ArticleMeta.tsx" +import { Form, Link, useLoaderData } from "@remix-run/react"; +import { useContext } from "react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function ArticleMeta() { + const currentUser = useContext(CurrentUser); + const { article } = useLoaderData(); + + return ( +
+
+ + + + +
+ + {article.article.author.username} + + {article.article.createdAt} +
+ + {article.article.author.username == currentUser?.username ? ( + <> + + 記事を編集 + +    + + + ) : ( + <> + + +    + + + )} +
+
+ ); +} +``` + +```tsx title="pages/article-read/ui/Comments.tsx" +import { useContext } from "react"; +import { Form, Link, useLoaderData } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function Comments() { + const { comments } = useLoaderData(); + const currentUser = useContext(CurrentUser); + + return ( +
+ {currentUser !== null ? ( +
+
+ +
+
+ + +
+
+ ) : ( +
+
+

+ サインイン +   または   + 登録 +   して記事にコメントを追加しましょう! +

+
+
+ )} + + {comments.comments.map((comment) => ( +
+
+

{comment.body}

+
+ +
+ + + +   + + {comment.author.username} + + {comment.createdAt} + {comment.author.username === currentUser?.username && ( + +
+ + +
+
+ )} +
+
+ ))} +
+ ); +} +``` + +これで、記事リーダーが完成しました!「著者をフォローする」ボタン、「いいね」ボタン、「コメントを残す」ボタンがすべて正常に機能するはずです。 + +
+ ![記事リーダーの画像](../../../../../../static/img/tutorial/realworld-article-reader.jpg) + +
記事リーダーの画像
+
+ +### 記事編集 + +これは、このガイドで最後に取り上げるページです。ここで最も興味深い部分は、フォームデータを検証する方法です。 + +`article-edit/ui/ArticleEditPage.tsx`ページ自体は、非常にシンプルで、追加のロジックは他の2つのコンポーネントに含まれます。 + +```tsx title="pages/article-edit/ui/ArticleEditPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { TagsInput } from "./TagsInput"; +import { FormErrors } from "./FormErrors"; + +export function ArticleEditPage() { + const article = useLoaderData(); + + return ( +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+
+
+
+
+ ); +} +``` + +このページは、存在する記事を取得し(新しい記事を作成する場合を除く)、対応するフォームフィールドを埋めます。これは以前に見たものです。着目すべき部分は`FormErrors`で、これは検証結果を取得し、ユーザーに表示します。 + +```tsx title="pages/article-edit/ui/FormErrors.tsx" +import { useActionData } from "@remix-run/react"; +import type { action } from "../api/action"; + +export function FormErrors() { + const actionData = useActionData(); + + return actionData?.errors != null ? ( +
    + {actionData.errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null; +} +``` + +アクションが`errors`フィールドを返し、人間に理解できるエラーメッセージの配列を表示することを想定しています。アクションには後で移ります。 + +もう1つのコンポーネントはタグ入力フィールドです。これは、選択されたタグのプレビューできる通常の入力フィールドです。特に注目すべき点はありません。 + +```tsx title="pages/article-edit/ui/TagsInput.tsx" +import { useEffect, useRef, useState } from "react"; + +export function TagsInput({ + name, + defaultValue, +}: { + name: string; + defaultValue?: Array; +}) { + const [tagListState, setTagListState] = useState(defaultValue ?? []); + + function removeTag(tag: string): void { + const newTagList = tagListState.filter((t) => t !== tag); + setTagListState(newTagList); + } + + const tagsInput = useRef(null); + useEffect(() => { + tagsInput.current && (tagsInput.current.value = tagListState.join(",")); + }, [tagListState]); + + return ( + <> + + setTagListState(e.target.value.split(",").filter(Boolean)) + } + /> +
+ {tagListState.map((tag) => ( + + + [" ", "Enter"].includes(e.key) && removeTag(tag) + } + onClick={() => removeTag(tag)} + >{" "} + {tag} + + ))} +
+ + ); +} +``` + +次に、API部分に移ります。ローダーはURLを確認し、記事へのリンクがある場合、既存の記事を編集していることを意味し、そのデータをロードする必要があります。そうでない場合は、何も返しません。このローダーを作成しましょう。 + +```tsx title="pages/article-edit/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const currentUser = await requireUser(request); + + if (!params.slug) { + return { article: null }; + } + + return throwAnyErrors( + GET("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: { Authorization: `Token ${currentUser.token}` }, + }), + ); +}; +``` + +アクションは新しいフィールドの値を受け取り、それらをデータスキーマに通し、すべてが正しければ、既存の記事を更新するか、新しい記事を作成することによって、バックエンドに変更を保存します。 + +```tsx title="pages/article-edit/api/action.ts" +import { json, redirect, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, PUT, requireUser } from "shared/api"; +import { parseAsArticle } from "../model/parseAsArticle"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + try { + const { body, description, title, tags } = parseAsArticle( + await request.formData(), + ); + const tagList = tags?.split(",") ?? []; + + const currentUser = await requireUser(request); + const payload = { + body: { + article: { + title, + description, + body, + tagList, + }, + }, + headers: { Authorization: `Token ${currentUser.token}` }, + }; + + const { data, error } = await (params.slug + ? PUT("/articles/{slug}", { + params: { path: { slug: params.slug } }, + ...payload, + }) + : POST("/articles", payload)); + + if (error) { + return json({ errors: error }, { status: 422 }); + } + + return redirect(`/article/${data.article.slug ?? ""}`); + } catch (errors) { + return json({ errors }, { status: 400 }); + } +}; +``` + +私たちのデータスキーマは、`FormData`を解析するので、最終的に処理するエラーメッセージを投げたりするのに役立ちます。この解析関数は次のようになります。 + +```tsx title="pages/article-edit/model/parseAsArticle.ts" +export function parseAsArticle(data: FormData) { + const errors = []; + + const title = data.get("title"); + if (typeof title !== "string" || title === "") { + errors.push("記事にタイトルを付けてください"); + } + + const description = data.get("description"); + if (typeof description !== "string" || description === "") { + errors.push("この記事が何についてか説明してください"); + } + + const body = data.get("body"); + if (typeof body !== "string" || body === "") { + errors.push("記事そのものを書いてください"); + } + + const tags = data.get("tags"); + if (typeof tags !== "string") { + errors.push("タグは文字列である必要があります"); + } + + if (errors.length > 0) { + throw errors; + } + + return { title, description, body, tags: data.get("tags") ?? "" } as { + title: string; + description: string; + body: string; + tags: string; + }; +} +``` + +少し長く繰り返しが多いように見えるかもしれませんが、これはエラーメッセージを人間に理解しやすくするための代償です。Zodのようなスキーマを使用することもできますが、その場合、フロントエンドでエラーメッセージを表示する必要があります。このフォームはそのような複雑さには値しません。 + +最後のステップは、ページ、ローダー、アクションをルートに接続することです。私たちは作成と編集の両方をきれいにサポートしているので、`editor._index.tsx`と`editor.$slug.tsx`の両方から同じアクションをエクスポートできます。 + +```tsx title="pages/article-edit/index.ts" +export { ArticleEditPage } from "./ui/ArticleEditPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/editor._index.tsx, app/routes/editor.$slug.tsx (同じ内容)" +import { ArticleEditPage } from "pages/article-edit"; + +export { loader, action } from "pages/article-edit"; + +export default ArticleEditPage; +``` + +これで完成です!ログインして新しい記事を作成してみてください。あるいは、フィールドに何も記入せず進み、バリデーションがどのように機能するかを検証してみてください。 + +
+ ![記事編集者の画像](../../../../../../static/img/tutorial/realworld-article-editor.jpg) + +
記事編集画像
+
+ +プロフィールページや設定ページは、記事の読み取りや編集ページに非常に似ていて、読者のための宿題として残されています。 diff --git a/src/content/docs/ja/docs/guides/examples/auth.mdx b/src/content/docs/ja/docs/guides/examples/auth.mdx new file mode 100644 index 0000000000..0d62993a0d --- /dev/null +++ b/src/content/docs/ja/docs/guides/examples/auth.mdx @@ -0,0 +1,241 @@ +--- +title: 認証 +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +一般的に、認証は以下のステップで構成されます。 + +1. ユーザーから資格情報を取得する +2. それをバックエンドに送信する +3. 認証されたリクエストを送信するためのトークンを保存する + +## ユーザーの資格情報を取得する方法 + +OAuthを通じて認証を行う場合は、OAuthプロバイダーのページへのリンクを持つログインページを作成し、[ステップ3](#how-to-store-the-token-for-authenticated-requests)に進むことができます。 + +### ログイン用の別ページ + +通常、ウェブサイトにはユーザー名とパスワードを入力するためのログイン専用ページがあります。これらのページは非常にシンプルであるため、分解する必要はありません。さらに、ログインフォームと登録フォームは外見が非常に似ているため、同じページにグループ化することもできます。ログイン/登録ページ用のスライスをPages層に作成します。 + + +- pages/ + - login/ + - ui/ + - LoginPage.tsx + - RegisterPage.tsx + - index.ts + - ... + + +ここでは、2つのコンポーネントを作成し、インデックスで両方をエクスポートしました。これらのコンポーネントは、ユーザーが資格情報を入力するためのわかりやすい要素を含むフォームを持ちます。 + +### ログイン用のダイアログボックス + +アプリケーションにどのページでも使用できるログイン用のダイアログボックスがある場合は、そのダイアログボックス用のウィジェットを作成できます。これにより、フォーム自体をあまり分解せずに、どのページでもこのダイアログボックスを再利用できます。 + + +- widgets/ + - login-dialog/ + - ui/ + - LoginDialog.tsx + - index.ts + - ... + + +このガイドの残りの部分は、ログインが別ページで行われる最初のアプローチに基づいていますが、同じ原則がダイアログボックス用のウィジェットにも適用されます。 + +### クライアントバリデーション + +たまには、特に登録時に、クライアント側で検証を行い、ユーザーにエラーを迅速に通知することがあります。この場合、検証は、ログインページの`model`セグメントで行うことができます。スキーマ検証ライブラリ、例えば[Zod][ext-zod]をJS/TS用に使用し、このスキーマを`ui`セグメントに提供します。 + + +```ts title="pages/login/model/registration-schema.ts" +import { z } from "zod"; + +export const registrationData = z.object({ + email: z.string().email(), + password: z.string().min(6), + confirmPassword: z.string(), +}).refine((data) => data.password === data.confirmPassword, { + message: "パスワードが一致しません", + path: ["confirmPassword"], +}); +``` + +次に、`ui`セグメントでこのスキーマを使用してユーザー入力を検証できます。 + +```tsx title="pages/login/ui/RegisterPage.tsx" +import { registrationData } from "../model/registration-schema"; + +function validate(formData: FormData) { + const data = Object.fromEntries(formData.entries()); + try { + registrationData.parse(data); + } catch (error) { + // TODO: ユーザーにエラーメッセージを表示 + } +} + +export function RegisterPage() { + return ( +
validate(new FormData(e.target))}> + + + + + + + + +
+ ) +} +``` + +## 資格情報をバックエンドに送信する方法 + +バックエンドのログインエンドポイントにリクエストを送信する関数を作成しましょう。この関数は、コンポーネントのコード内でミューテーションライブラリ(例えば、TanStack Query)を通じて直接呼び出すことも、状態管理ライブラリの副作用として呼び出すこともできます。 + +### リクエスト関数をどこに置くか + +この関数を置く場所は2つあります: `shared/api`またはページの`api`セグメントです。 + +#### `shared/api`に + +このアプローチは、すべてのリクエスト関数を`shared/api`に配置し、エンドポイントごとにグループ化するのに適しています。この場合、ファイル構造は次のようになります。 + + +- shared/ + - api/ + - endpoints/ + - login.ts + - register.ts + - logout.ts + - client.ts + - index.ts + + +`📄 client.ts`ファイルには、リクエストを実行するためのプリミティブのラッパーが含まれています(例えば、`fetch()`)。このラッパーは、バックエンドのベースURLを知っており、必要なヘッダーを設定し、データをシリアライズします。 + +```ts title="shared/api/endpoints/login.ts" +import { POST } from "../client"; + +export function login({ email, password }: { email: string, password: string }) { + return POST("/login", { email, password }); +} +``` + +```ts title="shared/api/index.ts" +export { login } from "./endpoints/login"; +``` + +#### ページの`api`セグメントに + +すべてのリクエストを1か所に保存していない場合は、ログインページの`api`セグメントにこのリクエスト関数を配置するのが適しているかもしれません。 + + +- pages/ + - login/ + - api/ + - login.ts + - register.ts + - logout.ts + - ui/ + - LoginPage.tsx + - index.ts + - ... + + +```ts title="pages/login/api/login.ts" +import { POST } from "shared/api"; + +export function login({ email, password }: { email: string, password: string }) { + return POST("/login", { email, password }); +} +``` + + +この関数は、ページのインデックスから再エクスポートする必要はありません。なぜなら、恐らくこのページ内でのみ使用されるからです。 + +### 2要素認証 + +アプリケーションが2要素認証(2FA)をサポートしている場合、ユーザーを一時的なパスワードを入力するための別のページにリダイレクトする必要があるかもしれません。通常、`POST /login`リクエストは、ユーザーに2FAが有効であることを示すフラグを持つユーザーオブジェクトを返します。このフラグが設定されている場合、ユーザーを2FAページにリダイレクトします。 + +このページはログインと非常に関連しているため、Pages層の同じ`login`スライスに配置することもできます。 + +また、上で作成した`login()`に似た別のリクエスト関数が必要になります。それらをShared層にまとめるか、ログインページの`api`セグメントに配置してください。 + +## 認証されたリクエスト用のトークンを保存する方法 \{#how-to-store-the-token-for-authenticated-requests} + +使用する認証スキームに関係なく、単純なログインとパスワード、OAuth、または2要素認証であっても、最終的にはトークンを取得します。以降のリクエストで自分を識別できるように、このトークンは保存する必要があります。 + +ウェブアプリケーションにおけるトークンの理想的な保存場所は**クッキー**です。クッキーはトークンの手動保存や処理を必要としません。したがって、クッキーの保存はフロントエンドアーキテクチャにほとんど労力を必要としません。フロントエンドフレームワークにサーバーサイドがある場合(例えば、[Remix][ext-remix])、クッキーのサーバーインフラは`shared/api`に保存する必要があります。[「認証」チュートリアルセクション][tutorial-authentication]には、Remixでの実装例があります。 + +ただし、時にはトークンをクッキーに保存することができない場合もあります。この場合、トークンを自分で保存しなければなりません。その際、トークンの有効期限が切れたときに更新するロジックを書く手間がかかるかもしれません。FSDの枠組み内には、トークンを保存できるいくつかの場所と、そのトークンをアプリケーションの他の部分で利用できるようにするいくつかの方法があります。 + +### Shared層に保存する + +このアプローチは、APIクライアントが`shared/api`に定義されている場合にうまく機能します。なぜなら、APIクライアントがトークンに自由にアクセスできるからです。クライアントが状態を持つようにするには、リアクティブストアを使用するか、単にモジュールレベルの変数を使用することができます。その後、`login()`/`logout()`関数内でこの状態を更新できます。 + +トークンの自動更新は、APIクライアント内のミドルウェアとして実装できます。これは、リクエストを行うたびに実行されます。例えば、次のようにすることができます。 + +- 認証し、アクセストークンとリフレッシュトークンを保存する +- 認証を必要とするリクエストを行う +- リクエストがアクセストークンの有効期限切れを示すステータスコードで失敗した場合、ストレージにリフレッシュトークンがあれば、更新リクエストを行い、新しいアクセストークンとリフレッシュトークンを保存し、元のリクエストを再試行する + +このアプローチの欠点の1つは、トークンの保存と更新ロジックが専用の場所を持たないことです。これは、特定のアプリケーションやチームには適しているかもしれませんが、トークン管理のロジックがより複雑な場合、リクエスト送信とトークン管理の責任を分けたいと思うかもしれません。この場合、リクエストとAPIクライアントを`shared/api`に置き、トークンストレージと更新ロジックを`shared/auth`に配置します。 + +このアプローチのもう1つの欠点は、サーバーがトークンとともに現在のユーザーに関する情報を返す場合、その情報を保存する場所がなく、特別なエンドポイント(例えば`/me`や`/users/current`)から再度取得する必要があることです。 + +### Entities層に保存する + +FSDプロジェクトには、ユーザーエンティティや現在のユーザーエンティティが存在することがよくあります。これらは同じエンティティである場合もあります。 + + + +ユーザーエンティティにトークンを保存するには、`model`セグメントにリアクティブストアを作成します。このストアには、トークンとユーザー情報のオブジェクトの両方を含めることができます。 + +APIクライアントは通常、`shared/api`に配置されるか、エンティティ間で分散されるため、このアプローチの主な問題は、他のリクエストがトークンにアクセスできるようにしつつ、[レイヤーのインポートルール][import-rule-on-layers]を破らないことです。 + +> スライス内のモジュール(ファイル)は、下層にあるスライスのみをインポートできる。 + +この問題にはいくつかの解決策があります。 + +1. **リクエストを行うたびにトークンを手動で渡す** + これは最も簡単な解決策ですが、すぐに不便になり、厳密な型付けがない場合は忘れやすくなります。この解決策は、Shared層のAPIクライアントのミドルウェアパターンとも互換性がありません。 + +2. **コンテキストや`localStorage`のようなグローバルストレージを介してアプリ全体にトークンへのアクセスを提供する** + トークンを取得するためのキーは`shared/api`に保存され、APIクライアントがそれを使用できるようにします。トークンのリアクティブストアはユーザーエンティティからエクスポートされ、必要に応じてコンテキストプロバイダーがApp層で設定されます。これにより、APIクライアントの設計に対する自由度が増しますが、このアプローチは暗黙の依存関係を生み出してしまいます。 + +3. **トークンが変更されるたびにAPIクライアントにトークンを挿入する** + リアクティブなストアであれば、変更を監視し、ユーザーエンティティのストアが変更されるたびにAPIクライアントのトークンを更新できます。この解決策は、前の解決策と同様に暗黙の依存関係を生み出してしまいますが、より命令的(「プッシュ」)であり、前のものはより宣言的(「プル」)です。 + +ユーザーエンティティのモデルに保存されたトークンの可用性の問題を解決したら、トークン管理に関連する追加のビジネスロジックを記述できます。例えば、`model`セグメントには、トークンを一定期間後に無効にするロジックや、期限切れのトークンを更新するロジックを含めることができます。これらのタスクを実行するために、ユーザーエンティティの`api`セグメント、または`shared/api`を使用します。 + +### Pages層/Widgets層に保存する(非推奨) + +アクセストークンのようなアプリ全体に関連する状態をページやウィジェットに保存することは推奨されません。ログインページの`model`セグメントにトークンストレージを配置しないでください。代わりに、最初の2つの解決策(Shared層配置かEntities層配置)のいずれかを選択してください。 + +## ログアウトとトークンの無効化 + +通常、アプリケーションではログアウト専用のページを作成しませんが、ログアウト機能は非常に重要です。この機能には、バックエンドへの認証リクエストとトークンストレージの更新が含まれます。 + +すべてのリクエストを`shared/api`に保存している場合は、ログイン関数の近くにログアウトリクエストの関数を配置してください。そうでない場合は、ログアウトを呼び出すボタンの近くに配置してください。例えば、すべてのページに存在し、ログアウトリンクを含むヘッダーウィジェットがある場合、そのリクエストをこのウィジェットの`api`セグメントに配置します。 + +トークンストレージの更新も、ログアウトボタンの場所からトリガーされる必要があります。リクエストとストレージの更新をこのウィジェットの`model`セグメントで統合できます。 + +### 自動ログアウト + +ログアウトリクエストやトークン更新リクエストの失敗を考慮することを忘れないでください。いずれの場合も、トークンストレージをリセットする必要があります。トークンをEntities層に保存している場合、このコードは`model`セグメントに配置できます。トークンをShared層に保存している場合、このロジックを`shared/api`に配置すると、セグメントが膨らみ、その目的が曖昧になってしまいます。`api`セグメントに無関係な2つのものが含まれていることに気づいた場合、トークン管理ロジックを別のセグメント、例えば`shared/auth`に分離することを検討してみてください。 + +[tutorial-authentication]: /docs/get-started/tutorial#authentication +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-remix]: https://remix.run +[ext-zod]: https://zod.dev diff --git a/src/content/docs/ja/docs/guides/examples/page-layout.mdx b/src/content/docs/ja/docs/guides/examples/page-layout.mdx new file mode 100644 index 0000000000..49ef32b219 --- /dev/null +++ b/src/content/docs/ja/docs/guides/examples/page-layout.mdx @@ -0,0 +1,106 @@ +--- +title: ページレイアウト +sidebar: + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + +このガイドでは、複数のページが同じ構造を持ち、主な内容だけが異なる場合のページレイアウトの抽象化について説明します。 + + + +## シンプルなレイアウト + +最もシンプルなレイアウトは、このページで直接見ることができます。これは、サイトのナビゲーションを含むヘッダー、2つのサイドバー、外部リンクを含むフッターを持っています。ここには複雑なビジネスロジックはなく、唯一の動的部分はサイドバーとヘッダーの右側にあるトグルスイッチです。このレイアウトは、`shared/ui`または`app/layouts`に全体を配置でき、サイドバーのコンテンツはプロパティを通じて埋め込むことができます。 + +```tsx title="shared/ui/layout/Layout.tsx" +import { Link, Outlet } from "react-router-dom"; +import { useThemeSwitcher } from "./useThemeSwitcher"; + +export function Layout({ siblingPages, headings }) { + const [theme, toggleTheme] = useThemeSwitcher(); + + return ( +
+
+ + +
+
+ + {/* ここにページの主な内容が表示されます */} + +
+
+
    +
  • GitHub
  • +
  • X
  • +
+
+
+ ); +} +``` + +```ts title="shared/ui/layout/useThemeSwitcher.ts" +export function useThemeSwitcher() { + const [theme, setTheme] = useState("light"); + + function toggleTheme() { + setTheme(theme === "light" ? "dark" : "light"); + } + + useEffect(() => { + document.body.classList.remove("light", "dark"); + document.body.classList.add(theme); + }, [theme]); + + return [theme, toggleTheme] as const; +} +``` + + +サイドバーのコードは読者に課題として残されています! + +## レイアウトでのウィジェットの使用 + +時には、特定のビジネスロジックをレイアウトに組み込む必要があります。特に、[React Router][ext-react-router]のような深くネストされたルートを使用している場合、Shared層やWidgets層にレイアウトを保存することはできません。これは[レイヤーのインポートルール][import-rule-on-layers]に違反しています。 + +> スライス内のモジュールは、下層にあるスライスのみをインポートできる。 + +解決策を議論する前に、これが実際に問題かどうかを確認する必要があります。このレイアウトは本当に必要なのか?もしそうなら、ウィジェットとして実装することが最適なのかも再考する必要があるでしょう。もしビジネスロジックのブロックが2〜3ページで使用され、レイアウトがそのウィジェットの小さなラッパーに過ぎない場合、次の2つのオプションを検討してください。 + +1. **レイアウトをApp層のルーターで直接作成する** + これは、ネストをサポートするルーターに最適です。特定のルートをグループ化し、必要なレイアウトをそれらにのみ適用できます。 + +2. **単にコピーする** + コードを抽象化する欲求はしばしば過大評価されます。特にレイアウトに関しては、変更がほとんどないためです。ある時点で、これらのページの1つが変更を必要とする場合、他のページに影響を与えずに変更を加えることができます。他のページを更新することを忘れるかもしれないと心配している場合は、ページ間の関係を説明するコメントを残すことができます。 + +上記のいずれのオプションも適用できない場合、ウィジェットをレイアウトに組み込むための2つの解決策があります。 + +1. **レンダープロップまたはスロットを使用する** + ほとんどのフレームワークは、UIの一部を外部から渡すことを許可しています。Reactではこれを[レンダープロップ][ext-render-props]と呼び、Vueでは[スロット][ext-vue-slots]と呼びます。 + +2. **レイアウトをApp層に移動する** + レイアウトをApp層に保存し、必要なウィジェットを組み合わせることもできます。 + +## 追加資料 + +- ReactとRemixを使用した認証付きレイアウトの作成例は[チュートリアル][tutorial]で見つけることができます(React Routerに類似する)。 + +[tutorial]: /docs/get-started/tutorial +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-react-router]: https://reactrouter.com/ +[ext-render-props]: https://www.patterns.dev/react/render-props-pattern/ +[ext-vue-slots]: https://jp.vuejs.org/guide/components/slots diff --git a/src/content/docs/ja/docs/guides/examples/types.mdx b/src/content/docs/ja/docs/guides/examples/types.mdx new file mode 100644 index 0000000000..cc059e99c4 --- /dev/null +++ b/src/content/docs/ja/docs/guides/examples/types.mdx @@ -0,0 +1,448 @@ +--- +title: 型 +sidebar: + order: 2 +--- + +import { Aside, FileTree } from '@astrojs/starlight/components'; + + + +このガイドでは、TypeScriptのような型付き言語のデータの型と、それがFSDにどのように適合するかについて説明します。 + + + +## ユーティリティ型 + +ユーティリティ型は、特に意味を持たず、通常は他の型と一緒に使用される型です。例えば + +
+ + +```ts +type ArrayValues = T[number]; +``` + + +
+ 出典: https://github.com/sindresorhus/type-fest/blob/main/source/array-values.d.ts +
+ +
+ +ユーティリティ型をプロジェクトに追加するには、[`type-fest`][ext-type-fest]のようなライブラリをインストールするか、`shared/lib`に独自のライブラリを作成します。必ず、新しい型をこのライブラリに追加できるか、できないかを明確に示してください。例えば、`shared/lib/utility-types`と名付け、その中にユーティリティ型があなたのチームの理解において何であるかを説明するREADMEファイルを追加してください。 + +ユーティリティ型の再利用の可能性を過大評価しないでください。再利用可能であるからといって、必ずしも再利用されるわけではなく、したがってすべてのユーティリティ型がShared層に存在する必要はありません。一部のユーティリティ型は、使用される場所の近くに置くべきです。 + + +- pages/ + - home/ + - api/ + - ArrayValues.ts ユーティリティ型 + - getMemoryUsageMetrics.ts このユーティリティを使用するコード + + + + +## ビジネスエンティティと相互参照 + +アプリケーションで最も重要な型の一つは、ビジネスエンティティの型、つまりアプリケーションが扱う実際のオブジェクトです。例えば、オンライン音楽サービスのアプリケーションでは、ビジネスエンティティとして「曲」(song)や「アルバム」(album)などがあります。 + +ビジネスエンティティは、しばしばバックエンドから提供されるため、最初のステップはバックエンドのレスポンスを型付けすることです。各エンドポイントに対してリクエスト関数を持ち、その関数の呼び出し結果を型付けするのが便利です。型の安全性を高めるために、Zodのようなスキーマ検証ライブラリを通じて結果を通過させることができます。 + +例えば、すべてのリクエストをShared層に保存している場合、次のようにできます。 + +```ts title="shared/api/songs.ts" +import type { Artist } from "./artists"; + +interface Song { + id: number; + title: string; + artists: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + + +`Song`型が他の`Artist`エンティティを参照していることに気付くかもしれません。これはリクエストをShared層に保存する利点です。実際の型が相互に参照されることが多いです。この関数を`entities/song/api`に置いた場合、`entities/artist`から`Artist`を単純にインポートすることはできません。なぜなら、FSDはスライス間のクロスインポートを[レイヤーのインポートルール][import-rule-on-layers]によって制限しているからです。 + +> スライス内のモジュールは、下層にあるスライスのみをインポートできる。 + +この問題を解決する方法は2つあります。 + +1. **型をパラメーター化する** + 型が他のエンティティと接続するためのスロットとして型引数を受け取るようにすることができます。さらに、これらのスロットに制約を課すこともできます。例えば + + ```ts title="entities/song/model/song.ts" + interface Song { + id: number; + title: string; + artists: Array; + } + ``` + + これはいくつかの型に対してはうまく機能しますが、機能しないケースもあります。`Cart = { items: Array }`のような単純な型は、任意のプロダクト型で簡単に機能させることができます。しかし、`Country`と`City`のようなより関連性の高い型は、分離するのが難しいかもしれません。 + +2. **クロスインポートする(正しく)** + FSD内でエンティティ間のクロスインポートを行うには、各スライス専用の特別の公開APIを使用することができます。例えば、`song`(曲)、`artist`(アーティスト)、`playlist`(プレイリスト)のエンティティがあり、後者の2つが`song`を参照する必要がある場合、`@x`ノーテーションを通じて`song`エンティティ内に2つの特別な公開APIを作成できます。 + + + - entities/ + - song/ + - @x/ + - artist.ts 公開API、`artist`エンティティをインポートする + - playlist.ts 公開API、`playlist`エンティティをインポートする + - index.ts 通常の公開API + + + `📄 entities/song/@x/artist.ts`ファイルの内容は、`📄 entities/song/index.ts`と似ています。 + + ```ts title="entities/song/@x/artist.ts" + export type { Song } from "../model/song.ts"; + ``` + + その後、`📄 entities/artist/model/artist.ts`は次のように`Song`をインポートできます。 + + ```ts title="entities/artist/model/artist.ts" + import type { Song } from "entities/song/@x/artist"; + + export interface Artist { + name: string; + songs: Array; + } + ``` + + エンティティ間の明示的な関係を持つことで、依存関係を正確に制御し、ドメインの分離を十分に保つことができます。 + +## データ転送オブジェクト(DTO)とマッパー \{#data-transfer-objects-and-mappers} + +データ転送オブジェクト、またはDTO(Data Transfer Object)は、バックエンドから送信されるデータの形式を説明する用語です。時にはDTOをそのまま使用できますが、時にはその形式がフロントエンドにとって不便な場合があります。ここでマッパーが役立ちます。マッパーは、DTOをより使いやすい形式に変換する関数です。 + +### DTOをどこに置くか + +バックエンドの型が別のパッケージにある場合(例えば、フロントエンドとバックエンド間でコードを共有している場合)、そこからDTOをインポートするだけで済みます。バックエンドとフロントエンド間でコードを共有していない場合、DTOをフロントエンドコードのどこかに保存する必要があります。この場合については以下で説明します。 + +リクエスト関数を`shared/api`に保存している場合、その関数で使用するDTOも、ちょうどその関数の近くに配置するべきです。 + + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + artist_ids: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +前のセクションで述べたように、リクエストとDTOをShared層に保存する利点は、他のDTOを参照できることです。 + +### マッパーをどこに置くか + +マッパーは、DTOを変換するための関数であり、したがってDTOの定義の近くに置くべきです。実際には、リクエストとDTOが`shared/api`に定義されている場合、マッパーもそこに置くべきです。 + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} + +interface Song { + id: string; + title: string; + /** 曲の完全なタイトル、ディスク番号を含む。 */ + fullTitle: string; + artistIds: Array; +} + +function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +リクエストとストレージがエンティティスライスに定義されている場合、このすべてのコードはそこに置くべきであり、エンティティ間のクロスインポートの制限を考慮する必要があります。 + +```ts title="entities/song/api/dto.ts" +import type { ArtistDTO } from "entities/artist/@x/song"; + +export interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} +``` + +```ts title="entities/song/api/mapper.ts" +import type { SongDTO } from "./dto"; + +export interface Song { + id: string; + title: string; + /** 曲の完全なタイトル、ディスク番号を含む。 */ + fullTitle: string; + artistIds: Array; +} + +export function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} +``` + +```ts title="entities/song/api/listSongs.ts" +import { adaptSongDTO } from "./mapper"; + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +```ts title="entities/song/model/songs.ts" +import { createSlice, createEntityAdapter } from "@reduxjs/toolkit"; + +import { listSongs } from "../api/listSongs"; + +export const fetchSongs = createAsyncThunk('songs/fetchSongs', listSongs); + +const songAdapter = createEntityAdapter(); +const songsSlice = createSlice({ + name: "songs", + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSongs.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload); + }) + }, +}); +``` + +### ネストされたDTOをどう扱うか + +最も問題となるのは、バックエンドからのレスポンスが複数のエンティティを含む場合です。例えば、曲がアーティストのIDだけでなく、アーティストのデータオブジェクト全体を含む場合です。この場合、エンティティは互いに知らないわけにはいきません(データを捨てたり、バックエンドチームと真剣に話し合いたくない場合を除いて)。スライス間の暗黙的な関係の解決策を考えるのではなく、`@x`ノーテーションを通じて明示的なクロスインポートを選ぶべきです。Redux Toolkitを使用してこれを実装する方法は次のとおりです。 + +```ts title="entities/song/model/songs.ts" +import { + createSlice, + createEntityAdapter, + createAsyncThunk, + createSelector, +} from '@reduxjs/toolkit' +import { normalize, schema } from 'normalizr' + +import { getSong } from "../api/getSong"; + +// normalizrでエンティティのスキーマを宣言 +export const artistEntity = new schema.Entity('artists') +export const songEntity = new schema.Entity('songs', { + artists: [artistEntity], +}) + +const songAdapter = createEntityAdapter() + +export const fetchSong = createAsyncThunk( + 'songs/fetchSong', + async (id: string) => { + const data = await getSong(id) + // データを正規化して、リデューサーが予測可能なオブジェクトをロードできるようにします。例えば + // `action.payload = { songs: {}, artists: {} }` + const normalized = normalize(data, songEntity) + return normalized.entities + } +) + +export const slice = createSlice({ + name: 'songs', + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload.songs) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +```ts title="entities/song/@x/artist.ts" +export { fetchSong } from "../model/songs"; +``` + +```ts title="entities/artist/model/artists.ts" +import { createSlice, createEntityAdapter } from '@reduxjs/toolkit' + +import { fetchSong } from 'entities/song/@x/artist' + +const artistAdapter = createEntityAdapter() + +export const slice = createSlice({ + name: 'users', + initialState: artistAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + // ここでバックエンドからの同じレスポンスを処理し、ユーザーを追加します + artistAdapter.upsertMany(state, action.payload.artists) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +これはスライスの分離の利点を少し制限しますが、私たちが制御できないこれらの2つのエンティティ間の関係を明確に示します。これらのエンティティがリファクタリングされる場合、同時にリファクタリングする必要があります。 + +## グローバルの型とRedux + +グローバルの型とは、アプリケーション全体で使用される型のことです。グローバルの型には、必要な情報に応じて2種類があります。 +1. アプリケーションに特有の情報を持たないユニバーサル型 +2. アプリケーション全体について知る必要がある型 + +最初のケースは簡単に解決できます。型をShared層の適切なセグメントに置くだけです。例えば、分析用のグローバル変数のインターフェースがある場合、それを`shared/analytics`に置くことができます。 + + + +2番目のケースは、RTKなしでReduxを使用しているプロジェクトでよく見られます。最終的なストアの型は、すべてのリデューサーを結合した後にのみ利用可能ですが、このストアの型はアプリケーションで使用されるセレクターに必要です。例えば、以下はReduxでのストアの典型的な定義です。 + +```ts title="app/store/index.ts" +import { combineReducers, rootReducer } from "redux"; + +import { songReducer } from "entities/song"; +import { artistReducer } from "entities/artist"; + +const rootReducer = combineReducers(songReducer, artistReducer); + +const store = createStore(rootReducer); + +type RootState = ReturnType; +type AppDispatch = typeof store.dispatch; +``` + +`shared/store`に型付けされた`useAppDispatch`と`useAppSelector`のフックを持つことは良いアイデアですが、[レイヤーのインポートルール][import-rule-on-layers]のために、App層から`RootState`と`AppDispatch`をインポートすることはできません。 + +> スライス内のモジュールは、下層にあるスライスのみをインポートできる。 + +この場合の推奨解決策は、Shared層とApp層の間に暗黙の依存関係を作成することです。これらの2つの型、`RootState`と`AppDispatch`は、変更される可能性が低く、Reduxの開発者には馴染みのあるものであるため、暗黙の関係は問題にならないでしょう。 + +TypeScriptでは、これらの型をグローバルとして宣言することで実現できます。例えば + +```ts title="app/store/index.ts" +/* 上記のコードブロックと同じ内容… */ + +declare type RootState = ReturnType; +declare type AppDispatch = typeof store.dispatch; +``` + +```ts title="shared/store/index.ts" +import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux"; + +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector: TypedUseSelectorHook = useSelector; +``` + +## 型のバリデーションスキーマとZod + +データが特定の形式や制約に従っていることを確認したい場合、バリデーションスキーマを作成できます。TypeScriptでこの目的に人気のあるライブラリは[Zod][ext-zod]です。バリデーションスキーマは、可能な限りそれを使用するコードの近くに配置する必要があります。 + +バリデーションスキーマは、データ転送オブジェクト(DTO)と似ており([DTOとマッパー](#data-transfer-objects-and-mappers)のセクションで説明)、DTOを受け取り、それを解析し、解析に失敗した場合はエラーを返します。 + +バリデーションの最も一般的なケースの一つは、バックエンドからのデータです。通常、データがスキーマに従わない場合、リクエストを失敗としてマークしたいので、リクエスト関数と同じ場所にスキーマを置くのが良いでしょう。通常、これは`api`セグメントになります。 + +ユーザー入力を介してデータが送信される場合、例えばフォームを通じて、バリデーションはデータ入力時に行わなければなりません。この場合、スキーマを`ui`セグメントに配置し、フォームコンポーネントの近くに置くか、`ui`セグメントが過負荷である場合は`model`セグメントに配置できます。 + +## コンポーネントのプロップスとコンテキストの型付け + +一般的に、プロップスやコンテキストのインターフェースは、それを使用するコンポーネントやコンテキストと同じファイルに保存するのが最良です。VueやSvelteのように、単一ファイルコンポーネントを持つフレームワークの場合、インターフェースを同じファイルに定義できない場合や、複数のコンポーネント間でこのインターフェースを再利用したい場合は、通常は`ui`セグメント内の同じフォルダーに別のファイルを作成します。 + +以下はJSX(ReactまたはSolid)の例です。 + +```ts title="pages/home/ui/RecentActions.tsx" +interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} + +export function RecentActions({ actions }: RecentActionsProps) { + /* … */ +} +``` + +以下は、Vueのために別のファイルにインターフェースを保存する例です。 + +```ts title="pages/home/ui/RecentActionsProps.ts" +export interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} +``` + +```html title="pages/home/ui/RecentActions.vue" + +``` + +## 環境宣言ファイル(`*.d.ts`) + +一部のパッケージ、例えば[Vite][ext-vite]や[ts-reset][ext-ts-reset]は、アプリケーションで動作するために環境宣言ファイルを必要とします。通常、これらは小さくて簡単なので、特にアーキテクチャを必要とせず、単に`src/`に置くことができます。`src`をより整理されたものにするために、App層の`app/ambient/`に保存することもできます。 + +他のパッケージは単に型を持たず、その型を未定義として宣言する必要があるか、あるいは自分で型を作成する必要があるかもしれません。これらの型の良い場所は`shared/lib`で、`shared/lib/untyped-packages`のようなフォルダーです。そこに`%LIBRARY_NAME%.d.ts`というファイルを作成し、必要な型を宣言します。 + +```ts title="shared/lib/untyped-packages/use-react-screenshot.d.ts" +// このライブラリには型がなく、自分で型を書くのは億劫です。 +declare module "use-react-screenshot"; +``` + +## 型の自動生成 + +外部ソースから型を生成することは、しばしば便利です。例えば、OpenAPIスキーマからバックエンドの型を生成することができます。この場合、これらの型のためにコード内に特別な場所を作成します。例えば、`shared/api/openapi`のようにします。これらのファイルが何であるか、どのように再生成されるかを説明するREADMEをこのフォルダーに含めておくと理想的です。 + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-type-fest]: https://github.com/sindresorhus/type-fest +[ext-zod]: https://zod.dev +[ext-vite]: https://vitejs.dev +[ext-ts-reset]: https://www.totaltypescript.com/ts-reset diff --git a/src/content/docs/ja/docs/guides/migration/from-custom.mdx b/src/content/docs/ja/docs/guides/migration/from-custom.mdx new file mode 100644 index 0000000000..a88e618732 --- /dev/null +++ b/src/content/docs/ja/docs/guides/migration/from-custom.mdx @@ -0,0 +1,230 @@ +--- +title: カスタムアーキテクチャからの移行 +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +このガイドは、カスタムのアーキテクチャからFeature-Sliced Designへの移行に役立つアプローチを説明します。 + +以下は、典型的なカスタムアーキテクチャのフォルダ構造です。このガイドでは、これを例として使用します。フォルダの内容が見えるように、フォルダの横にある青い矢印をクリックすることができます。 + + +- src/ + - actions/ + - product/ + - order/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - routes/ + - products.jsx + - products.[id].jsx + - utils/ + - reducers/ + - selectors/ + - styles/ + - App.jsx + - index.jsx + + +## 開始前に \{#before-you-start} + +Feature-Sliced Designへの移行を検討する際に、チームに最も重要な質問は「本当に必要か?」です。私たちはFeature-Sliced Designが好きですが、いくつかのプロジェクトはそれなしでうまくいけることを認めています。 + +移行を検討すべき理由はいくつかあります。 + +1. 新しいチームメンバーが生産的なレベルに達するのが難しいと不満を言う。 +2. コードの一部を変更すると、**しばしば**他の無関係な部分が壊れる。 +3. 巨大なコードベースのため、新しい機能を追加するのが難しい。 + +**同僚の意に反してFSDに移行することは避けてください**。たとえあなたがチームリーダーであっても、まずは同僚を説得し、移行の利点がコストを上回ることを理解させる必要があります。 + +また、アーキテクチャの変更は、瞬時には経営陣には見えないことを覚えておいてください。始める前に、経営陣が移行を支持していることを確認し、この移行がプロジェクトにどのように役立つかを説明してください。 + + + +--- + +もし移行を始める決断をした場合、最初に行うべきことは`📁 src`のエイリアスを設定することです。これは、後で上位フォルダを参照するのに便利です。以降のテキストでは、`@`を`./src`のエイリアスとして扱います。 + +## ステップ1。コードをページごとに分割する \{#divide-code-by-pages} + +ほとんどのカスタムアーキテクチャは、ロジックのサイズに関係なく、すでにページごとに分割されています。もし`📁 pages`がすでに存在する場合は、このステップをスキップできます。 + +もし`📁 routes`しかない場合は、`📁 pages`を作成し、できるだけ多くのコンポーネントコードを`📁 routes`から移動させてみてください。理想的には、小さなルートファイルと大きなページファイルがあることです。コードを移動させる際には、各ページのためのフォルダを作成し、その中にインデックスファイルを追加します。 + + + +ルートファイル + +```js title="src/routes/products.[id].js" +export { ProductPage as default } from "@/pages/product" +``` + +ページのインデックスファイル + +```js title="src/pages/product/index.js" +export { ProductPage } from "./ProductPage.jsx" +``` + +ページコンポーネントファイル + +```jsx title="src/pages/product/ProductPage.jsx" +export function ProductPage(props) { + return
; +} +``` + +## ステップ2。 ページ以外のすべてを分離する \{#separate-everything-else-from-pages} + +`📁 src/shared`フォルダを作成し、`📁 pages`や`📁 routes`からインポートされないすべてをそこに移動します。`📁 src/app`フォルダを作成し、ページやルートをインポートするすべてをそこに移動します。 + +Shared層にはスライスがないことを覚えておいてください。したがって、セグメントは互いにインポートできます。 + +最終的には、次のようなファイル構造になるはずです。 + + +- src/ + - app/ + - routes/ + - products.jsx + - products.[id].jsx + - App.jsx + - index.js + - pages/ + - product/ + - index.js + - ui/ + - ProductPage.jsx + - catalog/ + - shared/ + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## ステップ3。 ページ間のクロスインポートを解消する \{#tackle-cross-imports-between-pages} + +あるページが他のページから何かをインポートしているすべての箇所を見つけ、次のいずれかを行います。 + +1. インポートされているコードを依存するページにコピーして、依存関係を取り除く。 +2. コードをShared層の適切なセグメントに移動する + - UIキットの一部であれば、`📁 shared/ui`に移動。 + - 設定の定数であれば、`📁 shared/config`に移動。 + - バックエンドとのやり取りであれば、`📁 shared/api`に移動。 + + + +## ステップ4。 Shared層を分解する \{#unpack-shared-layer} + +この段階では、Shared層に多くのものが入っているかもしれませんが、一般的にはそのような状況を避けるべきです。理由は、Shared層に依存している他の層にあるコードが存在している可能性があるため、そこに変更を加えることは予期しない結果を引き起こす可能性が高くなります。 + +特定のページでのみ使用されるすべてのオブジェクトを見つけ、それらをそのページのスライスに移動します。そして、_これにはアクション、リデューサー、セレクターも含まれます_。すべてのアクションを一緒にグループ化することには意味がありませんが、関連するアクションをその使用場所の近くに置くことには意味があります。 + +最終的には、次のようなファイル構造になるはずです。 + + +- src/ + - app/ + - ... + - pages/ + - product/ + - actions/ + - reducers/ + - selectors/ + - ui/ + - Component.jsx + - Container.jsx + - ProductPage.jsx + - index.js + - catalog/ + - shared/ only objects that are reused + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## ステップ5。 コードを技術的な目的に基づいて整理する \{#organize-by-technical-purpose} + +FSDでは、技術的な目的に基づく分割がセグメントによって行われます。よく見られるセグメントはいくつかあります。 + +- `ui` — インターフェースの表示に関連するすべて: UIコンポーネント、日付のフォーマット、スタイルなど。 +- `api` — バックエンドとのやり取り: リクエスト関数、データ型、マッパーなど。 +- `model` — データモデル: スキーマ、インターフェース、ストレージ、ビジネスロジック。 +- `lib` — 他のモジュールに必要なライブラリコード。 +- `config` — 設定ファイルやフィーチャーフラグ。 + +必要に応じて独自のセグメントを作成できます。ただし、コードをその性質によってグループ化するセグメント(例: `components`、`actions`、`types`、`utils`)を作成しないようにしてください。代わりに、コードの目的に基づいてグループ化してください。 + +ページのコードをセグメントに再分配します。すでに`ui`セグメントがあるはずなので、今は他のセグメント(例えば、アクション、リデューサー、セレクターのための`model`や、サンクやミューテーションのための`api`)を作成するときです。 + +また、Shared層を再分配して、次のフォルダを削除します。 + +- `📁 components`、`📁 containers` — その内容のほとんどは`📁 shared/ui`になるべきです。 +- `📁 helpers`、`📁 utils` — 再利用可能なヘルパー関数が残っている場合は、目的に基づいてグループ化し、これらのグループを`📁 shared/lib`に移動します。 +- `📁 constants` — 同様に、目的に基づいてグループ化し、`📁 shared/config`に移動します。 + +## 任意のステップ \{#optional-steps} + +### ステップ6。 複数のページで使用されるReduxスライスからエンティティ/フィーチャーを形成する \{#form-entities-features-from-redux} + +通常、これらの再利用可能なReduxスライスは、ビジネスに関連する何かを説明します(例えば、プロダクトやユーザーなど)。したがって、それらをエンティティ層に移動できます。1つのエンティティにつき1つのフォルダです。Reduxスライスが、ユーザーがアプリケーションで実行したいアクションに関連している場合(例えば、コメントなど)、それをフィーチャー層に移動できます。 + +エンティティとフィーチャーは互いに独立している必要があります。ビジネス領域に組み込まれたエンティティ間の関係がある場合は、[ビジネスエンティティに関するガイド][business-entities-cross-relations]を参照して、これらの関係を整理する方法を確認してください。 + +これらのスライスに関連するAPI関数は、`📁 shared/api`に残すことができます。 + +### ステップ7。 モジュールをリファクタリングする \{#refactor-your-modules} + +`📁 modules`フォルダは通常、ビジネスロジックに使用されるため、すでにFSDのフィーチャー層に似た性質を持っています。一部のモジュールは、アプリケーションの大きな部分(例えば、アプリのヘッダーなど)を説明することもあります。この場合、それらをウィジェット層に移動できます。 + +### ステップ8。 `shared/ui`にUI基盤を正しく形成する \{#form-clean-ui-foundation} + +理想的には、`📁 shared/ui`にはビジネスロジックが含まれていないUI要素のセットが含まれるべきです。また、非常に再利用可能である必要があります。 + +以前`📁 components`や`📁 containers`にあったUIコンポーネントをリファクタリングして、ビジネスロジックを分離します。このビジネスロジックを上位層に移動します。あまり多くの場所で使用されていない場合は、コピーを検討することもできます。 + +[ext-steiger]: https://github.com/feature-sliced/steiger +[business-entities-cross-relations]: /docs/guides/examples/types#business-entities-and-their-cross-references diff --git a/src/content/docs/ja/docs/guides/migration/from-v1.mdx b/src/content/docs/ja/docs/guides/migration/from-v1.mdx new file mode 100644 index 0000000000..0631256e5b --- /dev/null +++ b/src/content/docs/ja/docs/guides/migration/from-v1.mdx @@ -0,0 +1,154 @@ +--- +title: v1からv2への移行 +sidebar: + order: 2 +--- + +## なぜv2なのか? \{#why-v2} + +初期の**feature-slices**の概念は、2018年に提唱されました。 + +それ以来、FSD方法論は多くの変革を経てきましたが、基本的な原則は保持されています。 + +- *標準化された*フロントエンドプロジェクト構造の使用 +- アプリケーションを*ビジネスロジック*に基づいて分割 +- *孤立した機能*の使用により、暗黙の副作用や循環依存を防止 +- モジュールの「内部」にアクセスすることを禁止する*公開API*の使用 + +しかし、以前のバージョンのFSD方法論には依然として**弱点が残っていました**。 + +- ボイラープレートの発生 +- コードベースの過剰な複雑化と抽象化間の明確でないルール +- プロジェクトのメンテナンスや新しいメンバーのオンボーディングを妨げていた暗黙のアーキテクチャ的決定 + +新しいバージョンのFSD方法論([v2][ext-v2])は、**これらの欠点を解消しつつ、既存の利点を保持することを目的としています**。 + +2018年以降、[**feature-driven**][ext-fdd]という別の類似の方法論が[発展してきました][ext-fdd-issues]。それを最初に提唱したのは[Oleg Isonen][ext-kof]でした。 + +2つのアプローチの統合により、**既存のプラクティスが改善され、柔軟性、明確さ、効率が向上しました**。 + +> 結果として、方法論の名称も「feature-slice**d**」に変更されました。 + +## なぜプロジェクトをv2に移行する意味があるのか? \{#why-does-it-make-sense-to-migrate-the-project-to-v2} + +> `WIP:` 現在の方法論のバージョンは開発中であり、一部の詳細は*変更される可能性があります*。 + +#### 🔍 より透明でシンプルなアーキテクチャ \{#-more-transparent-and-simple-architecture} + +FSD(v2)は、**より直感的で、開発者の間で広く受け入れられている抽象化とロジックの分割方法を提供しています**。 + +これにより、新しいメンバーの参加やプロジェクトの現状理解、アプリケーションのビジネスロジック分配に非常に良い影響を与えます。 + +#### 📦 より柔軟で誠実なモジュール性 \{#-more-flexible-and-honest-modularity} + +FSD(v2)は、**より柔軟な方法でロジックを分配することを可能にしています**。 + +- 孤立した部分をゼロからリファクタリングできる +- 同じ抽象化に依存しつつ、余計な依存関係の絡みを避けられる +- 新しいモジュールの配置をよりシンプルにできる *(layer → slice → segment)* + +#### 🚀 より多くの仕様、計画、コミュニティ \{#-more-specifications-plans-community} + +`core-team`は最新の(v2)バージョンのFSD方法論に積極的に取り組んでいます。 + +したがって、以下のことが期待できます。 + +- より多くの記述されたケース/問題 +- より多くの適用ガイド +- より多くの実例 +- 新しいメンバーのオンボーディングや方法論概念の学習のための全体的な文書の増加 +- 方法論の概念とアーキテクチャに関するコンベンションを遵守するためのツールキットのさらなる発展 + +> もちろん、初版に対するユーザーサポートも行われますが、私たちにとっては最新のバージョンが最優先です。 + +> 将来的には、次のメジャーアップデートの際に、現在のバージョン(v2)へのアクセスが保持され、**チームやプロジェクトにリスクをもたらすことはありません**。 + +## Changelog + +### `BREAKING` Layers + +FSD方法論は上位レベルでの層の明示的な分離を前提としています。 + +- `/app` > `/processes` > **`/pages`** > **`/features`** > `/entities` > `/shared` +- *つまり、すべてがフィーチャーやページとして解釈されるわけではない* +- このアプローチにより、層のルールを明示的に設定することが可能になる + - モジュールの**層が高いほど**、より多くの**コンテキスト**を持つことができる + + *(言い換えれば、各層のモジュールは、下層のモジュールのみをインポートでき、上層のモジュールはインポートできない)* + - モジュールの**層が低いほど**、変更を加える際の**危険性と責任**が増す + + *(一般的に、再利用されるのは下層のモジュールらからである)* + +### `BREAKING` Shared層 + +以前はプロジェクトのsrcルートにあったインフラストラクチャの `/ui`, `/lib`, `/api` 抽象化は、現在 `/src/shared` という別のディレクトリに分離されています。 + +- `shared/ui` - アプリケーションの共通UIキット(オプション) + - *ここで`Atomic Design`を使用することは引き続き許可されている* +- `shared/lib` - ロジックを実装するための補助ライブラリセット + - *引き続き、ヘルパー関数の「ごみ屋敷」を作らずに* +- `shared/api` - APIへのアクセスのための共通エントリポイント + - *各フィーチャー/ページにローカルに記述することも可能だが、推奨されない* +- 以前と同様に、`shared`にはビジネスロジックへの明示的な依存関係があってはならない + - *必要に応じて、この依存関係は`entities`、またはそれ以上の層に移動する必要がある* + +### `新規` Entities層, Processes層 + +v2では、**ロジックの複雑さと強い結合の問題を解消するために、新しい抽象化が追加されました**。 + +- `/entities` - **ビジネスエンティティ**の層で、ビジネスモデルやフロントエンド専用の合成エンティティに関連するスライスを含む + - *例:`user`, `i18n`, `order`, `blog`* +- `/processes` - アプリケーション全体にわたる**ビジネスプロセス**の層 + - **この層はオプションであり、通常は*ロジックが拡大し、複数のページにまたがる場合に使用が推奨される*** + - *例:`payment`, `auth`, `quick-tour`* + +### `BREAKING` 抽象化と命名 + +具体的な抽象化とその命名に関する[明確なガイドライン][refs-adaptability]が定義されています。 + +#### Layers + +- `/app` — **アプリケーションの初期化層** + - *以前のバリエーション: `app`, `core`, `init`, `src/index`* +- `/processes` — **ビジネスプロセスの層** + - *以前のバリエーション: `processes`, `flows`, `workflows`* +- `/pages` — **アプリケーションのページ層** + - *以前のバリエーション: `pages`, `screens`, `views`, `layouts`, `components`, `containers`* +- `/features` — **機能部分の層** + - *以前のバリエーション: `features`, `components`, `containers`* +- `/entities` — **ビジネスエンティティの層** + - *以前のバリエーション: `entities`, `models`, `shared`* +- `/shared` — **再利用可能なインフラストラクチャコードの層** 🔥 + - *以前のバリエーション: `shared`, `common`, `lib`* + +#### Segments + +- `/ui` — **UIセグメント** 🔥 + - *以前のバリエーション:`ui`, `components`, `view`* +- `/model` — **ビジネスロジックのセグメント** 🔥 + - *以前のバリエーション:`model`, `store`, `state`, `services`, `controller`* +- `/lib` — **補助コードのセグメント** + - *以前のバリエーション:`lib`, `libs`, `utils`, `helpers`* +- `/api` — **APIセグメント** + - *以前のバリエーション:`api`, `service`, `requests`, `queries`* +- `/config` — **アプリケーション設定のセグメント** + - *以前のバリエーション:`config`, `env`, `get-env`* + +### `REFINED` 低結合 + +新しいレイヤーのおかげで、モジュール間の[低結合の原則][refs-low-coupling]を遵守することがはるかに簡単になりました。 + +*それでも、モジュールを「切り離す」ことが非常に難しい場合は、できるだけ避けることが推奨されます*。 + +## 参照 \{#see-also} + +- [React Berlin Talk - Oleg Isonen "Feature Driven Architecture"][ext-kof-fdd] + +[refs-low-coupling]: /docs/reference/slices-segments#zero-coupling-high-cohesion +[refs-adaptability]: /docs/about/understanding/naming + +[ext-fdd]: https://github.com/feature-sliced/documentation/tree/rc/feature-driven +[ext-fdd-issues]: https://github.com/kof/feature-driven-architecture/issues +[ext-v2]: https://github.com/feature-sliced/documentation +[ext-kof]: https://github.com/kof +[ext-kof-fdd]: https://www.youtube.com/watch?v=BWAeYuWFHhs diff --git a/src/content/docs/ja/docs/guides/migration/from-v2-0.mdx b/src/content/docs/ja/docs/guides/migration/from-v2-0.mdx new file mode 100644 index 0000000000..e727221245 --- /dev/null +++ b/src/content/docs/ja/docs/guides/migration/from-v2-0.mdx @@ -0,0 +1,45 @@ +--- +title: v2.0からv2.1への移行 +sidebar: + order: 3 +--- + +v2.1の主な変更点は、インターフェースを分解するための「ページファースト」という新しいメンタルモデルです。 + +v2.0では、FSDは分解のためにエンティティ表現やインタラクティビティの最小部分まで考慮し、インターフェース内のエンティティとフィーチャーを特定することを推奨していました。そうしてから、エンティティとフィーチャーからウィジェットやページが構築されていきました。この分解モデルでは、ほとんどのロジックはエンティティとフィーチャーにあり、ページはそれ自体にはあまり重要性のない構成層に過ぎませんでした。 + +v2.1では、分解をページから始めること、または場合によってはページで止めることを推奨します。ほとんどの人はすでにアプリを個々のページに分ける方法を知っており、ページはコードベース内のコンポーネントを見つける際の一般的な出発点でもあります。この新しい分解モデルでは、各個別のページにほとんどのUIとロジックを保持し、Sharedに再利用可能な基盤を維持します。複数のページでビジネスロジックを再利用する必要が生じた場合は、それを下層のレイヤーに移動できます。 + +Feature-Sliced Designへのもう一つの追加は、`@x`表記を使用したエンティティ間のクロスインポートの標準化です。 + +## 移行方法 \{#how-to-migrate} + +v2.1には破壊的な変更はなく、FSD v2.0で書かれたプロジェクトもFSD v2.1の有効なプロジェクトです。しかし、新しいメンタルモデルがチームや特に新しい開発者のオンボーディングにとってより有益であると考えているため、分解に対して小さな調整を行うことを推奨します。 + +### スライスのマージ + +移行を始めるための簡単な方法は、プロジェクトでFSDのリンターである[Steiger][steiger]を実行することです。Steigerは新しいメンタルモデルで構築されており、最も役立つルールは次のとおりです。 + +- [`insignificant-slice`][insignificant-slice] — エンティティ、またはフィーチャーが1ページでのみ使用されている場合、このルールはそのエンティティ、またはフィーチャーをページに完全にマージすることを提案します。 +- [`excessive-slicing`][excessive-slicing] — レイヤーにスライスが多すぎる場合、通常は分解が細かすぎるサインです。このルールは、プロジェクトのナビゲーションを助けるためにいくつかのスライスをマージ、またはグループ化することを提案します。 + +```bash +npx steiger src +``` + +これにより、1回だけ使用されるスライスを特定できるため、それらが本当に必要か再考することができます。そのような考慮において、レイヤーはその内部のすべてのスライスのための何らかのグローバル名前空間を形成することを念頭に置いてください。1回だけ使用される変数でグローバル名前空間を汚染しないようにするのと同様に、レイヤーの名前空間内の場所を貴重なものとして扱い、慎重に使用するべきです。 + +### クロスインポートの標準化 + +以前にプロジェクト内でクロスインポートがあった場合、Feature-Sliced Designでのクロスインポートのための新しい表記法`@x`を活用できます。これは次のようになります。 + +```ts title="entities/B/some/file.ts" +import type { EntityA } from "entities/A/@x/B"; +``` + +詳細については、リファレンスの[クロスインポートの公開API][public-api-for-cross-imports]セクションを参照してください。 + +[insignificant-slice]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice +[steiger]: https://github.com/feature-sliced/steiger +[excessive-slicing]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/ja/docs/guides/tech/with-nextjs.mdx b/src/content/docs/ja/docs/guides/tech/with-nextjs.mdx new file mode 100644 index 0000000000..59602a1aae --- /dev/null +++ b/src/content/docs/ja/docs/guides/tech/with-nextjs.mdx @@ -0,0 +1,114 @@ +--- +title: NextJSとの併用 +sidebar: + order: 1 +--- + +import { Tabs, TabItem, FileTree } from '@astrojs/starlight/components'; + +# NextJSとの併用 + +NextJSプロジェクトでFSDを実装することは可能ですが、プロジェクトの構造に関するNextJSの要件とFSDの原則の間に2つの点で対立が生じます。 + +- `pages`のファイルルーティング +- NextJSにおける`app`の対立、または欠如 + +## `pages`におけるFSDとNextJSの対立 \{#pages-conflict} + +NextJSは、アプリケーションのルートを定義するために`pages`フォルダーを使用することを提案しています。`pages`フォルダー内のファイルがURLに対応することを期待しています。このルーティングメカニズムは、FSDの概念に**適合しません**。なぜなら、このようなルーティングメカニズムでは、スライスの平坦な構造を維持することができないからです。 + +### NextJSの`pages`フォルダーをプロジェクトのルートフォルダーに移動する(推奨) + +このアプローチは、NextJSの`pages`フォルダーをプロジェクトのルートフォルダーに移動し、FSDのページをNextJSの`pages`フォルダーにインポートすることにあります。これにより、`src`フォルダー内でFSDのプロジェクト構造を維持できます。 + + +- pages NextJSのpagesフォルダー +- src + - app + - entities + - features + - pages FSDのpagesフォルダー + - shared + - widgets + + +### FSD構造における`pages`フォルダーの名前変更 + +もう一つの解決策は、FSD構造内の`pages`層の名前を変更して、NextJSの`pages`フォルダーとの名前衝突を避けることです。 +FSDの`pages`層を`views`層に変更することができます。 +このようにすることで、`src`フォルダー内のプロジェクト構造は、NextJSの要件と矛盾することなく保持されます。 + + + +- app +- entities +- features +- pages NextJSのpagesフォルダー +- views 名前が変更されたFSDのページフォルダー +- shared +- widgets + + +この場合、プロジェクトのREADMEや内部ドキュメントなど、目立つ場所にこの名前変更を文書化することをお勧めします。この名前変更は、[「プロジェクト知識」][project-knowledge]の一部です。 + +## NextJSにおける`app`フォルダーの欠如 \{#app-absence} + +NextJSのバージョン13未満では、明示的な`app`フォルダーは存在せず、代わりにNextJSは`_app.tsx`ファイルを提供しています。このファイルは、プロジェクトのすべてのページのラッピングコンポーネントとして機能しています。 + +### `pages/_app.tsx`ファイルへの機能のインポート + +NextJSの構造における`app`フォルダーの欠如の問題を解決するために、`app`層内に`App`コンポーネントを作成し、NextJSがそれを使用できるように`pages/_app.tsx`に`App`コンポーネントをインポートすることができます。例えば + + +```tsx +// app/providers/index.tsx + +const App = ({ Component, pageProps }: AppProps) => { + return ( + + + + + + + + ); +}; + +export default App; +``` +その後、`App`コンポーネントとプロジェクトのグローバルスタイルを`pages/_app.tsx`に次のようにインポートできます。 + +```tsx +// pages/_app.tsx + +import 'app/styles/index.scss' + +export { default } from 'app/providers'; +``` + + +## App Routerの使用 \{#app-router} + +App Routerは、Next.jsのバージョン13.4で安定版として登場しました。App Routerを使用すると、`pages`フォルダーの代わりに`app`フォルダーをルーティングに使用できます。 +FSDの原則に従うために、NextJSの`app`フォルダーを`pages`フォルダーとの名前衝突を解消するために推奨される方法で扱うべきです。 + +このアプローチは、NextJSの`app`フォルダーをプロジェクトのルートフォルダーに移動し、FSDのページをNextJSの`app`フォルダーにインポートすることに基づいています。これにより、`src`フォルダー内のFSDプロジェクト構造が保持されます。また、プロジェクトのルートフォルダーに`pages`フォルダーを追加することもお勧めします。なぜなら、App RouterはPages Routerと互換性があるからです。 + + +- app/ NextJSのappフォルダー +- pages/ 空のNextJSのpagesフォルダー + - README.md このフォルダーの目的に関する説明 +- src/ + - app/ FSDのappフォルダー + - entities/ + - features/ + - pages/ FSDのpagesフォルダー + - shared/ + - widgets/ + + +[![StackBlitzで開く](https://developer.stackblitz.com/img/open_in_stackblitz.svg)][ext-app-router-stackblitz] + +[project-knowledge]: /docs/about/understanding/knowledge-types +[ext-app-router-stackblitz]: https://stackblitz.com/edit/stackblitz-starters-aiez55?file=README.md diff --git a/src/content/docs/ja/docs/guides/tech/with-nuxtjs.mdx b/src/content/docs/ja/docs/guides/tech/with-nuxtjs.mdx new file mode 100644 index 0000000000..a428ad58dc --- /dev/null +++ b/src/content/docs/ja/docs/guides/tech/with-nuxtjs.mdx @@ -0,0 +1,183 @@ +--- +title: NuxtJSとの併用 +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +# NuxtJSとの併用 + +NuxtJSプロジェクトでFSDを実装することは可能ですが、NuxtJSのプロジェクト構造要件とFSDの原則の違いにより、以下の2点でコンフリクトが発生してしまいます。 + +- NuxtJSは`src`フォルダーなしでプロジェクトのファイル構造を提供している。つまり、ファイル構造がプロジェクトのルートに配置される。 +- ファイルルーティングは`pages`フォルダーにあるが、FSDではこのフォルダーはフラットなスライス構造に割り当てられている。 + +## `src`ディレクトリのエイリアスを追加する + +設定ファイルに`alias`オブジェクトを追加します。 +```ts +export default defineNuxtConfig({ + devtools: { enabled: true }, // FSDには関係なく、プロジェクト起動時に有効 + alias: { + "@": '../src' + }, +}) +``` +## ルーター設定方法の選択 + +NuxtJSには、コンフィグを使用する方法とファイル構造を使用する方法の2つのルーティング設定方法があります。 +ファイルベースのルーティングの場合、`app/routes`ディレクトリ内に`index.vue`ファイルを作成します。一方、コンフィグを使用する場合は、`router.options.ts`ファイルでルートを設定します。 + +### コンフィグによるルーティング + +`app`層に`router.options.ts`ファイルを作成し、設定オブジェクトをエクスポートします。 +```ts title="app/router.options.ts" +import type { RouterConfig } from '@nuxt/schema'; + +export default { + routes: (_routes) => [], +}; + +``` + +プロジェクトにホームページを追加するには、次の手順を行います。 +- `pages`層内にページスライスを追加する +- `app/router.config.ts`のコンフィグに適切なルートを追加する + +ページスライスを作成するには、[CLI](https://github.com/feature-sliced/cli)を使用します。 + +```shell +fsd pages home +``` + +`home-page.vue`ファイルを`ui`セグメント内に作成し、公開APIを介してアクセスできるようにします。 + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +このように、ファイル構造は次のようになります。 + + +- src/ + - app/ + - router.config.ts + - pages/ + - home/ + - ui/ + - home-page.vue + - index.ts + + +最後に、ルートをコンフィグに追加します。 + +```ts title="app/router.config.ts" +import type { RouterConfig } from '@nuxt/schema' + +export default { + routes: (_routes) => [ + { + name: 'home', + path: '/', + component: () => import('@/pages/home.vue').then(r => r.default || r) + } + ], +} +``` + +### ファイルルーティング + +まず、プロジェクトのルートに`src`ディレクトリを作成し、その中に`app`層と`pages`層のレイヤー、`app`層内に`routes`フォルダーを作成します。 +このように、ファイル構造は次のようになります。 + + +- src/ + - app/ + - routes/ + - pages/ FSDに割り当てられたpagesフォルダー + + + +NuxtJSが`app`層内の`routes`フォルダーをファイルルーティングに使用するには、`nuxt.config.ts`を次のように変更します。 +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // FSDには関係なく、プロジェクト起動時に有効 + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes' + } +}) +``` + + +これで、`app`層内のページに対してルートを作成し、`pages`層からページを接続できます。 + +例えば、プロジェクトに`Home`ページを追加するには、次の手順を行います。 +- `pages`層内にページスライスを追加する +- `app`層内に適切なルートを追加する +- スライスのページをルートに接続する + +ページスライスを作成するには、[CLI](https://github.com/feature-sliced/cli)を使用します。 +```shell +fsd pages home +``` + + +`home-page.vue`ファイルを`ui`セグメント内に作成し、公開APIを介してアクセスできるようにします。  + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +このページのルートを`app`層内に作成します。 + + +- src/ + - app/ + - routes/ + - index.vue + - pages/ + - home/ + - ui/ + - home-page.vue + - index.ts + + +`index.vue`ファイル内にページコンポーネントを追加します。 + +```html title="src/app/routes/index.vue" + + + +``` + +## `layouts`について + +`layouts`は`app`層内に配置できます。そのためには、コンフィグを次のように変更します。 + +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // FSDには関係なく、プロジェクト起動時に有効 + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes', + layouts: './src/app/layouts' + } +}) +``` + + +## 参照 + +- [NuxtJSのディレクトリ設定変更に関するドキュメント](https://nuxt.com/docs/api/nuxt-config#dir) +- [NuxtJSのルーター設定変更に関するドキュメント](https://nuxt.com/docs/guide/recipes/custom-routing#router-config) +- [NuxtJSのエイリアス設定変更に関するドキュメント](https://nuxt.com/docs/api/nuxt-config#alias) diff --git a/src/content/docs/ja/docs/guides/tech/with-react-query.mdx b/src/content/docs/ja/docs/guides/tech/with-react-query.mdx new file mode 100644 index 0000000000..c0643e77d5 --- /dev/null +++ b/src/content/docs/ja/docs/guides/tech/with-react-query.mdx @@ -0,0 +1,428 @@ +--- +title: React Queryとの併用 +sidebar: + order: 10 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## キーをどこに置くか問題 + +### 解決策 - エンティティごとに分割する + +プロジェクトにすでにエンティティの分割があり、各クエリが1つのエンティティに対応している場合、エンティティごとに分割するのが最良です。この場合、次の構造を使用することをお勧めします。 + + +- src/ + - app/ + - pages/ + - entities/ + - \{entity\}/ + - api/ + - \{entity\}.query クエリファクトリー、キーと関数が定義されている + - get-\{entity\} エンティティを取得する関数 + - create-\{entity\} エンティティを作成する関数 + - update-\{entity\} オブジェクトを更新する関数 + - delete-\{entity\} オブジェクトを削除する関数 + - features/ + - widgets/ + - shared/ + + +もしエンティティ間に関係がある場合(例えば、「国」のエンティティに「都市」のエンティティ一覧フィールドがある場合)、`@x` アノテーションを使用した組織的なクロスインポートの[クロスインポート用のパブリックAPI][public-api-for-cross-imports]を利用するか、以下の代替案を検討できます。 + +### 代替案 — クエリを公開で保存する + +エンティティごとの分割が適さない場合、次の構造を考慮できます。 + + +- src/ + - shared/ + - api/ + - queries/ クエリファクトリー + - document.ts + - background-jobs.ts + - index.ts + + +次に、`@/shared/api/index.ts`に + +```ts title="@/shared/api/index.ts" +export { documentQueries } from "./queries/document"; +``` + +## 問題「ミューテーションはどこに?」 + +ミューテーションをクエリと混合することは推奨されません。2つの選択肢が考えられます。 + +### 1. 使用場所の近くにAPIセグメントにカスタムフックを定義する + +```tsx title="@/features/update-post/api/use-update-title.ts" +export const useUpdateTitle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, newTitle }) => + apiClient + .patch(`/posts/${id}`, { title: newTitle }) + .then((data) => console.log(data)), + + onSuccess: (newPost) => { + queryClient.setQueryData(postsQueries.ids(id), newPost); + }, + }); +}; +``` + +### 2. 別の場所(Shared層やEntities層)にミューテーション関数を定義し、コンポーネント内で`useMutation`を直接使用する + +```tsx +const { mutateAsync, isPending } = useMutation({ + mutationFn: postApi.createPost, +}); +``` + +```tsx title="@/pages/post-create/ui/post-create-page.tsx" +export const CreatePost = () => { + const { classes } = useStyles(); + const [title, setTitle] = useState(""); + + const { mutate, isPending } = useMutation({ + mutationFn: postApi.createPost, + }); + + const handleChange = (e: ChangeEvent) => + setTitle(e.target.value); + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutate({ title, userId: DEFAULT_USER_ID }); + }; + + return ( +
+ + + Create + + + ); +}; +``` + +## クエリの組織化 + +### クエリファクトリー + +このガイドでは、クエリファクトリーの使い方について説明します。 + + + +```ts +const keyFactory = { + all: () => ["entity"], + lists: () => [...postQueries.all(), "list"], +}; +``` + + + + +### 1. クエリファクトリーの作成 + +```tsx title="@/entities/post/api/post.queries.ts" +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; +import { getDetailPost } from "./get-detail-post"; +import { PostDetailQuery } from "./query/post.query"; + +export const postQueries = { + all: () => ["posts"], + + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), + + details: () => [...postQueries.all(), "detail"], + detail: (query?: PostDetailQuery) => + queryOptions({ + queryKey: [...postQueries.details(), query?.id], + queryFn: () => getDetailPost({ id: query?.id }), + staleTime: 5000, + }), +}; +``` + +### 2. アプリケーションコードでのクエリファクトリーの適用 + +```tsx +import { useParams } from "react-router-dom"; +import { postApi } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; + +type Params = { + postId: string; +}; + +export const PostPage = () => { + const { postId } = useParams(); + const id = parseInt(postId || ""); + const { + data: post, + error, + isLoading, + isError, + } = useQuery(postApi.postQueries.detail({ id })); + + if (isLoading) { + return
Loading...
; + } + + if (isError || !post) { + return <>{error?.message}; + } + + return ( +
+

Post id: {post.id}

+
+

{post.title}

+
+

{post.body}

+
+
+
Owner: {post.userId}
+
+ ); +}; +``` + +### クエリファクトリーを使用する利点 +- **クエリの構造化:** ファクトリーはすべてのAPIクエリを1か所に整理し、コードをより読みやすく、保守しやすくしている +- **クエリとキーへの便利なアクセス:** ファクトリーはさまざまなタイプのクエリとそのキーへの便利なメソッドを提供している +- **クエリの再フェッチ機能:** ファクトリーは、アプリケーションのさまざまな部分でクエリキーを変更することなく、簡単に再フェッチを行うことを可能にしている + +## ページネーション + +このセクションでは、ページネーションを使用して投稿エンティティを取得するためのAPIクエリを行う`getPosts`関数の例を挙げます。 + +### 1. `getPosts`関数の作成 + +`getPosts`関数は、APIセグメント内の`get-posts.ts`ファイルにあります。 + +```tsx title="@/pages/post-feed/api/get-posts.ts" +import { apiClient } from "@/shared/api/base"; + +import { PostWithPaginationDto } from "./dto/post-with-pagination.dto"; +import { PostQuery } from "./query/post.query"; +import { mapPost } from "./mapper/map-post"; +import { PostWithPagination } from "../model/post-with-pagination"; + +const calculatePostPage = (totalCount: number, limit: number) => + Math.floor(totalCount / limit); + +export const getPosts = async ( + page: number, + limit: number, +): Promise => { + const skip = page * limit; + const query: PostQuery = { skip, limit }; + const result = await apiClient.get("/posts", query); + + return { + posts: result.posts.map((post) => mapPost(post)), + limit: result.limit, + skip: result.skip, + total: result.total, + totalPages: calculatePostPage(result.total, limit), + }; +}; +``` + +### 2. ページネーション用のクエリファクトリー + +`postQueries`クエリファクトリーは、投稿に関するさまざまなクエリオプションを定義し、事前に定義されたページとリミットを使用して投稿一覧を取得するクエリを含みます。 + +```tsx +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; + +export const postQueries = { + all: () => ["posts"], + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), +}; +``` + + +### 3. アプリケーションコードでの使用 + +```tsx title="@/pages/home/ui/index.tsx" +export const HomePage = () => { + const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN; + const [page, setPage] = usePageParam(DEFAULT_PAGE); + const { data, isFetching, isLoading } = useQuery( + postApi.postQueries.list(page, itemsOnScreen), + ); + return ( + <> + setPage(page)} + page={page} + count={data?.totalPages} + variant="outlined" + color="primary" + /> + + + ); +}; +``` + + +## クエリ管理用の`QueryProvider` + +このガイドでは、`QueryProvider`をどのように構成するべきかを説明します。 + +### 1. `QueryProvider`の作成 + +`query-provider.tsx`ファイルは`@/app/providers/query-provider.tsx`にあります。 + +```tsx title="@/app/providers/query-provider.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; + client: QueryClient; +}; + +export const QueryProvider = ({ client, children }: Props) => { + return ( + + {children} + + + ); +}; +``` + +### 2. `QueryClient`の作成 + +`QueryClient`はAPIクエリを管理するために使用されるインスタンスです。`query-client.ts`ファイルは`@/shared/api/query-client.ts`にあります。`QueryClient`はクエリキャッシング用の特定の設定で作成されます。 + +```tsx title="@/shared/api/query-client.ts" +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }, + }, +}); +``` + +## コード生成 + +自動コード生成のためのツールが存在しますが、これらは上記のように設定可能なものと比較して柔軟性が低いです。Swaggerファイルが適切に構造化されている場合、これらのツールの1つを使用して`@/shared/api`ディレクトリ内のすべてのコードを生成することができます。 + +## RQの整理に関する追加のアドバイス + +### APIクライアント + +共有層であるshared層でカスタムのAPIクライアントクラスを使用することで、プロジェクト内でのAPI設定やAPI操作を標準化できます。これにより、ログ記録、ヘッダー、およびデータ交換形式(例: JSONやXML)を一元管理することができます。このアプローチにより、APIとの連携の変更や更新が簡単になり、プロジェクトのメンテナンスや開発が容易になります。 + +```tsx title="@/shared/api/api-client.ts" +import { API_URL } from "@/shared/config"; + +export class ApiClient { + private baseUrl: string; + + constructor(url: string) { + this.baseUrl = url; + } + + async handleResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + try { + return await response.json(); + } catch (error) { + throw new Error("Error parsing JSON response"); + } + } + + public async get( + endpoint: string, + queryParams?: Record, + ): Promise { + const url = new URL(endpoint, this.baseUrl); + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.append(key, value.toString()); + }); + } + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + return this.handleResponse(response); + } + + public async post>( + endpoint: string, + body: TData, + ): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return this.handleResponse(response); + } +} + +export const apiClient = new ApiClient(API_URL); +``` + +## 参照 \{#see-also} + +- [The Query Options API](https://tkdodo.eu/blog/the-query-options-api) + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/ja/docs/guides/tech/with-sveltekit.mdx b/src/content/docs/ja/docs/guides/tech/with-sveltekit.mdx new file mode 100644 index 0000000000..3440ce18ef --- /dev/null +++ b/src/content/docs/ja/docs/guides/tech/with-sveltekit.mdx @@ -0,0 +1,98 @@ +--- +title: SvelteKitとの併用 +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +SvelteKitプロジェクトでFSDを実装することは可能ですが、SvelteKitのプロジェクト構造要件とFSDの原則の違いにより、以下の2点でコンフリクトが発生してしまいます。 + +- SvelteKitは`src/routes`フォルダー内でファイル構造を作成することを提案しているが、FSDではルーティングは`app`層の一部である必要がある +- SvelteKitは、ルーティングに関係のないすべてのものを`src/lib`フォルダーに入れることを提案している + +## コンフィグファイルの設定 + +```ts title="svelte.config.ts" +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config}*/ +const config = { + preprocess: [vitePreprocess()], + kit: { + adapter: adapter(), + files: { + routes: 'src/app/routes', // ルーティングをapp層内に移動 + lib: 'src', + appTemplate: 'src/app/index.html', // アプリケーションのエントリーポイントをapp層内に移動 + assets: 'public' + }, + alias: { + '@/*': 'src/*' // srcディレクトリのエイリアスを作成 + } + } +}; +export default config; +``` + +## `src/app`内へのファイルルーティングの移動 + +`app`層を作成し、アプリケーションのエントリーポイントである`index.html`を移動し、`routes`フォルダーを作成します。 +最終的にファイル構造は次のようになります。 + + +- src/ + - app/ + - index.html + - routes/ + - pages/ FSDに割り当てられたpagesフォルダー + + +これで、`app`内にページのルートを作成したり、`pages`からのページをルートに接続したりできます。 + +例えば、プロジェクトにホームページを追加するには、次の手順を実行します。 +- `pages`層内にホームページスライスを追加する +- `app`層の`routes`フォルダーに対応するルートを追加する +- スライスのページとルートを統合する + +ホームページスライスを作成するには、[CLI](https://github.com/feature-sliced/cli)を使用します。 + +```shell +fsd pages home +``` + +`ui`セグメント内に`home-page.svelte`ファイルを作成し、公開APIを介してアクセスできるようにします。  + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page.svelte'; +``` + +このページのルートを`app`層内に作成します。 + + +- src/ + - app/ + - routes/ + - +page.svelte + - index.html + - pages/ + - home/ + - ui/ + - home-page.svelte + - index.ts + + +最後に`+page.svelte`ファイル内にページコンポーネントを追加します。 + +```html title="src/app/routes/+page.svelte" + + + + +``` + +## 参照 +- [SvelteKitのディレクトリ設定変更に関するドキュメント](https://kit.svelte.dev/docs/configuration#files) diff --git a/src/content/docs/ja/docs/reference/layers.mdx b/src/content/docs/ja/docs/reference/layers.mdx new file mode 100644 index 0000000000..7d54e39bd8 --- /dev/null +++ b/src/content/docs/ja/docs/reference/layers.mdx @@ -0,0 +1,154 @@ +--- +title: レイヤー +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +レイヤーは、Feature-Sliced Designにおける組織階層の最初のレベルです。その目的は、コードを責任の程度やアプリ内の他のモジュールへの依存度に基づいて分離することです。各レイヤーは、コードにどれだけの責任を割り当てるべきかを判断するための特別な意味を持っています。 + +合計で**7つのレイヤー**があり、責任と依存度が最も高いものから最も低いものへと配置されています。 + +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. + + +1. App (アップ) +2. Processes (プロセス、非推奨) +3. Pages (ページ) +4. Widgets (ウィジェット) +5. Features (フィーチャー) +6. Entities (エンティティ) +7. Shared (シェアード) + +プロジェクトにすべてのレイヤーを使用する必要はありません。プロジェクトに価値をもたらすと思う場合のみ追加してください。通常、ほとんどのフロントエンドプロジェクトには、少なくともShared、Page、Appのレイヤーがあります。 + +実際には、レイヤーは小文字の名前のフォルダーです(例えば、`📁 shared`、`📁 pages`、`📁 app`)。新しいレイヤーを追加することは推奨されていません。なぜなら、その意味は標準化されているからです。 + +## レイヤーに関するインポートルール + +レイヤーは _スライス_ で構成されており、これは非常に凝集性の高いモジュールのグループです。スライス間の依存関係は、**レイヤーに関するインポートルール**によって規制されています。 + +> _スライス内のモジュール(ファイル)は、下位のレイヤーにある他のスライスのみをインポートできます。_ + +例えば、フォルダー `📁 ~/features/aaa` は「aaa」という名前のスライスです。その中のファイル `~/features/aaa/api/request.ts` は、`📁 ~/features/bbb` 内のファイルからコードをインポートすることはできませんが、`📁 ~/entities` や `📁 ~/shared` からコードをインポートすることができ、例えば `~/features/aaa/lib/cache.ts` などの同じスライス内の隣接コードもインポートできます。 + +AppとSharedのレイヤーは、このルールの**例外**です。これらは同時にレイヤーとスライスの両方です。スライスはビジネスドメインによってコードを分割しますが、これらの2つのレイヤーは例外です。なぜなら、Sharedはビジネスドメインを持たず、Appはすべてのビジネスドメインを統合しているからです。 + +実際には、AppとSharedのレイヤーはセグメントで構成されており、セグメントは自由に互いにインポートできます。 + +## レイヤーの定義 + +このセクションでは、各レイヤーの意味を説明し、どのようなコードがそこに属するかの直感を得るためのものです。 + +### Shared + +このレイヤーは、アプリの残りの部分の基盤を形成します。外部世界との接続を作成する場所であり、例えばバックエンド、サードパーティライブラリ、環境などです。また、特定のタスクに集中した自分自身のライブラリを作成する場所でもあります。 + +このレイヤーは、Appレイヤーと同様に、_スライスを含みません_。スライスはビジネスドメインによってレイヤーを分割することを目的としていますが、Sharedにはビジネスドメインが存在しないため、Shared内のすべてのファイルは互いに参照し、インポートすることができます。 + +このレイヤーで通常見られるセグメントは次のとおりです。 + +- `📁 api` — APIクライアントおよび特定のバックエンドエンドポイントへのリクエストを行う関数 +- `📁 ui` — アプリケーションのUIキット + このレイヤーのコンポーネントはビジネスロジックを含むべきではありませんが、ビジネスに関連することは許可されています。例えば、会社のロゴやページレイアウトをここに置くことができます。UIロジックを持つコンポーネントも許可されています(例えば、オートコンプリートや検索バー) +- `📁 lib` — 内部ライブラリのコレクション + このフォルダーはヘルパーやユーティリティとして扱うべきではありません([なぜこれらのフォルダーがしばしばダンプに変わるか][ext-sova-utility-dump])。このフォルダー内の各ライブラリは、日付、色、テキスト操作など、1つの焦点を持つべきです。その焦点はREADMEファイルに文書化されるべきです。チームの開発者は、これらのライブラリに何を追加でき、何を追加できないかを知っているべきです +- `📁 config` — 環境変数、グローバルフィーチャーフラグ、アプリの他のグローバル設定 +- `📁 routes` — ルート定数、またはルートをマッチさせるためのパターン +- `📁 i18n` — 翻訳のセットアップコード、グローバル翻訳文字列 + +さらにセグメントを追加することは自由ですが、これらのセグメントの名前は内容の目的を説明するものでなければなりません。例えば、`components`、`hooks`、`types` は、コードを探しているときにあまり役立たないため、悪いセグメント名です。 + +### Entities + +このレイヤーのスライスは、プロジェクトが扱う現実世界の概念を表します。一般的には、ビジネスがプロダクトを説明するために使用する用語です。例えば、SNSは、ユーザー、投稿、グループなどのビジネスエンティティを扱うかもしれません。 + +エンティティスライスには、データストレージ(`📁 model`)、データ検証スキーマ(`📁 model`)、エンティティ関連のAPIリクエスト関数(`📁 api`)、およびインターフェース内のこのエンティティの視覚的表現(`📁 ui`)が含まれる場合があります。視覚的表現は、完全なUIブロックを生成する必要はなく、アプリ内の複数のページで同じ外観を再利用することを主に目的としています。異なるビジネスロジックは、プロップスやスロットを通じてそれに付加されることがあります。 + +#### エンティティの関係 + +FSDにおけるエンティティはスライスであり、デフォルトではスライスは互いに知ることができません。しかし、現実の世界では、エンティティはしばしば互いに相互作用し、一方のエンティティが他のエンティティを所有、または含むことがあります。そのため、これらの相互作用のビジネスロジックは、フィーチャーやページのような上位のレイヤーに保持されるのが望ましいです。 + +一つのエンティティのデータオブジェクトが他のデータオブジェクトを含む場合、通常はエンティティ間の接続を明示的にし、`@x`表記を使用してスライスの隔離を回避するのが良いアイデアです。理由は、接続されたエンティティは一緒にリファクタリングする必要があるため、その接続を見逃すことができないようにするのが最善です。 + +例: + +```ts title="entities/artist/model/artist.ts" +import type { Song } from "entities/song/@x/artist"; + +export interface Artist { + name: string; + songs: Array; +} +``` + +```ts title="entities/song/@x/artist.ts" +export type { Song } from "../model/song.ts"; +``` + +`@x`表記の詳細については、[クロスインポートの公開API][public-api-for-cross-imports]セクションを参照してください。 + +### Features + +このレイヤーは、アプリ内の主要なインタラクション、つまりユーザーが行いたいことを対象としています。これらのインタラクションは、ビジネスエンティティを含むことが多いです。 + +アプリのフィーチャーレイヤーを効果的に使用するための重要な原則は、**すべてのものがフィーチャーである必要はない**ということです。何かがフィーチャーである必要がある良い指標は、それが複数のページで再利用されるという事実です。 + +例えば、アプリに複数のエディターがあり、すべてにコメントがある場合、コメントは再利用されるフィーチャーです。スライスはコードを迅速に見つけるためのメカニズムであり、フィーチャーが多すぎると重要なものが埋もれてしまいます。 + +理想的には、新しいプロジェクトに入ったとき、既存のページやフィーチャーを見ると、アプリの機能性が分かります。何がフィーチャーであるべきかを決定する際には、プロジェクトの新参者が重要なコードの大きな領域を迅速に発見できるように最適化してください。 + +フィーチャーのスライスには、インタラクションを実行するためのUI(例えばフォーム、`📁 ui`)、アクションを実行するために必要なAPI呼び出し(`📁 api`)、検証および内部状態(`📁 model`)、フィーチャーフラグ(`📁 config`)が含まれる場合があります。 + +### Widgets + +ウィジェットレイヤーは、大きな自己完結型のUIブロックを対象としています。ウィジェットは、複数のページで再利用される場合や、所属するページにある複数の大きな独立したブロックの一つである場合に最も便利です。 + +UIのブロックがページの大部分を構成し、再利用されない場合、それは**ウィジェットであるべきではなく**、代わりにそのページ内に直接配置するべきです。 + + + +### Pages + +ページは、ウェブサイトやアプリケーションを構成するものです(スクリーンやアクティビティとも呼ばれます)。通常、1ページは1つのスライスに対応しますが、非常に似たページが複数ある場合、それらを1つのスライスにまとめることができます。例えば、登録フォームとログインフォームです。 + +チームがナビゲートしやすい限り、ページスライスに配置できるコードの量に制限はありません。ページ上のUIブロックが再利用されない場合、それをページスライス内に保持することは完全に問題ありません。 + +ページスライスには、通常、ページのUIやローディング状態、エラーバウンダリ(`📁 ui`)、データの取得や変更リクエスト(`📁 api`)が含まれます。ページが専用のデータモデルを持つことは一般的ではなく、状態の小さな部分はコンポーネント自体に保持されることがあります。 + +### Processes + + + +プロセスは、マルチページインタラクションのための逃げ道です。 + +このレイヤーは意図的に未定義のままにされています。ほとんどのアプリケーションはこのレイヤーを使用せず、ルーターやサーバーレベルのロジックをAppレイヤーに保持するべきです。このレイヤーは、Appレイヤーが大きくなりすぎてメンテナンスが困難になった場合にのみ使用することを検討してください。 + +### App + +アプリ全体に関するあらゆるもの、技術的な意味(例えば、コンテキストプロバイダー)やビジネス的な意味(例えば、分析)を含みます。 + +このレイヤーには通常、スライスは含まれず、Sharedと同様に、セグメントが直接存在します。 + +このレイヤーで通常見られるセグメントは次のとおりです。 + +- `📁 routes` — ルーターの設定 +- `📁 store` — グローバルストアの設定 +- `📁 styles` — グローバルスタイル +- `📁 entrypoint` — アプリケーションコードへのエントリポイント、フレームワーク固有 + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports +[ext-remix]: https://remix.run +[ext-sova-utility-dump]: https://dev.to/sergeysova/why-utils-helpers-is-a-dump-45fo diff --git a/src/content/docs/ja/docs/reference/public-api.mdx b/src/content/docs/ja/docs/reference/public-api.mdx new file mode 100644 index 0000000000..727e13422d --- /dev/null +++ b/src/content/docs/ja/docs/reference/public-api.mdx @@ -0,0 +1,162 @@ +--- +title: 公開API +sidebar: + order: 3 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +公開APIは、モジュールのグループ(スライスなど)とそれを使用するコードとの間の**契約**です。また、特定のオブジェクトへのアクセスを制限し、その公開APIを通じてのみアクセスを許可します。 + +実際には、通常、再エクスポートを伴うインデックスファイルとして実装されます。 + +```js title="pages/auth/index.js" +export { LoginPage } from "./ui/LoginPage"; +export { RegisterPage } from "./ui/RegisterPage"; +``` + +## 良い公開APIとは? + +良い公開APIは、他のコードとの統合を便利で信頼性の高いものにします。これを達成するためには、以下の3つの目標を設定することが重要です。 + +1. アプリケーションの残りの部分は、スライスの構造的変更(リファクタリングなど)から保護されるべきです。 +2. スライスの動作における重要な変更が以前の期待を破る場合、公開APIに変更が必要です。 +3. スライスの必要な部分のみを公開するべきです。 + +最後の目標には重要な実践的な意味があります。特にスライスの初期開発段階では、すべてをワイルドカードで再エクスポートしたくなるかもしれません。なぜなら、ファイルからエクスポートする新しいオブジェクトは、スライスからも自動的にエクスポートされるからです。 + + +```js title="バッドプラクティス, features/comments/index.js" +// ❌ これは悪いコードの例です。このようにしないでください。 +export * from "./ui/Comment"; // 👎 +export * from "./model/comments"; // 💩 +``` + +これは、スライスの理解可能性を損ないます。インターフェースが理解できないと、スライスのコードを深く掘り下げて統合方法を調べなければいけなくなってしまいます。もう一つの問題は、モジュールの内部を誤って公開してしまう可能性があり、誰かがそれに依存し始めるとリファクタリングが難しくなることです。 + +## クロスインポートのための公開API \{#public-api-for-cross-imports} + +クロスインポートは、同じレイヤーの別のスライスからインポートする状況です。通常、これは[レイヤーに関するインポートルール][import-rule-on-layers]によって禁止されていますが、しばしば正当な理由でクロスインポートが必要です。たとえば、ビジネスエンティティは現実世界で互いに参照し合うことが多く、これらの関係をコードに反映させるのが最善です。 + +この目的のために、`@x`表記として知られる特別な種類の公開APIがあります。エンティティAとBがあり、エンティティBがエンティティAからインポートする必要がある場合、エンティティAはエンティティB専用の別の公開APIを宣言できます。 + + +- entities/ + - A/ + - @x/ + - B.ts エンティティB内のコード専用の特別な公開API + - index.ts 通常の公開API + + +その後、`entities/B/`内のコードは`entities/A/@x/B`からインポートできます。 + +```ts +import type { EntityA } from "entities/A/@x/B"; +``` + + +`A/@x/B`という表記は「AとBが交差している」と読むことを意図しています。 + +:::note + +クロスインポートは最小限に抑え、**この表記はエンティティレイヤーでのみ使用してください**。クロスインポートを排除することがしばしば非現実的だからです。 + +::: + +## インデックスファイルの問題 + +`index.js`のようなインデックスファイル(Barrelファイルとも呼ばれる)は、公開APIを定義する最も一般的な方法です。作成は簡単ですが、特定のバンドラーやフレームワークで問題を引き起こすことがあります。 + +### 循環インポート + +循環インポートとは、2つ以上のファイルが互いに循環的にインポートすることです。 + +
+ 三つのファイルが循環的にインポートしている + 三つのファイルが循環的にインポートしている +
+ 上の図には、`fileA.js`、`fileB.js`、`fileC.js`の三つのファイルが循環的にインポートしている様子が示されています。 +
+
+ +これらの状況は、バンドラーにとって扱いが難しく、場合によってはデバッグが難しいランタイムエラーを引き起こすことさえあります。 + +循環インポートはインデックスファイルなしでも発生する可能性がありますが、インデックスファイルがあると、循環インポートを誤って作成する明確な機会が生まれます。これは、公開APIのスライスに2つのオブジェクト(例えば、`HomePage`と`loadUserStatistics`)が存在し、`HomePage`が`loadUserStatistics`に以下のようにアクセスすると循環インポートが発生してしまいます。 + + +```jsx title="pages/home/ui/HomePage.jsx" +import { loadUserStatistics } from "../"; // pages/home/index.jsからインポート + +export function HomePage() { /* … */ } +``` + +```js title="pages/home/index.js" +export { HomePage } from "./ui/HomePage"; +export { loadUserStatistics } from "./api/loadUserStatistics"; +``` + + +この状況は循環インポートを作成します。なぜなら、`index.js`が`ui/HomePage.jsx`をインポートしますが、`ui/HomePage.jsx`が`index.js`をインポートするからです。 + +この問題を防ぐために、次の2つの原則を考慮してください。 +- ファイルが同じスライス内にある場合は、常に**相対インポート**を使用し、完全なインポートパスを記述すること +- ファイルが異なるスライスにある場合は、常に**絶対インポート**を使用すること(エイリアスなどで) + +### Sharedにおける大きなバンドルサイズと壊れたツリーシェイキング \{#large-bundles} + +インデックスファイルがすべてを再エクスポートする場合、いくつかのバンドラーはツリーシェイキング(インポートされていないコードを削除すること)に苦労するかもしれません。 + +通常、これは公開APIにとって問題ではありません。なぜなら、モジュールの内容は通常非常に密接に関連しているため、1つのものをインポートし、他のものをツリーシェイキングする必要がほとんどないからです。しかし、公開APIのルールが問題を引き起こす非常に一般的なケースが2つあります。それは`shared/ui`と`shared/lib`です。 + +これらの2つのフォルダーは、しばしば一度にすべてが必要ではない無関係なもののコレクションです。たとえば、`shared/ui`にはUIライブラリのすべてのコンポーネントのモジュールが含まれているかもしれません。 + + +- shared/ui/ + - button/ + - text-field/ + - carousel/ + - accordion/ + + +この問題は、これらのモジュールの1つが重い依存関係(シンタックスハイライトやドラッグ&ドロップライブラリ)を持っている場合、悪化します。ボタンなど、`shared/ui`から何かを使用するすべてのページにそれらを引き込むことは望ましくありません。 + +`shared/ui`や`shared/lib`の単一の公開APIによってバンドルサイズが不適切に増加する場合は、各コンポーネントやライブラリのために別々のインデックスファイルを持つことをお勧めします。 + + +- shared/ + - ui/ + - button/ + - index.js + - text-field/ + - index.js + + +その後、これらのコンポーネントの消費者は、次のように直接インポートできます。 + +```js title="pages/sign-in/ui/SignInPage.jsx" +import { Button } from '@/shared/ui/button'; +import { TextField } from '@/shared/ui/text-field'; +``` + + +### 公開APIを回避することに対する実質的な保護がない + +スライスのためにインデックスファイルを作成しても、誰もそれを使用せず、直接インポートを使用することができます。これは特に自動インポートにおいて問題です。なぜなら、オブジェクトをインポートできる場所がいくつかあるため、IDEがあなたのために決定しなければならないからです。時には、直接インポートを選択し、スライスの公開APIルールを破ることがあります。 + +これらの問題を自動的にキャッチするために、[Steiger][ext-steiger]を使用することをお勧めします。これは、Feature-Sliced Designのルールセットを持つアーキテクチャリンターです。 + +### 大規模プロジェクトにおけるバンドラーのパフォーマンスの低下 + +プロジェクト内に大量のインデックスファイルがあると、開発サーバーが遅くなる可能性があります。これは、TkDodoが[「Barrelファイルの使用をやめてください」][ext-please-stop-using-barrel-files]という記事で指摘しています。 + +この問題に対処するためにできることはいくつかあります。 + +1. [「Sharedにおける大きなバンドルサイズと壊れたツリーシェイキング」](#large-bundles)のセクションと同じアドバイス — `shared/ui`や`shared/lib`の各コンポーネントやライブラリのために別々のインデックスファイルを持つこと。 +2. スライスを持つレイヤーのセグメントにインデックスファイルを持たないようにすること。 + たとえば、「コメント」フィーチャーのインデックス(`📄 features/comments/index.js`)がある場合、そのフィーチャーの`ui`セグメントのために別のインデックスを持つ理由はありません(`📄 features/comments/ui/index.js`)。 +3. 非常に大きなプロジェクトがある場合、アプリケーションをいくつかの大きなチャンクに分割できる可能性が高いです。 + たとえば、Google Docsは、ドキュメントエディターとファイルブラウザに非常に異なる責任を持っています。各パッケージが独自のレイヤーセットを持つ別々のFSDルートとしてモノレポを作成できます。いくつかのパッケージは、SharedとEntitiesレイヤーのみを持つことができ、他のパッケージはPagesとAppのみを持つことができ、他のパッケージは独自の小さなSharedを含むことができますが、他のパッケージからの大きなSharedも使用できます。 + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-please-stop-using-barrel-files]: https://tkdodo.eu/blog/please-stop-using-barrel-files diff --git a/src/content/docs/ja/docs/reference/slices-segments.mdx b/src/content/docs/ja/docs/reference/slices-segments.mdx new file mode 100644 index 0000000000..78bcbc744e --- /dev/null +++ b/src/content/docs/ja/docs/reference/slices-segments.mdx @@ -0,0 +1,69 @@ +--- +title: スライスとセグメント +sidebar: + order: 2 +--- + +## スライス + +スライスは、Feature-Sliced Designの組織階層の第2レベルです。主な目的は、プロダクト、ビジネス、または単にアプリケーションにとっての意味に基づいてコードをグループ化することです。 + +スライスの名前は標準化されていません。なぜなら、それらはアプリケーションのビジネス領域によって直接決定されるからです。たとえば、フォトギャラリーには `photo`、`effects`、`gallery-page` というスライスがあるかもしれません。SNSには、`post`、`comments`、`news-feed` などの別のスライスが必要です。 + +Shared層とApp層にはスライスが含まれていません。これは、Sharedがビジネスロジックを含むべきではないため、プロダクト的な意味を持たないからです。また、Appはアプリケーション全体に関わるコードのみを含むべきであり、したがって分割は必要ありません。 + +### 低結合と高凝集 \{#zero-coupling-high-cohesion} + +スライスは、独立した強く凝集しているコードファイルのグループとして設計されています。以下の図は、凝集(cohesion)と結合(coupling)といった複雑な概念を視覚化するのに役立ちます。 + +
+ + +
+ この図は、https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/ に触発されています。 +
+
+ +理想的なスライスは、同じレベルの他のスライスから独立しており(低結合)、その主な目的に関連するコードの大部分を含んでいます(高凝集)。 + +スライスの独立性は、[層のインポートルール][layers--import-rule]によって保証されます。 + +> _スライス内のモジュール(ファイル)は、厳密に下の層にあるスライスのみをインポートできます。_ + +### スライスの公開APIルール + +スライス内では、コードは自由に整理できますが、スライスが質の高い公開APIを持っている限り、問題はありません。これがスライスの公開APIのルールの本質です。 + +> _各スライス(およびスライスを持たない層のセグメント)は、公開APIの定義を含む必要があります。_ +> +> _あるスライス/セグメントの外部モジュールは、そのスライス/セグメントの内部ファイル構造ではなく、公開APIのみを参照できます。_ + +公開APIの要求の理由や、作成のベストプラクティスについては、[公開APIのガイド][ref-public-api]を参照してください。 + +### スライスのグループ + +密接に関連するスライスは、フォルダに構造的にグループ化できますが、他のスライスと同じ隔離ルールを遵守する必要があります。グループ化用のフォルダ内でのコードの共有は許可されていません。 + +![「compose」、「like」、「delete」機能が「post」フォルダにグループ化されています。このフォルダには、禁止を示すために取り消し線が引かれた「some-shared-code.ts」ファイルもあります。](/img/graphic-nested-slices.svg) + +## セグメント + +セグメントは、組織階層の第3および最後のレベルであり、その目的は、技術的な目的に基づいてコードをグループ化することです。 + +いくつかの標準化されたセグメント名があります。 + +- `ui` — UIに関連するすべてのもの:UIコンポーネント、日付フォーマッタ、スタイルなど。 +- `api` — バックエンドとのインタラクション:リクエスト関数、データ型、マッパーなど。 +- `model` — データモデル:スキーマ、インターフェース、ストレージ、ビジネスロジック。 +- `lib` — スライス内のモジュールに必要なライブラリコード。 +- `config` — 設定ファイルとフィーチャーフラグ。 + +[レイヤーに関するページ][layers--layer-definitions]には、これらのセグメントが異なる層でどのように使用されるかの例があります。 + +独自のセグメントを作成することもできます。カスタムセグメントの最も一般的な場所は、スライスが意味を持たないApp層とShared層です。 + +これらのセグメントの名前が、その内容が何のために必要かを説明するものであることを確認してください。たとえば、`components`、`hooks`、`types` は、コードを探すときにあまり役に立たないため、悪いセグメント名です。 + +[layers--layer-definitions]: /docs/reference/layers#layer-definitions +[layers--import-rule]: /docs/reference/layers#import-rule-on-layers +[ref-public-api]: /docs/reference/public-api diff --git a/src/content/docs/kr/docs/about/mission.mdx b/src/content/docs/kr/docs/about/mission.mdx new file mode 100644 index 0000000000..2f4df6694d --- /dev/null +++ b/src/content/docs/kr/docs/about/mission.mdx @@ -0,0 +1,66 @@ +--- +sidebar: + order: 1 +title: Mission +--- + +이 문서는 우리가 이 방법론을 설계할 때 어떤 **목표**를 가지고 있는지, +그리고 실제로 적용할 때 어떤 **한계**가 있는지를 설명합니다. + +이 방법론의 목표는 **이념적 완벽함과 실용적인 단순성 사이의 균형**을 맞추는 것입니다. +모든 사람과 모든 프로젝트에 100% 들어맞는 만능 해결책은 존재하지 않습니다. + +그럼에도 불구하고, 이 방법론은 **다양한 개발자들이 쉽게 접근할 수 있고, 실제 업무에서 충분히 쓸 만해야 합니다.** + +## 목표 + +### 다양한 개발자에게 직관적이고 명확하게 + +방법론은 프로젝트에 참여하는 **대부분의 팀원들이 쉽게 이해하고 사용할 수 있도록** 설계되어야 합니다. + +_새로운 도구나 개념이 추가되었을 때, 시니어나 리더급 개발자만 이해할 수 있다면 그 방법론은 충분하지 않습니다._ + +### 일상적인 문제 해결 + +방법론은 실제 개발 과정에서 자주 맞닥뜨리는 문제들에 대해 +**명확한 기준과 해결책**을 제시해야 합니다. + +이를 위해 **CLI, 린터(linter)** 같은 도구도 함께 제공하는 것이 중요합니다. + +이런 도구들을 통해 개발자들은 + +- 아키텍처 설계나 구현 과정에서 반복적으로 발생하는 문제를 줄이고, +- 이미 검증된 접근 방식을 자연스럽게 사용할 수 있습니다. + +> _@sergeysova: 방법론을 기반으로 코드를 작성하는 개발자는 +> 이미 많은 문제에 대한 “해법 세트”를 가지고 시작한다고 상상해 보세요. +> 문제 발생 빈도가 10배 정도 줄어든다고 생각할 수 있습니다._ + +## 한계 + +우리는 _특정 관점을 강요하지 않으려_ 하면서도, +동시에 *개발자로서 기존 습관이 오히려 문제 해결을 방해할 수 있다는 점*도 인지하고 있습니다. + +개발자마다 시스템 설계 경험과 개발 경력이 다르기 때문에, +아래 내용을 이해하는 것이 중요합니다. + +- **항상 통하지는 않음** + 단순하고 명확한 접근법이라고 해서 + 모든 상황, 모든 사람에게 항상 효과적이라고 볼 수는 없습니다. + + > _@sergeysova: 어떤 개념은 직접 문제를 겪고, + > 오랜 시간 고민하며 해결해 보는 과정을 거쳐야만 + > 비로소 직관적으로 이해할 수 있습니다._ + > + > - _수학: 그래프 이론_ + > - _물리학: 양자 역학_ + > - _프로그래밍: 애플리케이션 아키텍처_ + +- **가능하고 바람직한 방향** + 이 방법론은 **단순함**과 **확장 가능성**을 지향하는 방향으로 설계되었습니다. + +## 참고 자료 + +- [아키텍쳐 문제들][refs-architecture--problems] + +[refs-architecture--problems]: /docs/about/understanding/architecture#problems diff --git a/src/content/docs/kr/docs/about/motivation.mdx b/src/content/docs/kr/docs/about/motivation.mdx new file mode 100644 index 0000000000..b80c38bc9f --- /dev/null +++ b/src/content/docs/kr/docs/about/motivation.mdx @@ -0,0 +1,182 @@ +--- +title: Motivation +sidebar: + order: 2 +--- + +import { Aside } from '@astrojs/starlight/components'; + +**Feature-Sliced Design**은 [여러 개발자들의 연구와 경험을 결합해][ext-discussions] +복잡하고 점점 더 커지는 프로젝트를 더 단순하게 개발하고, 비용을 줄이려는 아이디어에서 출발했습니다. + +물론 이 방법론이 모든 문제를 해결하는 만능 열쇠는 아니며, [적용상의 한계][refs-mission]도 분명히 존재합니다. + +그럼에도 불구하고, _이 방법론이 제공하는 실질적인 효용성_ 때문에 많은 개발자들이 관심을 갖고 있습니다. + + + +## 기존 솔루션만으로 부족한 이유 + + +> 일반적으로 다음과 같은 반문들이 제기됩니다: +> +> - _"이미 `SOLID`, `KISS`, `YAGNI`, `DDD`, `GRASP`, `DRY` 같은 확립된 원칙들이 있는데, 왜 또 다른 방법론이 필요한가?"_ +> - _"문서화, 테스트, 구조화된 프로세스로 충분히 해결할 수 있지 않은가?"_ +> - _"모든 개발자가 위의 원칙을 제대로 따른다면 문제가 생기지 않았을 것이다."_ +> - _"이미 필요한 건 다 발명되었고, 당신이 잘 활용하지 못할 뿐이다."_ +> - _"프레임워크 X를 쓰면 된다. 거기에 다 들어있다."_ + +### 원칙만으로는 충분하지 않다 + +**좋은 아키텍처를 위해 “원칙이 존재한다”는 사실만으로는 충분하지 않습니다.** + +- 모든 개발자가 이러한 원칙을 깊이 이해하고, 상황에 맞게 올바르게 적용하는 것은 쉽지 않습니다. +- 설계 원칙은 어디까지나 일반적인 지침일 뿐, + **“확장 가능하고 유연한 애플리케이션 구조를 구체적으로 어떻게 설계할 것인가”** 에 대한 답은 직접 찾아야 합니다. + +### 프로세스가 항상 작동하지는 않는다 + +_문서화, 테스트, 프로세스_ 관리가 중요한 것은 맞지만, +여기에 많은 비용을 들인다고 해서 **아키텍처 문제나 신규 인력 온보딩 문제**가 자동으로 해결되는 것은 아닙니다. + +- 문서가 방대해지거나 오래되면, 새로운 개발자가 프로젝트에 빠르게 적응하는 데 큰 도움이 되지 않을 수 있습니다. +- 모든 구성원이 동일한 아키텍처 이해를 유지하고 있는지 계속 확인하려면, 그 자체로도 많은 리소스가 필요합니다. +- bus-factor 역시 잊지 말아야 할 중요한 리스크입니다. + +### 기존 프레임워크를 모든 상황에 적용할 수는 없다 + +- 많은 솔루션은 진입 장벽이 높아, 새로운 개발자를 프로젝트에 투입하기가 어렵습니다. +- 대부분의 경우 프로젝트 초기 단계에 기술 스택이 이미 정해지기 때문에, + **특정 기술에 종속되지 않고, 주어진 조건 안에서 유연하게 일할 수 있어야 합니다.** + +> Q: 내 프로젝트에서 `React/Vue/Redux/Effector/Mobx/{당신의_기술}`을 사용할 때, +> entities 구조와 관계를 어떻게 하면 더 잘 설계할 수 있을까요? + +### 결과적으로 + +각 프로젝트는 시간이 많이 들고, 다른 곳에 그대로 재사용하기도 힘든 +*눈송이처럼 독특한 구조*로 남기 쉬운 상황이 됩니다. + +> @sergeysova: _이것이 지금 프론트엔드 개발이 겪고 있는 문제입니다. +> 각 리드는 제각각의 아키텍처와 구조를 만들지만, +> 그것이 시간이 지나도 유지될지에 대해서는 보장할 수 없습니다. +> 결국 소수의 개발자만 프로젝트를 유지할 수 있고, +> 새로운 팀원이 합류할 때마다 긴 적응 기간이 필요해집니다._ + +## 개발자에게 왜 필요한가? + +### 아키텍처 고민을 줄이고 비즈니스 기능에 집중 + +이 방법론은 아키텍처 설계에 쏟는 고민을 줄여, +개발자가 **비즈니스 로직 구현에 더 집중**할 수 있도록 도와줍니다. + +또한 구조를 일정한 규칙 아래 표준화함으로써, +서로 다른 프로젝트 간에도 **일관된 구조**를 유지할 수 있게 합니다. + +_커뮤니티에서 신뢰를 얻으려면, 다른 개발자들이 이 방법론을 빠르게 익히고 +실제 프로젝트의 문제를 해결하는 데 활용할 수 있어야 합니다._ + +### 경험으로 입증된 솔루션 제공 + +이 방법론은 복잡한 비즈니스 로직을 다루면서 쌓인 **경험 기반의 해법**을 제공합니다. +또한 실제 사례와 best practices를 모아 놓은 집합체이기도 하므로, +개발자에게 “이런 상황에서는 이렇게 해도 된다”는 실질적인 가이드를 제공합니다. + +### 프로젝트의 장기적 건강성 유지 + +이 방법론을 사용하면, 많은 리소스를 들이지 않고도 +**기술 부채와 구조적 문제를 미리 감지하고 해결**할 수 있습니다. + +기술 부채는 시간이 지날수록 누적되며, +이를 관리하는 책임은 리드뿐 아니라 팀 전체에 있습니다. + +## 비즈니스에 왜 필요한가? + +### 빠른 온보딩 + +이 방법론에 익숙한 개발자를 프로젝트에 투입하면, +**추가 교육 없이도 빠르게 구조를 이해하고 작업을 시작**할 수 있습니다. + +그 결과: + +- 프로젝트 투입 속도가 빨라지고 +- 인력 교체나 확장에도 유연하게 대응할 수 있습니다. + +### 검증된 솔루션 제공 + +이 방법론은 비즈니스가 직면하는 **시스템 개발상의 문제**에 대해 +검증된 형태의 해결책을 제공합니다. + +대부분의 비즈니스는 개발 과정에서 발생하는 문제를 해결할 수 있는 +프레임워크나 아키텍처 솔루션을 필요로 합니다. +이 방법론은 그 중 하나의 선택지가 될 수 있습니다. + +### 프로젝트 전 단계에 적용 가능 + +이 방법론은 운영, 유지보수 단계뿐 아니라 **MVP 단계에서도** 도움이 됩니다. + +MVP의 직접적인 목표는 “장기 아키텍처”가 아니라 **실제 기능 제공**이지만, +방법론의 best practices를 일부라도 적용하면, 제한된 시간 안에서도 +**나중에 완전히 갈아엎지 않아도 되는 구조**에 가까운 타협점을 찾을 수 있습니다. + +_테스팅에도 비슷한 원리가 적용됩니다._ + +## 방법론이 필요하지 않은 경우 + +다음과 같은 경우에는 이 방법론이 꼭 필요하지 않을 수 있습니다. + +- 프로젝트 수명이 짧은 경우 +- 지속적인 아키텍처 관리가 필요 없는 경우 +- 비즈니스가 “코드 품질과 전달 속도 사이의 연관성”을 중요하게 보지 않는 경우 +- 사후 지원보다 **빠른 납품**이 더 우선인 경우 + +### 비즈니스 규모 + +- **소규모** → 지금 당장 사용할 수 있는 빠른 솔루션이 중요합니다. + 시간이 지나 성장하면서, 품질과 안정성에 대한 투자의 필요성을 점차 인식하게 됩니다. +- **중간 규모** → 기능 경쟁 속에서도 품질 개선, 리팩토링, 테스트 등에 투자하며 + **장기적으로 확장 가능한 아키텍처**를 중요하게 생각합니다. +- **대규모** → 이미 자체적인 아키텍처 접근 방식을 가지고 있을 가능성이 크며, + 외부 방법론을 새로 도입할 가능성은 상대적으로 낮습니다. + +## 계획 + +이 방법론의 주요 목표는 [여기][refs-mission--goals]에 정의되어 있으며, +앞으로 이 방법론이 어떤 방향으로 발전해야 할지에 대해서도 함께 고민하고 있습니다. + +### 경험 결합 + +현재 우리는 `core-team`의 다양한 경험을 모아 +**더 단단한 방법론**을 만드는 작업을 진행 중입니다. + +물론 그 결과가 어쩌면 Angular 3.0처럼 평가될 수도 있습니다. +하지만 중요한 것은, **복잡한 아키텍처 설계 문제를 진지하게 탐구하는 과정 자체**입니다. + +_커뮤니티 경험 또한 적극적으로 반영해, +최대한 많은 사람들이 납득할 수 있는 최적의 합의점을 찾는 것이 목표입니다._ + +### 사양을 넘어선 생명력 + +모든 것이 계획대로 진행된다면, 이 방법론은 단순히 +“사양과 툴킷” 수준에 머무르지 않을 것입니다. + +- 관련 발표나 보고서, 기사 +- 다른 기술 스택으로 마이그레이션할 수 있는 CODE_MODE +- 대규모 솔루션을 유지보수하는 개발자들에게 적용할 기회 + - _특히 React는 다른 프레임워크에 비해 구체적인 해결책을 거의 제공하지 않는다는 점이 문제입니다._ + +## 참고 자료 + +- [(토론) 방법론이 필요하지 않나요?][disc-src] +- [방법론의 목표와 한계][refs-mission] +- [프로젝트에서 다루는 지식의 유형][refs-knowledge] + +[refs-mission]: /docs/about/mission +[refs-mission--goals]: /docs/about/mission#goals +[refs-knowledge]: /docs/about/understanding/knowledge-types +[disc-src]: https://github.com/feature-sliced/documentation/discussions/27 +[ext-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/kr/docs/about/understanding/architecture.mdx b/src/content/docs/kr/docs/about/understanding/architecture.mdx new file mode 100644 index 0000000000..afdec6193d --- /dev/null +++ b/src/content/docs/kr/docs/about/understanding/architecture.mdx @@ -0,0 +1,108 @@ +--- +title: About architecture +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +## 문제점들 + +프론트엔드 아키텍처에 대한 논의는 보통 **프로젝트가 커지고**, +그로 인해 **개발 생산성이 눈에 띄게 떨어지거나 일정이 계속 지연될 때** 자연스럽게 시작됩니다. + +### Bus-factor & 온보딩 + +현재 프로젝트의 구조와 아키텍처를 **일부 기존 팀원만 제대로 이해하고 있는** 경우가 많습니다. +이런 상황에서는 새로운 팀원이 들어올 때마다 다음과 같은 문제가 생깁니다. + +**예시:** + +- 신규 팀원이 스스로 작업을 할 수 있을 정도로 익숙해지기까지 오랜 시간이 걸립니다. +- 명확한 아키텍처 설계 원칙이 없어, 개발자마다 제각기 다른 방식으로 문제를 해결합니다. +- 거대한 모놀리스 코드베이스에서 데이터 흐름을 추적하기 어렵습니다. + +### 암묵적인 부작용과 예측 불가능한 영향 + +개발이나 리팩터링 과정에서 **작은 수정이 생각보다 큰 범위에 영향을 주는** 일이 자주 발생합니다. +이는 모듈 간 의존성이 복잡하게 얽혀 있어, 한 부분을 고칠 때 다른 부분이 함께 깨지기 쉬운 구조 때문입니다. + +**예시:** + +- 기능 간에 불필요한 의존성이 점점 쌓입니다. +- 한 페이지의 상태(store)를 수정했는데, 전혀 다른 페이지 동작이 갑자기 이상해집니다. +- 비즈니스 로직이 여러 파일과 레이어에 흩어져 있어, 전체 흐름을 따라가기 어렵습니다. + +### 제어되지 않는 로직 재사용 + +기존에 있는 로직을 **적절히 재사용하거나 확장하기가 어렵다**는 것도 큰 문제입니다. +보통 [아래와 같은 두 가지 극단적인 상황](https://github.com/feature-sliced/documentation/discussions/14)이 나타납니다. + +- 재사용 가능한 코드가 분명히 있음에도, **매번 비슷한 모듈을 처음부터 새로 구현**합니다. +- 반대로, 거의 한 곳에서만 사용하는 코드까지 전부 `shared` 폴더로 옮겨져, + **실제로는 재사용되지 않는 “공용 모듈”이 계속 쌓입니다.** + +**예시:** + +- 동일한 계산/검증 로직이 여러 군데에서 **복붙**으로 반복 구현되어, + 수정할 때는 모든 위치를 하나씩 찾아 고쳐야 합니다. +- 버튼, 팝업 같은 컴포넌트가 스타일/동작이 약간씩 다른 여러 버전으로 중복 존재합니다. +- 유틸 함수들이 규칙 없이 `utils.ts`, `helpers.ts` 등에 계속 쌓여, + 어떤 함수가 있는지 찾기 어렵고 중복도 쉽게 생깁니다. + +## 요구사항 + +“이상적인” 아키텍처를 이야기할 때, 현실적인 관점에서 다음과 같은 요구사항을 생각해 볼 수 있습니다. + + + +### 명시성 + +- 프로젝트의 구조와 아키텍처를 **누구나 쉽게 이해하고, 다른 사람에게 설명할 수 있어야** 합니다. +- 아키텍처는 프로젝트의 **비즈니스 도메인과 핵심 가치**를 자연스럽게 드러내야 합니다. +- 각 계층(layer)과 모듈 간의 **의존 관계와 영향 범위**가 명확해야 합니다. +- **중복된 로직을 쉽게 찾아내고 제거**할 수 있어야 합니다. +- 중요한 비즈니스 로직이 프로젝트 전반에 **얇게 흩어져 있지 않도록** 관리해야 합니다. +- 규칙이나 추상화는 **필요 이상으로 복잡하지 않게**, 최소한으로 유지해야 합니다. + +### 제어 + +- 새로운 기능을 **빠르게 추가하고**, 발생한 문제를 **쉽게 찾아 해결**할 수 있어야 합니다. +- 프로젝트 전체의 개발 흐름을 **계획하고 조정할 수 있는 구조**여야 합니다. +- 코드가 **확장하기 쉽고, 유지보수하기 편하며, 필요할 때 제거도 수월**해야 합니다. +- 기능 단위(feature 단위)로 **경계와 격리 수준이 명확**해야 합니다. +- 컴포넌트나 모듈은 **교체/삭제하기 쉬운 형태**여야 합니다. + - [미래의 변경을 과하게 예측해 과도하게 최적화하는 설계는 지양합니다][ext-kof-not-modification] + - 앞으로 어떤 요구사항이 나올지는 정확히 알 수 없기 때문입니다. + - [대신 “삭제하기 쉬운 구조”를 더 중요하게 봅니다][ext-kof-but-removing] + - 지금 알고 있는 요구사항을 기준으로 의사결정하는 편이 더 실용적입니다. + +### 적응성 + +- **다양한 규모와 성격의 프로젝트**에 적용할 수 있어야 합니다. + - 기존 시스템 및 인프라와도 큰 충돌 없이 통합할 수 있어야 합니다. + - 프로젝트의 전 생애주기(초기, 운영, 확장 단계)에 걸쳐 일관되게 적용 가능해야 합니다. +- 특정 프레임워크나 기술 스택에 과도하게 묶여 있지 않아야 합니다. +- 여러 팀 혹은 여러 명의 개발자가 **병렬로 개발**하고, + 팀이 커져도 구조가 버티도록 설계되어야 합니다. +- **비즈니스 요구사항과 기술 환경이 바뀌더라도** 유연하게 대응할 수 있어야 합니다. + +## 참고 자료 + +- [(React Berlin Talk) Oleg Isonen - Feature Driven Architecture][ext-kof] +- [(React SPB Meetup #1) Sergey Sova - Feature Slices][ext-slices-spb] +- [(Article) 프로젝트 모듈화에 대하여][ext-medium] +- [(Article) 관점 분리와 기능 기반 구조화에 대하여][ext-ryanlanciaux] + +[ext-kof-not-modification]: https://youtu.be/BWAeYuWFHhs?t=1631 +[ext-kof-but-removing]: https://youtu.be/BWAeYuWFHhs?t=1666 +[ext-slices-spb]: https://t.me/feature_slices +[ext-kof]: https://youtu.be/BWAeYuWFHhs +[ext-medium]: https://alexmngn.medium.com/why-react-developers-should-modularize-their-applications-d26d381854c1 +[ext-ryanlanciaux]: https://ryanlanciaux.com/blog/2017/08/20/a-feature-based-approach-to-react-development/ diff --git a/src/content/docs/kr/docs/about/understanding/knowledge-types.mdx b/src/content/docs/kr/docs/about/understanding/knowledge-types.mdx new file mode 100644 index 0000000000..2774b95fd3 --- /dev/null +++ b/src/content/docs/kr/docs/about/understanding/knowledge-types.mdx @@ -0,0 +1,51 @@ +--- +title: Knowledge types +sidebar: + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + +소프트웨어 프로젝트를 개발할 때 다루게 되는 지식은 크게 다음 **세 가지 유형**으로 나눌 수 있습니다. + +### 기반 지식 (Fundamental Knowledge) + +프로그래밍의 기초가 되는 지식으로, 시간이 지나도 거의 변하지 않습니다. + +- 알고리즘과 자료구조 +- 컴퓨터 과학의 핵심 개념 +- 프로그래밍 언어의 기본 원리와 핵심 API + +### 기술 스택 (Technical Stack) + +프로젝트를 실제로 개발할 때 사용하는 **도구들에 대한 지식**입니다. + +- 프로그래밍 언어와 프레임워크 +- 라이브러리와 개발 도구 +- (선택적으로) 개발 환경, 배포 도구, CI/CD 설정 등 + +### 프로젝트 도메인 지식 (Project Knowledge) + +특정 프로젝트에만 존재하는 **고유한 지식**입니다. + +- 비즈니스 로직과 규칙 +- 해당 프로젝트만의 아키텍처 결정 사항 +- 팀 내 개발 규칙과 관례 + +이러한 지식은 **다른 프로젝트에서는 재사용 가치가 상대적으로 낮지만**, +**새로운 팀원이 이 프로젝트에 기여하기 위해서는 반드시 필요한 정보**입니다. + + + +## 참고 자료 \{#see-also} + +- [(영상 🇷🇺) Ilya Klimov - 지식 유형에 관하여][ext-klimov] + +[ext-klimov]: https://youtu.be/4xyb_tA-uw0?t=249 diff --git a/src/content/docs/kr/docs/about/understanding/naming.mdx b/src/content/docs/kr/docs/about/understanding/naming.mdx new file mode 100644 index 0000000000..4fe57f898c --- /dev/null +++ b/src/content/docs/kr/docs/about/understanding/naming.mdx @@ -0,0 +1,88 @@ +--- +title: Naming +sidebar: + order: 4 +--- + +개발자들은 각자의 경험과 관점에 따라 **같은 개념을 서로 다른 이름으로 부르는 경우**가 많습니다. +이런 차이는 팀 내에서 혼동을 만들고, 코드 이해 속도를 떨어뜨릴 수 있습니다. 예를 들어: + +- UI 컴포넌트를 `ui`, `components`, `ui-kit`, `views` 등으로 부르는 경우 +- 공통 코드를 `core`, `shared`, `app` 등으로 지칭하는 경우 +- 비즈니스 로직을 `store`, `model`, `state` 등으로 명명하는 경우 + +이 문서에서는 Feature-Sliced Design(FSD)에서 사용하는 **표준 네이밍 규칙**을 정리하고, +프로젝트 내 다른 용어들과 충돌할 때 어떻게 다루면 좋을지 설명합니다. + +## Feature-Sliced Design의 표준 네이밍 \{#naming-in-fsd} + +FSD는 layer와 segment에 대해 다음과 같은 **공통된 네이밍 규칙**을 사용합니다. + +### Layers + +- `app` +- `processes` +- `pages` +- `features` +- `entities` +- `shared` + +### Segments + +- `ui` +- `model` +- `lib` +- `api` +- `config` + +이러한 표준 용어를 프로젝트 전반에서 통일해 사용하는 것은 매우 중요합니다. + +- 팀 내 의사소통이 더 명확해집니다. +- 새로운 팀원이 구조를 이해하고 적응하는 속도가 빨라집니다. +- 커뮤니티에 도움을 요청하거나, 다른 프로젝트와 경험을 공유할 때도 원활한 소통이 가능합니다. + +## 네이밍 충돌 해결 \{#when-can-naming-interfere} + +FSD에서 사용하는 용어가 **프로젝트의 비즈니스 용어와 겹치는 경우**가 있을 수 있습니다. 예를 들어: + +- `FSD#process` vs 애플리케이션의 “시뮬레이션 프로세스” +- `FSD#page` vs “로그 페이지” +- `FSD#model` vs “자동차 모델” + +이런 상황에서는, 개발자가 코드에서 `process`, `page`, `model` 같은 단어를 보았을 때 +**지금 이게 FSD의 용어인지, 비즈니스 도메인 용어인지**를 먼저 구분해야 하므로 +짧게나마 해석 비용이 추가됩니다. 이런 **용어 충돌은 개발 효율을 떨어뜨릴 수 있습니다.** + +따라서 프로젝트 용어집(glossary)에 FSD 특유의 용어가 포함되어 있다면, +팀원뿐 아니라 **비기술적 이해관계자(기획, 디자이너 등)** 와 이야기할 때도 +이 용어가 어떤 의미인지 혼동되지 않도록 특히 신경 써야 합니다. + +### 용어 사용 가이드 + +1. **기술적 커뮤니케이션** + - 개발자끼리 FSD 관점에서 이야기할 때는, + 가능한 한 **FSD 용어라는 것을 분명히 드러내며** 사용하는 것을 권장합니다. + - 예: + > “이 기능은 FSD `features` layer로 올리는 게 좋겠습니다.” + > “이 부분은 `entities`로 분리하는 쪽이 구조상 더 자연스러워 보여요.” + +2. **비기술적 커뮤니케이션** + - 비개발자나 비즈니스 이해관계자와의 대화에서는 + 가능한 한 FSD 관련 용어 사용을 줄이고, **일반적인 비즈니스 언어**를 사용하는 편이 좋습니다. + - 예: + - “코드 구조” 대신 “기능 단위로 나누어 개발하고 있습니다.” + - “`entities` layer” 대신 “사용자/상품 같은 핵심 데이터 단위” 등으로 풀어서 설명 + +## 참고 자료 \{#see-also} + +FSD 네이밍과 관련된 더 깊은 논의는 아래 토론 스레드들을 참고하세요. + +- [(토론) Naming의 적응성][disc-src] +- [(토론) Entities Naming 설문조사][disc-naming] +- [(토론) "processes" vs "flows" vs ...][disc-processes] +- [(토론) "model" vs "store" vs ...][disc-model] + +[disc-model]: https://github.com/feature-sliced/documentation/discussions/68 +[disc-naming]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-464894 +[disc-processes]: https://github.com/feature-sliced/documentation/discussions/20 +[disc-src]: https://github.com/feature-sliced/documentation/discussions/16 diff --git a/src/content/docs/kr/docs/about/understanding/needs-driven.mdx b/src/content/docs/kr/docs/about/understanding/needs-driven.mdx new file mode 100644 index 0000000000..6593959477 --- /dev/null +++ b/src/content/docs/kr/docs/about/understanding/needs-driven.mdx @@ -0,0 +1,186 @@ +--- +title: Needs driven +sidebar: + order: 2 +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +## 왜 이런 접근이 필요한가? + +각 Entity의 이름과 구조를 명확히 하려면, +먼저 **그 코드가 어떤 목적의 문제를 해결하려고 하는지 정확히 이해**해야 합니다. + +> _@sergeysova: 개발할 때 Entity와 함수 이름에는 반드시 그 의도를 반영하려고 합니다._ + +작업이 불명확하면 테스트를 작성하기 어렵고, 에러 처리가 비효율적이 되며, +결국 사용자 경험에도 나쁜 영향을 줍니다. + +## 우리가 말하는 작업이란? + +프론트엔드는 사용자가 가진 문제를 해결하고, +그들의 요구를 만족시키기 위한 인터페이스를 제공합니다. + +사용자는 서비스 안에서 **자신의 필요를 해결하거나, 어떤 목표를 달성하기 위해** 행동합니다. + +관리자와 분석가는 이러한 “사용자의 작업”을 명확하게 정의하고, +개발자는 네트워크 지연, 에러, 사용자 실수 같은 현실적인 환경을 고려해 이를 구현합니다. + +**정리하면, 사용자의 목표가 곧 개발자가 수행해야 할 작업입니다.** + +> _Feature-Sliced Design의 핵심 철학 중 하나는 +> “프로젝트의 전체 작업을 더 작은 목표 단위로 나누는 것”입니다._ + +## 개발에 어떤 영향을 주는가? + +### 작업 분해 + +개발자는 유지보수성과 확장성을 위해, 큰 작업을 점진적으로 잘게 나눕니다. + +- 최상위 수준의 Entity로 먼저 나누기 +- 필요에 따라 더 작은 단위로 세분화하기 +- 각 Entity에 역할이 드러나는 명확한 이름 부여하기 + +> **모든 Entity는 사용자의 문제 해결에 직접적으로 기여해야 합니다.** + +### 작업의 본질 이해 + +Entity의 이름을 정하려면, +해당 Entity의 **목적과 역할을 충분히 이해**해야 합니다. + +- 이 Entity가 정확히 어떤 상황에서 사용되는지 +- 어떤 사용자 작업 범위를 구현하는지 +- 다른 작업/Entity와 어떤 연관성이 있는지 + +결국, **이름을 고민하는 과정에서 애초에 모호했던 작업 자체를 발견해낼 수 있습니다.** + +> Entity의 이름을 정의하려면, +> 먼저 그 Entity가 해결할 “작업”이 무엇인지 명확히 이해해야 합니다. + +## 어떻게 정의할 것인가? + +**Feature가 해결하려는 작업을 정의하려면, +그 작업의 본질을 먼저 파악해야 합니다.** + +이 역할은 주로 프로젝트 관리자와 분석가가 담당합니다. + +_방법론은 그 위에서 **개발자에게 구체적인 방향을 제시**할 뿐입니다._ + +> _@sergeysova: 프론트엔드는 단순히 “무언가를 화면에 보여주는 것”이 아닙니다. +> “왜 이걸 보여줘야 하는가?”를 스스로 묻고, 그 안에서 사용자의 실제 필요를 이해해야 합니다._ + +사용자의 필요를 제대로 이해하면, +**제품이 사용자의 목표 달성에 어떻게 도움을 주는지 더 구체적으로 설계**할 수 있습니다. + +모든 새로운 작업은 비즈니스와 사용자의 문제를 동시에 다뤄야 하며, +분명한 목적을 가져야 합니다. + +_**개발자는 자신이 맡은 작업의 목표를 분명히 이해해야 합니다.** +완벽한 프로세스가 없더라도, 관리/기획 담당자와의 커뮤니케이션을 통해 목표를 파악하고 +이를 코드로 효과적으로 구현할 수 있어야 합니다._ + +## 이점 + +이런 전체적인 Process를 거쳤을 때 얻을 수 있는 이점을 정리해 봅니다. + +### 1. 사용자 작업 이해 + +사용자의 문제와 비즈니스 요구를 충분히 이해하면, +기술적인 제약 안에서도 **더 나은 해결 방식**을 제안할 수 있습니다. + +> _이 모든 것은 개발자가 자신의 역할과 목표에 +> “얼마나 적극적으로 관심을 가지는지”에 달려 있습니다. +> 그렇지 않다면, 어떤 방법론도 큰 의미를 가지지 못합니다._ + +### 2. 구조화와 체계화 + +작업을 제대로 이해하고 나면, +**사고 과정이 자연스럽게 정리되고, 코드 구조도 함께 체계화**됩니다. + +### 3. 기능과 그 구성 요소 이해 + +각 Feature는 사용자에게 **명확한 가치를 제공**해야 합니다. +여러 기능이 한 Feature 안에 뒤섞여 있으면 **경계가 흐려집니다.** +Feature는 **분리**와 **확장**이 가능한 단위여야 합니다. + +핵심 질문은 항상 이것입니다: + **“이 Feature가 사용자에게 어떤 가치를 주는가?”** + +- 예시: + - ❌ `지도-사무실` (무엇을 하는지 모호함) + - ⭕ `회의실-예약`, `직원-검색`, `근무지-변경` (기능이 명확하게 드러남) + +> @sergeysova: Feature는 해당 기능의 **핵심 구현 코드만** 포함해야 합니다. +> 관련 없는 코드는 과감히 제외하고, 이 Feature에 꼭 필요한 로직만 담는 것이 좋습니다. + +### 4. 유지보수성 + +비즈니스 로직이 코드에 잘 드러나 있으면, +**장기적인 유지보수성이 크게 향상**됩니다. + +_새로운 팀원이 합류하더라도, +코드를 읽는 것만으로 “무엇을, 왜 구현했는지” 이해할 수 있게 됩니다._ + +> (도메인 주도 설계에서 말하는 [비즈니스 언어][ext-ubiq-lang] 개념과도 유사합니다.) + +--- + +## 현실적 고려사항 + +비즈니스 프로세스와 설계가 처음부터 잘 정리되어 있다면, +구현 자체는 그리 어렵지 않습니다. + +하지만 실제로는 **충분한 설계 없이 Feature가 계속 추가**되는 경우가 많습니다. + +그 결과, 지금 당장 보기에는 적절해 보이던 Feature가 +한 달 뒤 새로운 요구사항이 들어왔을 때 **전체 구조를 뒤흔드는 원인**이 되기도 합니다. + +> [토론][disc-src]: 개발자는 보통 “2~3단계 앞”을 내다보며 설계를 하지만, +> 그 한계는 경험에 따라 달라집니다. +> 숙련된 개발자는 “최대 10단계 앞”까지 예상하여 +> Feature를 나누고 합치는 결정을 더 잘할 수 있습니다. +> +> 그럼에도 불구하고, 때때로 경험으로도 해결하기 어려운 복잡한 상황이 생기며, +> 이때는 문제를 **최소한의 크기로 쪼개는 것**이 중요합니다. + +## 방법론의 역할 + +이 방법론의 목적은 **개발자가 사용자의 문제를 더 효과적으로 해결하도록 돕는 것**입니다. + +즉, 이 방법론은 단지 “코드를 어떻게 나눌 것인가”에 대한 규칙이 아니라, +**사용자의 필요를 이해하고, 그것을 코드 구조에 반영하는 도구**입니다. + +### 방법론 요구 사항 + +**Feature-Sliced Design**은 최소한 다음 두 가지 요구를 충족해야 합니다. + +#### 1. **Feature, Process, Entity를 구성하는 명확한 방법 제공** + +- 코드 분할 기준과 명명 규칙 정의 + +#### 2. **[변화하는 요구사항에 유연한 아키텍처 제공][refs-arch--adaptability]** + +## 참고 자료 + +- [(포스트) 명확한 작업 정의 가이드 (+ 토론)][disc-src] + > _**이 문서는 해당 토론을 기반으로 작성**되었습니다. + > 자세한 내용은 원문 링크를 참고하세요._ +- [(토론) Feature 분해 방법론][tg-src] +- [(아티클) "효과적인 애플리케이션 구조화"][ext-medium] + +[refs-arch--adaptability]: architecture#adaptability +[ext-medium]: https://alexmngn.medium.com/how-to-better-organize-your-react-applications-2fd3ea1920f1 +[disc-src]: https://t.me/sergeysova/318 +[tg-src]: https://t.me/atomicdesign/18972 +[ext-ubiq-lang]: https://thedomaindrivendesign.io/developing-the-ubiquitous-language diff --git a/src/content/docs/kr/docs/get-started/faq.mdx b/src/content/docs/kr/docs/get-started/faq.mdx new file mode 100644 index 0000000000..bac22423fa --- /dev/null +++ b/src/content/docs/kr/docs/get-started/faq.mdx @@ -0,0 +1,67 @@ +--- +title: FAQ +sidebar: + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +### Toolkit이나 Linter가 있나요? + +프로젝트 구조가 FSD 규칙에 맞는지 점검하는 **[Steiger Linter][ext-steiger]** 가 있습니다. +또한 CLI나 IDE 확장을 통해 사용할 수 있는 **[FSD 구조 생성 도구][ext-tools]** 도 제공합니다. + +### Page Layout / Template은 어디에 보관해야 하나요? + +단순한 마크업이라면 `shared/ui`에 두는 것이 일반적입니다. +레이아웃이 간단하다면 **별도 추상화 없이 각 페이지에 직접 작성해도 됩니다.** +복잡한 구조라면 별도 **Widget**이나 **Page**로 분리해 App Router(Nested Routing 포함)에서 조합하세요. + +### Feature와 Entity의 차이는 무엇인가요? +| 구분 | 정의 | 예시 | +| --- | --- | --- | +| **Entity** | 애플리케이션이 다루는 **비즈니스 개체** | `user`, `product` | +| **Feature** | 사용자가 Entity로 수행하는 **상호작용** | 로그인, 장바구니 담기 | + +더 자세한 설명과 코드 예시는 [Slices][reference-entities] 문서에서 확인할 수 있습니다. + +### Pages, Features, Entities를 서로 포함할 수 있나요? +가능합니다. 다만 **상위 Layer**에서만 조합해야 합니다. +예를 들어, Widget 내부에서는 여러 Feature를 **props**나 **children** 형태로 조합할 수 있습니다. +하지만 한 Feature가 다른 Feature를 직접 import 하는 것은 [**Layer Import 규칙**][import-rule-layers]에 따라 금지됩니다. + +### Atomic Design을 함께 사용할 수 있나요? + +궁금하다면 [예시](https://t.me/feature_sliced/1653)를 참고하세요. +FSD는 Atomic Design 사용을 **제한하지 않습니다.** +필요하다면 `ui` Segment 안에서 Atomic 분류를 적용할 수 있습니다. + +### FSD 관련 참고 자료가 더 있나요? +더 다양한 예제와 자료는 [feature-sliced/awesome](https://github.com/feature-sliced/awesome)에서 확인할 수 있습니다. + +### Feature-Sliced Design이 필요한 이유는 무엇인가요? +FSD는 프로젝트를 **핵심 기능 단위로 명확하게 구조화**할 수 있도록 돕습니다. +표준화된 구조는 온보딩 속도를 높이고, 폴더 구조에 대한 불필요한 논쟁을 줄여 줍니다. +자세한 배경은 [Motivation][motivation] 페이지를 참고하세요. + +### 주니어 개발자도 아키텍처 방법론이 필요할까요? + +필요합니다. 혼자 개발할 때는 구조의 중요성이 잘 느껴지지 않지만, +새로운 팀원이 합류하거나 개발이 일시적으로 중단되더라도, **명확한 구조 덕분에 프로젝트를 쉽게 이어갈 수 있습니다.** + +### 인증(Auth) Context는 어떻게 다루나요? + +관련 예시는 [Auth 예제 가이드](/docs/guides/examples/auth)에서 확인할 수 있습니다. + +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[import-rule-layers]: /docs/reference/layers#import-rule-on-layers +[reference-entities]: /docs/reference/layers#entities +[motivation]: /docs/about/motivation +[telegram]: https://t.me/feature_sliced +[discord]: https://discord.gg/S8MzWTUsmp +[github-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/kr/docs/get-started/overview.mdx b/src/content/docs/kr/docs/get-started/overview.mdx new file mode 100644 index 0000000000..1049127a71 --- /dev/null +++ b/src/content/docs/kr/docs/get-started/overview.mdx @@ -0,0 +1,181 @@ +--- +title: Overview +sidebar: + order: 1 +--- + +import { Aside, FileTree } from '@astrojs/starlight/components'; + +Feature-Sliced Design (FSD) 는 프론트엔드 애플리케이션의 코드를 구조화하기 위한 아키텍처 방법론입니다. +이 방법론의 목적은 **요구사항이 바뀌어도 코드 구조가 무너지지 않고, 새 기능을 쉽게 추가할 수 있는 프로젝트를 만드는 것**입니다. +FSD는 코드를 **얼마나 많은 책임을 가지는지**와 **다른 모듈에 얼마나 의존하는지**에 따라 계층화합니다. + +FSD는 단순한 폴더 규칙이 아닙니다. +실제 개발 환경에서 구조를 설계하고 유지하기 위한 도구도 함께 제공합니다. + +- [Steiger][ext-steiger] — 프로젝트 구조가 FSD 기준에 맞는지 검사합니다. +- [Awesome][ext-tools] — FSD 예제와 도구를 모아둔 참고 리스트입니다. +- [예제 모음][examples] — 다양한 프로젝트에서 사용된 폴더 구조 예시를 볼 수 있습니다. + +## 내 프로젝트에 적합할까요? \{#is-it-right-for-me} + +FSD는 웹, 모바일, 데스크톱 등 **프론트엔드 애플리케이션을 만드는 프로젝트에 잘 어울립니다.** +단순한 라이브러리보다는 **애플리케이션**에 더 적합합니다. + +그리고 특정 언어나 프레임워크에 제한이 없고, Monorepo 환경에서도 단계적으로 적용할 수 있습니다. + +> 지금 구조에 특별한 문제가 없다면 굳이 바꿀 필요는 없습니다. +> 하지만 다음과 같은 상황이라면 FSD가 도움이 될 수 있습니다: +> - 프로젝트가 커지면서 구조가 얽히고, 유지보수 속도가 느려졌을 때 +> - 새로 합류한 팀원이 폴더 구조를 이해하기 힘들어할 때 + +다만 모든 프로젝트가 FSD에 꼭 맞는 것은 아닙니다. +예시로 각 페이지가 독립적인 특성을 가진 프로젝트에서는 오히려 구조가 복잡해질 수 있습니다. +따라서 도입 전에는 **파일럿 프로젝트로 먼저 검증해보는 것**을 적극 추천합니다. + +구조를 전환하기로 했다면 [Migration 가이드][migration]를 참고하세요. + +## 구조 예시 \{#basic-example} + +간단한 FSD 구조는 다음과 같습니다: + + +- app/ +- pages/ +- shared/ + + +이 상위 폴더들이 **Layer**입니다. +Layer는 표준화된 이름을 가지며, 각각 명확한 역할을 담당합니다. + + +- app/ + - routes/ + - analytics/ +- pages/ + - home/ + - article-reader/ + - ui/ + - api/ + - settings/ +- shared/ + - ui/ + - api/ + + +📂 pages 내부의 *home*, *article-reader*, *settings*는 **Slice**입니다. +Slice는 비즈니스 도메인(이 예시에서는 각 페이지) 단위로 코드를 구분합니다. + +각 Slice 안에는 ui, api, model 등의 **Segment**가 있습니다. +Segment는 코드의 역할이나 기능에 따라 분류됩니다. + +- **ui** - UI Components +- **api** - REST/GraphQL Client, Fetchers +- **model** - State, Types, Selectors + +예를 들어 UI 구성 요소, 서버 연동 등이 이에 해당합니다. +동일한 구조는 app과 shared Layer에도 적용할 수 있습니다. + +## 개념 \{#concepts} + +FSD는 다음과 같은 3단계 계층 구조를 따릅니다: + +
+ ![아래에 설명된 FSD 개념의 계층 구조](../../../../../../static/img/visual_schema.jpg) + +
+

+ 위 다이어그램은 FSD의 계층 구조를 시각적으로 보여줍니다.
+ 세 개의 수직 블록 그룹은 각각 Layer, Slice, Segment를 나타냅니다. +

+

+ 왼쪽의 Layer 블록에는 app, processes, pages, widgets, + features, entities, shared가 포함됩니다. +

+

+ 예를 들어, entities Layer 안에는 여러 개의 Slice가 존재하며, + 예시로는 user, post, comment 등이 있습니다. +

+

+ Slice는 비즈니스 도메인별(user, post, comment)로 나뉘며, 각 Slice 안의 Segment들은 코드의 역할(예: UI, 데이터, 상태) 에 따라 구성됩니다.
+ 예시로 post Slice에는 ui, model, api Segment가 포함됩니다. +

+
+
+ +### Layer \{#layers} + +Layer는 모든 FSD 프로젝트의 표준 최상위 폴더입니다. + +1. **App** - Routing, Entrypoint, Global Styles, Provider 등 앱을 실행하는 모든 요소 +2. **Processes** - 더 이상 사용되지 않음 +3. **Pages** - Route 기준으로 구성된 주요 화면 단위 +4. **Widgets** - 크고 독립적으로 동작하는 UI 구성 단위, 일반적으로 하나의 완결된 화면 기능(use case)을 제공합니다. +5. **Features** - 사용자에게 비즈니스 가치를 제공하는 액션을 구현한 재사용 가능한 제품 기능 단위 +6. **Entities** - 프로젝트가 다루는 비즈니스 Entity +7. **Shared** - 모든 Layer에서 재사용되는 코드(라이브러리, 유틸리티 등) + +**App/Shared** Layer는 Slice 없이 Segment로 구성됩니다. +상위 Layer는 자신보다 하위 Layer를 참조 할 수 있지만, 하위 Layer가 상위 Layer를 참조하는 것은 허용되지 않습니다. +예를 들어 pages는 features나 entities의 모듈을 참조할 수 있지만, features가 pages를 참조하는 것은 금지됩니다. + +### Slice  \{#slices} + +Slice는 Layer 내부를 비즈니스 도메인별로 나눕니다. +이름/개수에 제한이 없으며, 같은 Layer 내 다른 Slice를 참조할 수 없습니다. +이 규칙이 높은 응집도와 낮은 결합도를 보장합니다. + +### Segment \{#segments} + +Slice와 App/Shared Layer는 Segment로 세분화되어, 코드의 역할(예: UI, 데이터 처리, 상태 관리 등)에 따라 코드를 그룹화합니다. +일반적으로 다음과 같은 Segment를 사용합니다 + +- `ui` - UI components, date formatter, styles 등 UI 표현과 직접 관련된 코드 +- `api` - request functions, data types, mappers 등 백엔드 통신 및 데이터 로직 +- `model` - schema, interfaces, store, business logic 등 애플리케이션 도메인 모델 +- `lib` - 해당 Slice에서 여러 모듈이 함께 사용하는 공통 library code +- `config` - configuration files, feature flags 등 환경/기능 설정 + +대부분의 Layer에서는 위 다섯 Segment로 충분합니다. +필요하다면 App 또는 Shared Layer에서만 추가 Segment를 정의하세요. + +## 장점 \{#advantages} + +FSD 구조를 사용하면 다음과 같은 장점을 얻을 수 있습니다: + +**일관성** + 구조가 표준화되어 팀 간 협업과 신규 멤버 온보딩이 쉬워집니다. + +**격리성** + Layer와 Slice 간 의존성을 제한하여, 특정 모듈만 안전하게 수정할 수 있습니다. + +**재사용 범위 제어** + 재사용 가능한 코드를 필요한 범위에서만 활용할 수 있어, **DRY** 원칙과 실용성을 균형 있게 유지합니다. + +**도메인 중심 구조** + 비즈니스 용어 기반의 구조로 되어 있어, 전체 코드를 몰라도 특정 기능을 독립적으로 구현할 수 있습니다. + +## 점진적 도입 \{#incremental-adoption} + +기존 프로젝트에 FSD를 도입하는 방법: + +1. `app`, `shared` Layer를 먼저 정리하며 기반을 다집니다. +2. 기존 UI를 `widgets`, `pages` Layer로 분배합니다. 이 과정에서 FSD 규칙을 위반해도 괜찮습니다. +3. Import 위반을 하나씩 해결하면서, 코드에서 로직을 분리해 `entities`와 `features`로 옮깁니다. + +> 도입 단계에서는 새로운 대규모 Entity나 복잡한 기능을 추가하지 않는 것이 좋습니다. +> 구조를 안정적으로 정리하는 데 집중하는 것이 우선입니다. +> 자세한 절차는 [Migration 가이드][migration]를 참고하세요. + +## 다음 단계 \{#next-steps} + +- [Tutorial][tutorial]을 통해 FSD 방식의 사고를 익혀보세요. +- 다양한 [예제][examples]를 통해 실제 프로젝트 구조를 살펴보세요. +- 궁금한 점은 [Telegram 커뮤니티][ext-telegram]에서 질문해보세요. + +[tutorial]: /docs/get-started/tutorial +[examples]: /examples +[migration]: /docs/guides/migration/from-custom +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[ext-telegram]: https://t.me/feature_sliced diff --git a/src/content/docs/kr/docs/get-started/tutorial.mdx b/src/content/docs/kr/docs/get-started/tutorial.mdx new file mode 100644 index 0000000000..4ab8ec5fb2 --- /dev/null +++ b/src/content/docs/kr/docs/get-started/tutorial.mdx @@ -0,0 +1,2273 @@ +--- +title: Tutorial +sidebar: + order: 2 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +## Part 1. 설계 + +이 튜토리얼에서는 Real World App이라고도 알려진 Conduit를 살펴보겠습니다. Conduit는 기본적인 [Medium](https://medium.com/) 클론입니다 - 글을 읽고 쓸 수 있으며 다른 사람의 글에 댓글을 달 수 있습니다. + +![Conduit home page](../../../../../../static/img/tutorial/realworld-feed-anonymous.jpg) + +이 애플리케이션은 매우 작은 애플리케이션이므로 과도한 분해를 피하고 간단하게 유지할 것입니다. 전체 애플리케이션이 세 개의 레이어인 **App**, **Pages**, 그리고 **Shared**에 맞춰 들어갈 것입니다. 그렇지 않다면 우리는 계속해서 추가적인 레이어를 도입할 것입니다. 준비되셨나요? + +### 먼저 페이지를 나열해 봅시다. + +위의 스크린샷을 보면 최소한 다음과 같은 페이지들이 있다고 가정할 수 있습니다: + +- 홈 (글 피드) +- 로그인 및 회원가입 +- 글 읽기 +- 글 편집기 +- 사용자 프로필 보기 +- 사용자 프로필 편집 (사용자 설정) + +이 페이지들 각각은 Pages *레이어*의 독립된 *슬라이스*가 될 것입니다. 개요에서 언급했듯이 슬라이스는 단순히 레이어 내의 폴더이고, 레이어는 `pages`와 같은 미리 정의된 이름을 가진 폴더일 뿐입니다. + +따라서 우리의 Pages 폴더는 다음과 같이 보일 것입니다. + + +- pages/ + - feed/ + - sign-in/ + - article-read/ + - article-edit/ + - profile/ + - settings/ + + +Feature-Sliced Design이 규제되지 않은 코드 구조와 다른 주요 차이점은 페이지들이 서로를 참조할 수 없다는 것입니다. 즉, 한 페이지가 다른 페이지의 코드를 가져올 수 없습니다. 이는 **레이어의 import 규칙** 때문입니다. + +*슬라이스의 모듈은 엄격히 아래에 있는 레이어에 위치한 다른 슬라이스만 가져올 수 있습니다.* + +이 경우 페이지는 슬라이스이므로, 이 페이지 내의 모듈(파일)은 같은 레이어인 Pages가 아닌 아래 레이어의 코드만 참조할 수 있습니다. + +### 피드 자세히 보기 + +
+ ![Anonymous user’s perspective](../../../../../../static/img/tutorial/realworld-feed-anonymous.jpg) +
+ _익명 사용자의 관점_ +
+
+ +
+ ![Authenticated user’s perspective](../../../../../../static/img/tutorial/realworld-feed-authenticated.jpg) +
+ _인증된 사용자의 관점_ +
+
+ +피드 페이지에는 세 가지 동적 영역이 있습니다. + +1. 로그인 여부를 나타내는 로그인 링크 +2. 피드에서 필터링을 트리거하는 태그 목록 +3. 좋아요 버튼이 있는 하나/두 개의 글 피드 + +로그인 링크는 모든 페이지에 공통적인 헤더의 일부이므로 나중에 따로 다루겠습니다. + +#### 태그 목록 + +태그 목록을 만들기 위해서는 사용 가능한 태그를 가져오고, 각 태그를 칩으로 렌더링하고, 선택된 태그를 클라이언트 측 저장소에 저장해야 합니다. 이러한 작업들은 각각 "API 상호작용", "사용자 인터페이스", "저장소" 카테고리에 속합니다. Feature-Sliced Design에서는 코드를 *세그먼트*를 사용하여 목적별로 분리합니다. 세그먼트는 슬라이스 내의 폴더이며, 목적을 설명하는 임의의 이름을 가질 수 있지만, 일부 목적은 너무 일반적이어서 특정 세그먼트 이름에 대한 규칙이 있습니다. + + +- 📂 `api/` 백엔드 상호작용 +- 📂 `ui/` 렌더링과 외관을 다루는 코드 +- 📂 `model/` 저장소와 비즈니스 로직 +- 📂 `config/` 기능 플래그, 환경 변수 및 기타 구성 형식 + +태그를 가져오는 코드는 `api`에, 태그 컴포넌트는 `ui`에, 저장소 상호작용은 `model`에 배치할 것입니다. + +#### 글 + +같은 그룹화 원칙을 사용하여 글 피드를 같은 세 개의 세그먼트로 분해할 수 있습니다. + +- 📂 `api/`: 좋아요 수가 포함된 페이지네이션된 글 가져오기 +- 📂 `ui/`: + - 태그가 선택된 경우 추가 탭을 렌더링할 수 있는 탭 목록 + - 개별 글 + - 기능적 페이지네이션 +- 📂 `model/`: 현재 로드된 글과 현재 페이지의 클라이언트 측 저장소 (필요한 경우) + +### 일반적인 코드 재사용 + +대부분의 페이지는 의도가 매우 다르지만, 앱 전체에 걸쳐 일부 요소는 동일하게 유지됩니다. 예를 들어, 디자인 언어를 준수하는 UI 키트나 모든 것이 동일한 인증 방식으로 REST API를 통해 수행되는 백엔드의 규칙 등이 있습니다. 슬라이스는 격리되도록 설계되었기 때문에, 코드 재사용은 더 낮은 계층인 **Shared**에 의해 촉진됩니다. + + +Shared는 슬라이스가 아닌 세그먼트를 포함한다는 점에서 다른 계층과 다릅니다. 이런 면에서 Shared 계층은 계층과 슬라이스의 하이브리드로 생각할 수 있습니다. + +일반적으로 Shared의 코드는 미리 계획되지 않고 개발 중에 추출됩니다. 실제로 어떤 코드 부분이 공유되는지는 개발 중에만 명확해지기 때문입니다. 그러나 어떤 종류의 코드가 자연스럽게 Shared에 속하는지 머릿속에 메모해 두는 것은 여전히 도움이 됩니다. + + +- 📂 `ui/` — UI 키트, 비즈니스 로직이 없는 순수한 UI. 예: 버튼, 모달 대화 상자, 폼 입력. +- 📂 `api/` — 요청 생성 기본 요소(예: 웹의 `fetch()`)에 대한 편의 래퍼 및 선택적으로 백엔드 사양에 따라 특정 요청을 트리거하는 함수. +- 📂 `config/` — 환경 변수 파싱 +- 📂 `i18n/` — 언어 지원에 대한 구성 +- 📂 `router/` — 라우팅 기본 요소 및 라우트 상수 + +이는 Shared의 세그먼트 이름의 몇 가지 예시일 뿐이며, 이 중 일부를 생략하거나 자신만의 세그먼트를 만들 수 있습니다. 새로운 세그먼트를 만들 때 기억해야 할 유일한 중요한 점은 세그먼트 이름이 **본질(무엇인지)이 아닌 목적(왜)을 설명해야 한다**는 것입니다. "components", "hooks", "modals"과 같은 이름은 이 파일들이 무엇인지는 설명하지만 내부 코드를 탐색하는 데 도움이 되지 않기 때문에 사용해서는 안 됩니다. 이는 팀원들이 이러한 폴더의 모든 파일을 파헤쳐야 하며, 관련 없는 코드를 가까이 유지하게 되어 리팩토링의 영향을 받는 코드 영역이 넓어지고 결과적으로 코드 리뷰와 테스트를 더 어렵게 만듭니다. + +### 엄격한 공개 API 정의 + +Feature-Sliced Design의 맥락에서 *공개 API*라는 용어는 슬라이스나 세그먼트가 프로젝트의 다른 모듈에서 가져올 수 있는 것을 선언하는 것을 의미합니다. 예를 들어, JavaScript에서는 슬라이스의 다른 파일에서 객체를 다시 내보내는 `index.js` 파일일 수 있습니다. 이를 통해 외부 세계와의 계약(즉, 공개 API)이 동일하게 유지되는 한 슬라이스 내부의 코드를 자유롭게 리팩토링할 수 있습니다. + +슬라이스가 없는 Shared 계층의 경우, Shared의 모든 것에 대한 단일 인덱스를 정의하는 것과 반대로 각 세그먼트에 대해 별도의 공개 API를 정의하는 것이 일반적으로 더 편리합니다. 이렇게 하면 Shared에서의 가져오기가 자연스럽게 의도별로 구성됩니다. 슬라이스가 있는 다른 계층의 경우 반대가 사실입니다 — 일반적으로 슬라이스당 하나의 인덱스를 정의하고 슬라이스가 외부 세계에 알려지지 않은 자체 세그먼트 세트를 결정하도록 하는 것이 더 실용적입니다. 다른 계층은 일반적으로 내보내기가 훨씬 적기 때문입니다. + +우리의 슬라이스/세그먼트는 서로에게 다음과 같이 나타날 것입니다. + + +- pages/ + - feed/ + - index + - sign-in/ + - index + - article-read/ + - index + - ... +- shared/ + - ui/ + - index + - api/ + - index + - ... + + +`pages/feed`나 `shared/ui`와 같은 폴더 내부의 내용은 해당 폴더에만 알려져 있으며, 다른 파일은 이러한 폴더의 내부 구조에 의존해서는 안 됩니다. + + +### UI의 큰 재사용 블록 + +앞서 모든 페이지에 나타나는 헤더를 다시 살펴보기로 했습니다. 모든 페이지에서 처음부터 다시 만드는 것은 비실용적이므로 재사용하고 싶을 것입니다. 우리는 이미 코드 재사용을 용이하게 하는 Shared를 가지고 있지만, Shared에 큰 UI 블록을 넣는 데는 주의할 점이 있습니다 — Shared 계층은 위의 계층에 대해 알지 못해야 합니다. + +Shared와 Pages 사이에는 Entities, Features, Widgets의 세 가지 다른 계층이 있습니다. 일부 프로젝트는 이러한 계층에 큰 재사용 가능한 블록에 필요한 것이 있을 수 있으며, 이는 해당 재사용 가능한 블록을 Shared에 넣을 수 없다는 것을 의미합니다. 그렇지 않으면 상위 계층에서 가져오게 되어 금지됩니다. 이것이 Widgets 계층이 필요한 이유입니다. Widgets는 Shared, Entities, Features 위에 위치하므로 이들 모두를 사용할 수 있습니다. + +우리의 경우, 헤더는 매우 간단합니다 — 정적 로고와 최상위 탐색입니다. 탐색은 사용자가 현재 로그인했는지 여부를 확인하기 위해 API에 요청을 해야 하지만, 이는 `api` 세그먼트에서 간단한 가져오기로 처리할 수 있습니다. 따라서 우리는 헤더를 Shared에 유지할 것입니다. + +### 폼이 있는 페이지 자세히 보기 + +읽기가 아닌 편집을 위한 페이지도 살펴보겠습니다. + +![Conduit post editor](../../../../../../static/img/tutorial/realworld-editor-authenticated.jpg) + +간단해 보이지만, 폼 유효성 검사, 오류 상태, 데이터 지속성 등 아직 탐구하지 않은 애플리케이션 개발의 여러 측면을 포함하고 있습니다. + +이 페이지를 만들려면 Shared에서 일부 입력과 버튼을 가져와 이 페이지의 `ui` 세그먼트에서 폼을 구성할 것입니다. 그런 다음 `api` 세그먼트에서 백엔드에 글을 생성하는 변경 요청을 정의할 것입니다. + +요청을 보내기 전에 유효성을 검사하려면 유효성 검사 스키마가 필요하며, 이를 위한 좋은 위치는 데이터 모델이기 때문에 `model` 세그먼트입니다. 여기서 오류 메시지를 생성하고 `ui` 세그먼트의 다른 컴포넌트를 사용하여 표시할 것입니다. + +사용자 경험을 개선하기 위해 우발적인 데이터 손실을 방지하기 위해 입력을 지속시킬 수도 있습니다. 이것도 `model` 세그먼트의 작업입니다. + +### 요약 + +우리는 여러 페이지를 검토하고 애플리케이션의 예비 구조를 개략적으로 설명했습니다. + +1. Shared layer + 1. `ui`는 재사용 가능한 UI 키트를 포함할 것입니다. + 2. `api`는 백엔드와의 기본적인 상호작용을 포함할 것입니다. + 3. 나머지는 필요에 따라 정리될 것입니다. +2. Pages layer — 각 페이지는 별도의 슬라이스입니다. + 1. `ui`는 페이지 자체와 모든 부분을 포함할 것입니다. + 2. `api`는 `shared/api`를 사용하여 더 특화된 데이터 가져오기를 포함할 것입니다. + 3. `model`은 표시할 데이터의 클라이언트 측 저장소를 포함할 수 있습니다. + +이제 코드 작성을 시작해 봅시다! + +## Part 2. 코드 작성 + +이제 설계를 완료했으니 실제로 코드를 작성해 봅시다. React와 [Remix](https://remix.run)를 사용할 것입니다. + +이 프로젝트를 위한 템플릿이 준비되어 있습니다. GitHub에서 클론하여 시작하세요. [https://github.com/feature-sliced/tutorial-conduit/tree/clean](https://github.com/feature-sliced/tutorial-conduit/tree/clean). + +`npm install`로 의존성을 설치하고 `npm run dev`로 개발 서버를 시작하세요. [http://localhost:3000](http://localhost:3000)을 열면 빈 앱이 보일 것입니다. + + +### 페이지 레이아웃 + +모든 페이지에 대한 빈 컴포넌트를 만드는 것부터 시작하겠습니다. 프로젝트에서 다음 명령을 실행하세요. + +```bash +npx fsd pages feed sign-in article-read article-edit profile settings --segments ui +``` + +이렇게 하면 `pages/feed/ui/`와 같은 폴더와 모든 페이지에 대한 인덱스 파일인 `pages/feed/index.ts`가 생성됩니다. + +### 피드 페이지 연결 + +애플리케이션의 루트 경로를 피드 페이지에 연결해 봅시다. `pages/feed/ui`에 `FeedPage.tsx` 컴포넌트를 만들고 다음 내용을 넣으세요: + +```tsx title="pages/feed/ui/FeedPage.tsx" +export function FeedPage() { + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+
+ ); +} +``` + +그런 다음 피드 페이지의 공개 API인 `pages/feed/index.ts` 파일에서 이 컴포넌트를 다시 내보내세요. + +```ts title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +``` + +이제 루트 경로에 연결합니다. Remix에서 라우팅은 파일 기반이며, 라우트 파일은 `app/routes` 폴더에 있어 Feature-Sliced Design과 잘 맞습니다. + +`app/routes/_index.tsx`에서 `FeedPage` 컴포넌트를 사용하세요. + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +그런 다음 개발 서버를 실행하고 애플리케이션을 열면 Conduit 배너가 보일 것입니다! + +![The banner of Conduit](../../../../../../static/img/tutorial/conduit-banner.jpg) + +### API 클라이언트 + +RealWorld 백엔드와 통신하기 위해 Shared에 편리한 API 클라이언트를 만들어 봅시다. 클라이언트를 위한 `api`와 백엔드 기본 URL과 같은 변수를 위한 `config`, 두 개의 세그먼트를 만드세요. + + +```bash +npx fsd shared --segments api config +``` + +그런 다음 `shared/config/backend.ts`를 만드세요. + +```tsx title="shared/config/backend.ts" +export { mockBackendUrl as backendBaseUrl } from "mocks/handlers"; +``` + +```tsx title="shared/config/index.ts" +export { backendBaseUrl } from "./backend"; +``` + +RealWorld 프로젝트는 편리하게 [OpenAPI 사양](https://github.com/gothinkster/realworld/blob/main/api/openapi.yml)을 제공하므로, 클라이언트를 위한 자동 생성 타입을 활용할 수 있습니다. 추가 타입 생성기가 포함된 [`openapi-fetch` 패키지](https://openapi-ts.pages.dev/openapi-fetch/)를 사용할 것입니다. + +다음 명령을 실행하여 최신 API 타입을 생성하세요. + +```bash +npm run generate-api-types +``` + +이렇게 하면 `shared/api/v1.d.ts` 파일이 생성됩니다. 이 파일을 사용하여 `shared/api/client.ts`에 타입이 지정된 API 클라이언트를 만들 것입니다. + +```tsx title="shared/api/client.ts" +import createClient from "openapi-fetch"; + +import { backendBaseUrl } from "shared/config"; +import type { paths } from "./v1"; + +export const { GET, POST, PUT, DELETE } = createClient({ baseUrl: backendBaseUrl }); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; +``` + +### 피드의 실제 데이터 + +이제 백엔드에서 가져온 글을 피드에 추가할 수 있습니다. 글 미리보기 컴포넌트를 구현하는 것부터 시작하겠습니다. + +다음 내용으로 `pages/feed/ui/ArticlePreview.tsx`를 만드세요. + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +export function ArticlePreview({ article }) { /* TODO */ } +``` + +TypeScript를 사용하고 있으므로 글 객체에 타입을 지정하면 좋을 것 같습니다. 생성된 `v1.d.ts`를 살펴보면 글 객체가 `components["schemas"]["Article"]`을 통해 사용 가능한 것을 볼 수 있습니다. 그럼 Shared에 데이터 모델이 있는 파일을 만들고 모델을 내보내겠습니다. + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; +``` + +이제 글 미리보기 컴포넌트로 돌아가 데이터로 마크업을 채울 수 있습니다. 컴포넌트를 다음 내용으로 업데이트하세요. + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+ +
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +좋아요 버튼은 지금은 아무 작업도 하지 않습니다. 글 읽기 페이지를 만들고 좋아요 기능을 구현할 때 수정하겠습니다. + +이제 글을 가져와서 이러한 카드를 여러 개 렌더링할 수 있습니다. Remix에서 데이터 가져오기는 *로더* — 페이지가 필요로 하는 것을 정확히 가져오는 서버 측 함수 — 를 통해 수행됩니다. 로더는 페이지를 대신하여 API와 상호 작용하므로 페이지의 `api` 세그먼트에 넣을 것입니다: + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; + +import { GET } from "shared/api"; + +export const loader = async () => { + const { data: articles, error, response } = await GET("/articles"); + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return json({ articles }); +}; +``` + +페이지에 연결하려면 라우트 파일에서 `loader`라는 이름으로 내보내야 합니다. + +```tsx title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +export { loader } from "./api/loader"; +``` + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export { loader } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +마지막 단계는 피드에 이러한 카드를 렌더링하는 것입니다. `FeedPage`를 다음 코드로 업데이트하세요. + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+
+
+
+ ); +} +``` + +### 태그로 필터링 + +태그와 관련해서는 백엔드에서 태그를 가져오고 현재 선택된 태그를 저장해야 합니다. 가져오기 방법은 이미 알고 있습니다 — 로더에서 또 다른 요청을 하면 됩니다. `remix-utils` 패키지에서 `promiseHash`라는 편리한 함수를 사용할 것입니다. 이 패키지는 이미 설치되어 있습니다. + +로더 파일인 `pages/feed/api/loader.ts`를 다음 코드로 업데이트하세요. + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async () => { + return json( + await promiseHash({ + articles: throwAnyErrors(GET("/articles")), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + + +오류 처리를 일반 함수 `throwAnyErrors`로 추출했다는 점에 주목하세요. 꽤 유용해 보이므로 나중에 재사용할 수 있을 것 같습니다. 지금은 그냥 주목해 두겠습니다. + +이제 태그 목록으로 넘어갑시다. 이는 상호작용이 가능해야 합니다 — 태그를 클릭하면 해당 태그가 선택되어야 합니다. Remix 규칙에 따라 URL 검색 매개변수를 선택된 태그의 저장소로 사용할 것입니다. 브라우저가 저장을 처리하게 하고 우리는 더 중요한 일에 집중하겠습니다. + +`pages/feed/ui/FeedPage.tsx`를 다음 코드로 업데이트하세요. + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles, tags } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +그런 다음 로더에서 `tag` 검색 매개변수를 사용해야 합니다. `pages/feed/api/loader.ts`의 `loader` 함수를 다음과 같이 변경하세요. + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { params: { query: { tag: selectedTag } } }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +이게 전부입니다. `model` 세그먼트가 필요하지 않습니다. Remix는 꽤 깔끔하죠. + +### 페이지네이션 + +비슷한 방식으로 페이지네이션을 구현할 수 있습니다. 직접 시도해 보거나 아래 코드를 복사하세요. 어차피 당신을 판단할 사람은 없습니다. + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const [searchParams] = useSearchParams(); + const { articles, tags } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} + +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +이것으로 완료되었습니다. 탭 목록도 비슷하게 구현할 수 있지만, 인증을 구현할 때까지 잠시 보류하겠습니다. 그런데 말이 나왔으니! + +### 인증 + +인증에는 두 개의 페이지가 관련됩니다 - 로그인과 회원가입입니다. 이들은 대부분 동일하므로 필요한 경우 코드를 재사용할 수 있도록 `sign-in`이라는 동일한 슬라이스에 유지하는 것이 합리적입니다. + +`pages/sign-in`의 `ui` 세그먼트에 다음 내용으로 `RegisterPage.tsx`를 만드세요. + +```tsx title="pages/sign-in/ui/RegisterPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { register } from "../api/register"; + +export function RegisterPage() { + const registerData = useActionData(); + + return ( +
+
+
+
+

Sign up

+

+ Have an account? +

+ + {registerData?.error && ( +
    + {registerData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +이제 고쳐야 할 깨진 import가 있습니다. 새로운 세그먼트가 필요하므로 다음과 같이 만드세요. + +```bash +npx fsd pages sign-in -s api +``` + +그러나 등록의 백엔드 부분을 구현하기 전에 Remix가 세션을 처리할 수 있도록 일부 인프라 코드가 필요합니다. 다른 페이지에서도 필요할 수 있으므로 이는 Shared로 갑니다. + +다음 코드를 `shared/api/auth.server.ts`에 넣으세요. 이는 Remix에 매우 특화된 것이므로 너무 걱정하지 마세요. 그냥 복사-붙여넣기 하세요. + +```tsx title="shared/api/auth.server.ts" +import { createCookieSessionStorage, redirect } from "@remix-run/node"; +import invariant from "tiny-invariant"; + +import type { User } from "./models"; + +invariant( + process.env.SESSION_SECRET, + "SESSION_SECRET must be set for authentication to work", +); + +const sessionStorage = createCookieSessionStorage<{ + user: User; +}>({ + cookie: { + name: "__session", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [process.env.SESSION_SECRET], + secure: process.env.NODE_ENV === "production", + }, +}); + +export async function createUserSession({ + request, + user, + redirectTo, +}: { + request: Request; + user: User; + redirectTo: string; +}) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.set("user", user); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 7, // 7 days + }), + }, + }); +} + +export async function getUserFromSession(request: Request) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + return session.get("user") ?? null; +} + +export async function requireUser(request: Request) { + const user = await getUserFromSession(request); + + if (user === null) { + throw redirect("/login"); + } + + return user; +} +``` + +그리고 바로 옆에 있는 `models.ts` 파일에서 `User` 모델도 내보내세요. + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +export type User = components["schemas"]["User"]; +``` + +이 코드가 작동하려면 `SESSION_SECRET` 환경 변수를 설정해야 합니다. 프로젝트 루트에 `.env` 파일을 만들고 `SESSION_SECRET=`을 작성한 다음 키보드에서 무작위로 키를 눌러 긴 무작위 문자열을 만드세요. 다음과 같은 결과가 나와야 합니다. + + +```bash title=".env" +SESSION_SECRET=dontyoudarecopypastethis +``` + +마지막으로 이 코드를 사용하기 위해 공개 API에 일부 내보내기를 추가하세요. + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +``` + +이제 RealWorld 백엔드와 실제로 통신하여 등록을 수행하는 코드를 작성할 수 있습니다. 그것을 `pages/sign-in/api`에 유지할 것입니다. `register.ts`라는 파일을 만들고 다음 코드를 넣으세요. + + +```tsx title="pages/sign-in/api/register.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const register = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const username = formData.get("username")?.toString() ?? ""; + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users", { + body: { user: { email, password, username } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +``` + +거의 다 왔습니다! 페이지와 액션을 `/register` 라우트에 연결하기만 하면 됩니다. `app/routes`에 `register.tsx`를 만드세요. + +```tsx title="app/routes/register.tsx" +import { RegisterPage, register } from "pages/sign-in"; + +export { register as action }; + +export default RegisterPage; +``` + +이제 [http://localhost:3000/register](http://localhost:3000/register)로 가면 사용자를 생성할 수 있어야 합니다! 애플리케이션의 나머지 부분은 아직 이에 반응하지 않을 것입니다. 곧 그 문제를 해결하겠습니다. + +매우 유사한 방식으로 로그인 페이지를 구현할 수 있습니다. 직접 시도해 보거나 그냥 코드를 가져와서 계속 진행하세요. + +```tsx title="pages/sign-in/api/sign-in.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const signIn = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users/login", { + body: { user: { email, password } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/ui/SignInPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { signIn } from "../api/sign-in"; + +export function SignInPage() { + const signInData = useActionData(); + + return ( +
+
+
+
+

Sign in

+

+ Need an account? +

+ + {signInData?.error && ( +
    + {signInData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +export { SignInPage } from './ui/SignInPage'; +export { signIn } from './api/sign-in'; +``` + +```tsx title="app/routes/login.tsx" +import { SignInPage, signIn } from "pages/sign-in"; + +export { signIn as action }; + +export default SignInPage; +``` + +이제 사용자가 이 페이지에 실제로 접근할 수 있는 방법을 제공해 봅시다. + +### 헤더 + +1부에서 논의했듯이, 앱 헤더는 일반적으로 Widgets나 Shared에 배치됩니다. 매우 간단하고 모든 비즈니스 로직을 외부에 유지할 수 있기 때문에 Shared에 넣을 것입니다. 이를 위한 장소를 만들어 봅시다. + +```bash +npx fsd shared ui +``` + +이제 다음 내용으로 `shared/ui/Header.tsx`를 만드세요. + +```tsx title="shared/ui/Header.tsx" +import { useContext } from "react"; +import { Link, useLocation } from "@remix-run/react"; + +import { CurrentUser } from "../api/currentUser"; + +export function Header() { + const currentUser = useContext(CurrentUser); + const { pathname } = useLocation(); + + return ( + + ); +} +``` + +이 컴포넌트를 `shared/ui`에서 내보내세요. + +```tsx title="shared/ui/index.ts" +export { Header } from "./Header"; +``` + +헤더에서는 `shared/api`에 유지되는 컨텍스트에 의존합니다. 그것도 만드세요. + +```tsx title="shared/api/currentUser.ts" +import { createContext } from "react"; + +import type { User } from "./models"; + +export const CurrentUser = createContext(null); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +export { CurrentUser } from "./currentUser"; +``` + +이제 페이지에 헤더를 추가해 봅시다. 모든 페이지에 있어야 하므로 루트 라우트에 추가하고 outlet(페이지가 렌더링될 위치)을 `CurrentUser` 컨텍스트 제공자로 감싸는 것이 합리적입니다. 이렇게 하면 전체 앱과 헤더가 현재 사용자 객체에 접근할 수 있습니다. 또한 쿠키에서 실제로 현재 사용자 객체를 가져오는 로더를 추가할 것입니다. `app/root.tsx`에 다음 내용을 넣으세요. + +```tsx title="app/root.tsx" +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from "@remix-run/react"; + +import { Header } from "shared/ui"; +import { getUserFromSession, CurrentUser } from "shared/api"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export const loader = ({ request }: LoaderFunctionArgs) => + getUserFromSession(request); + +export default function App() { + const user = useLoaderData(); + + return ( + + + + + + + + + + + + + +
+ + + + + + + + ); +} +``` + +이 시점에서 홈 페이지에 다음과 같은 내용이 표시되어야 합니다. + +
+ ![The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing.](../../../../../../static/img/tutorial/realworld-feed-without-tabs.jpg) + +
헤더, 피드, 태그를 포함한 Conduit의 피드 페이지. 탭은 아직 없습니다.
+
+ +### 탭 + +이제 인증 상태를 감지할 수 있으므로 탭과 글 좋아요를 빠르게 구현하여 피드 페이지를 완성해 봅시다. 또 다른 폼이 필요하지만 이 페이지 파일이 꽤 커지고 있으므로 이러한 폼을 인접한 파일로 옮기겠습니다. `Tabs.tsx`, `PopularTags.tsx`, `Pagination.tsx`를 다음 내용으로 만들 것입니다. + + +```tsx title="pages/feed/ui/Tabs.tsx" +import { useContext } from "react"; +import { Form, useSearchParams } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; + +export function Tabs() { + const [searchParams] = useSearchParams(); + const currentUser = useContext(CurrentUser); + + return ( +
+
+
    + {currentUser !== null && ( +
  • + +
  • + )} +
  • + +
  • + {searchParams.has("tag") && ( +
  • + + {searchParams.get("tag")} + +
  • + )} +
+
+
+ ); +} +``` + +```tsx title="pages/feed/ui/PopularTags.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; + +export function PopularTags() { + const { tags } = useLoaderData(); + + return ( +
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+ ); +} +``` + +```tsx title="pages/feed/ui/Pagination.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; + +export function Pagination() { + const [searchParams] = useSearchParams(); + const { articles } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ + ); +} +``` + +이제 `FeedPage`를 다음과 같이 업데이트하세요. + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; +import { Tabs } from "./Tabs"; +import { PopularTags } from "./PopularTags"; +import { Pagination } from "./Pagination"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ + + {articles.articles.map((article) => ( + + ))} + + +
+ +
+ +
+
+
+
+ ); +} +``` + +마지막으로 로더를 업데이트하여 새로운 필터를 처리하세요. + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + /* unchanged */ +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + if (url.searchParams.get("source") === "my-feed") { + const userSession = await requireUser(request); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles/feed", { + params: { + query: { + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + headers: { Authorization: `Token ${userSession.token}` }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); + } + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +피드 페이지를 떠나기 전에, 글에 대한 좋아요를 처리하는 코드를 추가해 봅시다. `ArticlePreview.tsx`를 다음과 같이 변경하세요. + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Form, Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+
+ +
+
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +이 코드는 글에 좋아요를 표시하기 위해 `/article/:slug`로 `_action=favorite`과 함께 POST 요청을 보냅니다. 아직 작동하지 않겠지만, 글 읽기 페이지 작업을 시작하면서 이것도 구현할 것입니다. + +이것으로 피드가 공식적으로 완성되었습니다! 야호! + +### 글 읽기 페이지 + +먼저 데이터가 필요합니다. 로더를 만들어 봅시다. + +```bash +npx fsd pages article-read -s api +``` + +```tsx title="pages/article-read/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import invariant from "tiny-invariant"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, getUserFromSession } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + invariant(params.slug, "Expected a slug parameter"); + const currentUser = await getUserFromSession(request); + const authorization = currentUser + ? { Authorization: `Token ${currentUser.token}` } + : undefined; + + return json( + await promiseHash({ + article: throwAnyErrors( + GET("/articles/{slug}", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + comments: throwAnyErrors( + GET("/articles/{slug}/comments", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + }), + ); +}; +``` + +```tsx title="pages/article-read/index.ts" +export { loader } from "./api/loader"; +``` + + +이제 `/article/:slug` 라우트에 연결할 수 있습니다. `article.$slug.tsx`라는 라우트 파일을 만드세요. + +```tsx title="app/routes/article.$slug.tsx" +export { loader } from "pages/article-read"; +``` + +페이지 자체는 세 가지 주요 블록으로 구성됩니다 - 글 헤더와 액션(두 번 반복), 글 본문, 댓글 섹션입니다. 다음은 페이지의 마크업입니다. 특별히 흥미로운 내용은 없습니다: + +```tsx title="pages/article-read/ui/ArticleReadPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticleMeta } from "./ArticleMeta"; +import { Comments } from "./Comments"; + +export function ArticleReadPage() { + const { article } = useLoaderData(); + + return ( +
+
+
+

{article.article.title}

+ + +
+
+ +
+
+
+

{article.article.body}

+
    + {article.article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} +``` + +더 흥미로운 것은 `ArticleMeta`와 `Comments`입니다. 이들은 글 좋아요, 댓글 작성 등과 같은 쓰기 작업을 포함합니다. 이들을 작동시키려면 먼저 백엔드 부분을 구현해야 합니다. 페이지의 `api` 세그먼트에 `action.ts`를 만드세요: + +```tsx title="pages/article-read/api/action.ts" +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { namedAction } from "remix-utils/named-action"; +import { redirectBack } from "remix-utils/redirect-back"; +import invariant from "tiny-invariant"; + +import { DELETE, POST, requireUser } from "shared/api"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const currentUser = await requireUser(request); + + const authorization = { Authorization: `Token ${currentUser.token}` }; + + const formData = await request.formData(); + + return namedAction(formData, { + async delete() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirect("/"); + }, + async favorite() { + invariant(params.slug, "Expected a slug parameter"); + await POST("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfavorite() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async createComment() { + invariant(params.slug, "Expected a slug parameter"); + const comment = formData.get("comment"); + invariant(typeof comment === "string", "Expected a comment parameter"); + await POST("/articles/{slug}/comments", { + params: { path: { slug: params.slug } }, + headers: { ...authorization, "Content-Type": "application/json" }, + body: { comment: { body: comment } }, + }); + return redirectBack(request, { fallback: "/" }); + }, + async deleteComment() { + invariant(params.slug, "Expected a slug parameter"); + const commentId = formData.get("id"); + invariant(typeof commentId === "string", "Expected an id parameter"); + const commentIdNumeric = parseInt(commentId, 10); + invariant( + !Number.isNaN(commentIdNumeric), + "Expected a numeric id parameter", + ); + await DELETE("/articles/{slug}/comments/{id}", { + params: { path: { slug: params.slug, id: commentIdNumeric } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async followAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await POST("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfollowAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await DELETE("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + }); +}; +``` + +그 슬라이스에서 이를 내보내고 라우트에서도 내보내세요. 그리고 페이지 자체도 연결하겠습니다. + +```tsx title="pages/article-read/index.ts" +export { ArticleReadPage } from "./ui/ArticleReadPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/article.$slug.tsx" +import { ArticleReadPage } from "pages/article-read"; + +export { loader, action } from "pages/article-read"; + +export default ArticleReadPage; +``` + +이제 독자 페이지에서 좋아요 버튼을 아직 구현하지 않았지만, 피드의 좋아요 버튼이 작동하기 시작할 것입니다! 이 라우트로 "좋아요" 요청을 보내고 있었기 때문입니다. 한번 시도해 보세요. + +`ArticleMeta`와 `Comments`는 다시 한번 폼들의 모음입니다. 이전에 이미 해봤으니, 코드를 가져와서 넘어가겠습니다. + +```tsx title="pages/article-read/ui/ArticleMeta.tsx" +import { Form, Link, useLoaderData } from "@remix-run/react"; +import { useContext } from "react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function ArticleMeta() { + const currentUser = useContext(CurrentUser); + const { article } = useLoaderData(); + + return ( +
+
+ + + + +
+ + {article.article.author.username} + + {article.article.createdAt} +
+ + {article.article.author.username == currentUser?.username ? ( + <> + + Edit Article + +    + + + ) : ( + <> + + +    + + + )} +
+
+ ); +} +``` + +```tsx title="pages/article-read/ui/Comments.tsx" +import { useContext } from "react"; +import { Form, Link, useLoaderData } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function Comments() { + const { comments } = useLoaderData(); + const currentUser = useContext(CurrentUser); + + return ( +
+ {currentUser !== null ? ( +
+
+ +
+
+ + +
+
+ ) : ( +
+
+

+ Sign in +   or   + Sign up +   to add comments on this article. +

+
+
+ )} + + {comments.comments.map((comment) => ( +
+
+

{comment.body}

+
+ +
+ + + +   + + {comment.author.username} + + {comment.createdAt} + {comment.author.username === currentUser?.username && ( + +
+ + +
+
+ )} +
+
+ ))} +
+ ); +} +``` + +이것으로 우리의 글 읽기 페이지도 완성되었습니다! 이제 작성자를 팔로우하고, 글에 좋아요를 누르고, 댓글을 남기는 버튼들이 예상대로 작동해야 합니다. + +
+ ![Article reader with functioning buttons to like and follow](../../../../../../static/img/tutorial/realworld-article-reader.jpg) + +
기능하는 좋아요와 팔로우 버튼이 있는 글 읽기 페이지
+
+ +### 글 작성 페이지 + +이것은 이 튜토리얼에서 다룰 마지막 페이지이며, 여기서 가장 흥미로운 부분은 폼 데이터를 어떻게 검증할 것인가 입니다. + +페이지 자체인 `article-edit/ui/ArticleEditPage.tsx`는 꽤 간단할 것이며, 추가적인 복잡성은 다른 두 개의 컴포넌트로 숨겨질 것입니다. + +```tsx title="pages/article-edit/ui/ArticleEditPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { TagsInput } from "./TagsInput"; +import { FormErrors } from "./FormErrors"; + +export function ArticleEditPage() { + const article = useLoaderData(); + + return ( +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+
+
+
+
+ ); +} +``` + +이 페이지는 현재 글(새로 작성하는 경우가 아니라면)을 가져와서 해당하는 폼 필드를 채웁니다. 이전에 본 적이 있습니다. 흥미로운 부분은 `FormErrors`인데, 이는 검증 결과를 받아 사용자에게 표시할 것입니다. 한번 살펴보겠습니다. + +```tsx title="pages/article-edit/ui/FormErrors.tsx" +import { useActionData } from "@remix-run/react"; +import type { action } from "../api/action"; + +export function FormErrors() { + const actionData = useActionData(); + + return actionData?.errors != null ? ( +
    + {actionData.errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null; +} +``` + +여기서는 우리의 액션이 `errors` 필드, 즉 사람이 읽을 수 있는 오류 메시지 배열을 반환할 것이라고 가정하고 있습니다. 곧 액션에 대해 다루겠습니다. + +또 다른 컴포넌트는 태그 입력입니다. 이는 단순한 입력 필드에 선택된 태그의 추가적인 미리보기가 있는 것입니다. 여기에는 특별한 것이 없습니다: + +```tsx title="pages/article-edit/ui/TagsInput.tsx" +import { useEffect, useRef, useState } from "react"; + +export function TagsInput({ + name, + defaultValue, +}: { + name: string; + defaultValue?: Array; +}) { + const [tagListState, setTagListState] = useState(defaultValue ?? []); + + function removeTag(tag: string): void { + const newTagList = tagListState.filter((t) => t !== tag); + setTagListState(newTagList); + } + + const tagsInput = useRef(null); + useEffect(() => { + tagsInput.current && (tagsInput.current.value = tagListState.join(",")); + }, [tagListState]); + + return ( + <> + + setTagListState(e.target.value.split(",").filter(Boolean)) + } + /> +
+ {tagListState.map((tag) => ( + + + [" ", "Enter"].includes(e.key) && removeTag(tag) + } + onClick={() => removeTag(tag)} + >{" "} + {tag} + + ))} +
+ + ); +} +``` + +이제 API 부분입니다. 로더는 URL을 살펴보고, 글 슬러그가 포함되어 있다면 기존 글을 수정하는 것이므로 해당 데이터를 로드해야 합니다. 그렇지 않으면 아무것도 반환하지 않습니다. 그 로더를 만들어 봅시다. + +```ts title="pages/article-edit/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const currentUser = await requireUser(request); + + if (!params.slug) { + return { article: null }; + } + + return throwAnyErrors( + GET("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: { Authorization: `Token ${currentUser.token}` }, + }), + ); +}; +``` + +액션은 새로운 필드 값들을 받아 우리의 데이터 스키마를 통해 실행하고, 모든 것이 올바르다면 이러한 변경사항을 백엔드에 커밋합니다. 이는 기존 글을 업데이트하거나 새 글을 생성하는 방식으로 이루어집니다. + +```tsx title="pages/article-edit/api/action.ts" +import { json, redirect, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, PUT, requireUser } from "shared/api"; +import { parseAsArticle } from "../model/parseAsArticle"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + try { + const { body, description, title, tags } = parseAsArticle( + await request.formData(), + ); + const tagList = tags?.split(",") ?? []; + + const currentUser = await requireUser(request); + const payload = { + body: { + article: { + title, + description, + body, + tagList, + }, + }, + headers: { Authorization: `Token ${currentUser.token}` }, + }; + + const { data, error } = await (params.slug + ? PUT("/articles/{slug}", { + params: { path: { slug: params.slug } }, + ...payload, + }) + : POST("/articles", payload)); + + if (error) { + return json({ errors: error }, { status: 422 }); + } + + return redirect(`/article/${data.article.slug ?? ""}`); + } catch (errors) { + return json({ errors }, { status: 400 }); + } +}; +``` + +스키마는 `FormData`를 위한 파싱 함수로도 작동하여, 깨끗한 필드를 편리하게 얻거나 마지막에 처리할 오류를 던질 수 있게 해줍니다. 그 파싱 함수는 다음과 같이 보일 수 있습니다. + +```tsx title="pages/article-edit/model/parseAsArticle.ts" +export function parseAsArticle(data: FormData) { + const errors = []; + + const title = data.get("title"); + if (typeof title !== "string" || title === "") { + errors.push("Give this article a title"); + } + + const description = data.get("description"); + if (typeof description !== "string" || description === "") { + errors.push("Describe what this article is about"); + } + + const body = data.get("body"); + if (typeof body !== "string" || body === "") { + errors.push("Write the article itself"); + } + + const tags = data.get("tags"); + if (typeof tags !== "string") { + errors.push("The tags must be a string"); + } + + if (errors.length > 0) { + throw errors; + } + + return { title, description, body, tags: data.get("tags") ?? "" } as { + title: string; + description: string; + body: string; + tags: string; + }; +} +``` + +물론 이는 다소 길고 반복적이지만, 사람이 읽을 수 있는 오류 메시지를 위해 우리가 지불해야 하는 대가입니다. 이것은 Zod 스키마일 수도 있지만, 그렇게 하면 프론트엔드에서 오류 메시지를 렌더링해야 하고, 이 폼은 그런 복잡성을 감당할 만한 가치가 없습니다. + +마지막 단계로 - 페이지, 로더, 그리고 액션을 라우트에 연결합니다. 우리는 생성과 편집을 모두 깔끔하게 지원하므로 `editor._index.tsx`와 `editor.$slug.tsx` 모두에서 동일한 것을 내보낼 수 있습니다. + +```tsx title="pages/article-edit/index.ts" +export { ArticleEditPage } from "./ui/ArticleEditPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/editor._index.tsx, app/routes/editor.$slug.tsx (same content)" +import { ArticleEditPage } from "pages/article-edit"; + +export { loader, action } from "pages/article-edit"; + +export default ArticleEditPage; +``` + +이제 완료되었습니다! 로그인하고 새 글을 작성해보세요. 또는 글을 "잊어버리고" 검증이 작동하는 것을 확인해보세요. + +
+ ![The Conduit article editor, with the title field saying “New article” and the rest of the fields empty. Above the form there are two errors: “**Describe what this article is about” and “Write the article itself”.**](../../../../../../static/img/tutorial/realworld-article-editor.jpg) + +
제목 필드에 "새 글"이라고 쓰여 있고 나머지 필드는 비어 있는 Conduit 글 편집기. 폼 위에 두 개의 오류가 있습니다. **"이 글이 무엇에 관한 것인지 설명해주세요"**, **"글 본문을 작성해주세요"**.
+
+ +프로필과 설정 페이지는 글 읽기와 편집기 페이지와 매우 유사하므로, 독자인 여러분의 연습 과제로 남겨두겠습니다 :) diff --git a/src/content/docs/kr/docs/guides/examples/api-requests.mdx b/src/content/docs/kr/docs/guides/examples/api-requests.mdx new file mode 100644 index 0000000000..479a1bae86 --- /dev/null +++ b/src/content/docs/kr/docs/guides/examples/api-requests.mdx @@ -0,0 +1,173 @@ +--- +title: Handling API Requests +sidebar: + order: 4 +--- + +import { FileTree, Tabs, TabItem, Aside } from '@astrojs/starlight/components'; + +## Shared API Requests + +공통적으로 사용하는 API 요청 로직은 `shared/api` 폴더에 보관하는 것을 권장합니다. +이렇게 하면 애플리케이션 전체에서 **일관된 방식으로 재사용**할 수 있고, +초기 구현 속도(프로토타이핑)도 빠르게 유지할 수 있습니다. + +대부분의 프로젝트는 다음 구조와 `client.ts` 설정만으로 충분합니다. + +일반적인 파일 구조 예시: + + +- shared/ + - api/ + - client.ts + - index.ts + - endpoints/ + - login.ts + + +`client.ts` 파일은 모든 HTTP request 관련 설정을 **한 곳에서** 관리합니다. +즉, 공통 설정을 client에 모아두면 개별 endpoint 로직에서는 **request를 보내는 데만** 집중할 수 있습니다. + +`client.ts`에서는 다음 항목들을 설정합니다: + + +- 백엔드 기본 URL +- Default headers (예: 인증 header) +- JSON 직렬화/파싱 + +아래 예시에서 axios 버전과 fetch 버전 모두 확인할 수 있습니다. + + + + +```ts title="shared/api/client.ts" +// Axios 예시 +import axios from 'axios'; + +export const client = axios.create({ + baseURL: 'https://your-api-domain.com/api/', + timeout: 5000, + headers: { 'X-Custom-Header': 'my-custom-value' } +}); +``` + + + +```ts title="shared/api/client.ts" +export const client = { + async post(endpoint: string, body: any, options?: RequestInit) { + const response = await fetch(`https://your-api-domain.com/api${endpoint}`, { + method: 'POST', + body: JSON.stringify(body), + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'my-custom-value', + ...options?.headers, + }, + }); + return response.json(); + } + // ... other methods like put, delete, etc. +}; +``` + + + + +이제 `shared/api/endpoints` 폴더 안에 API endpoint별 request 함수를 작성합니다. +이렇게 endpoint 단위로 분리해두면 API 변경이 있을 때 유지보수가 매우 쉬워집니다. + + + +```ts title="shared/api/endpoints/login.ts" +import { client } from '../client'; + +export interface LoginCredentials { + email: string; + password: string; +} + +export function login(credentials: LoginCredentials) { + return client.post('/login', credentials); +} +``` + +그리고 다음처럼 `shared/api/index.ts`에서 request 함수와 타입들을 공개 API로 내보냅니다: + +```ts title="shared/api/index.ts" +export { client } from './client'; // If you want to export the client itself +export { login } from './endpoints/login'; +export type { LoginCredentials } from './endpoints/login'; +``` + +## Slice-specific API Requests + +특정 페이지나 feature 내부에서만 사용하는 request는 해당 slice의 api 폴더에 넣어 관리하는 것을 권장합니다. +이렇게 하면 slice별 코드가 서로 섞이지 않고, 책임이 명확하게 분리되며, 유지보수가 쉬워집니다. + +예시 구조: + +- pages/ + - login/ + - index.ts + - api/ + - login.ts + - ui/ + - LoginPage.tsx + + +```ts title="pages/login/api/login.ts" +import { client } from 'shared/api'; + +interface LoginCredentials { + email: string; + password: string; +} + +export function login(credentials: LoginCredentials) { + return client.post('/login', credentials); +} +``` + +이 함수는 **로그인 페이지 내부에서만 사용하는 API 요청** 이므로 +slice의 public API(`index.ts`)로 다시 export할 필요는 없습니다. + + + +## API 타입과 클라이언트 자동 생성 + +백엔드에 OpenAPI 스펙이 준비되어 있다면, +[orval](https://orval.dev/)이나 [openapi-typescript](https://openapi-ts.dev/) 같은 도구를 사용해 +**API 타입과 request 함수**를 자동으로 생성할 수 있습니다. + +이렇게 생성된 코드는 보통 `shared/api/openapi` 같은 폴더에 두고, +`README.md`에 다음 내용을 함께 문서화하는 것을 권장합니다. + +- 생성 스크립트를 어떻게 실행하는지 +- 어떤 타입/클라이언트가 생성되는지 +- 사용하는 방법 예시 + +## 서버 상태 라이브러리 연동 + +[TanStack Query (React Query)](https://tanstack.com/query/latest)나 [Pinia Colada](https://pinia-colada.esm.dev/) 같은 **서버 상태 관리 라이브러리**를 사용할 때는, +서로 다른 slice에서 **타입이나 cache key를 공유**해야 할 때가 자주 생깁니다. + +이런 경우에는 다음과 같은 항목들을 `shared` layer에 두고 같이 쓰는 것이 좋습니다. + +- API 데이터 타입 (API data types) +- 캐시 키 (cache keys) +- 공통 query/mutation 옵션 (common query/mutation options) diff --git a/src/content/docs/kr/docs/guides/examples/auth.mdx b/src/content/docs/kr/docs/guides/examples/auth.mdx new file mode 100644 index 0000000000..0f73d5b2a8 --- /dev/null +++ b/src/content/docs/kr/docs/guides/examples/auth.mdx @@ -0,0 +1,357 @@ +--- +title: Authentication +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +웹 애플리케이션에서의 **인증(Authentication)** 플로우는 보통 다음과 같은 세 단계로 진행됩니다. + +1. **Credential 입력 수집** — 아이디, 비밀번호(또는 OAuth redirect URL)를 사용자에게 입력받습니다. +2. **백엔드 Endpoint 호출** — `/login`, `/oauth/callback`, `/2fa` 등 로그인 관련 API endpoint로 request를 보냅니다. +3. **Token 저장** — 응답으로 받은 token을 **cookie** 또는 **store**에 저장해, 이후 request에 자동으로 포함되도록 합니다. + +## 1. Credential 입력 수집 + +이 단계에서는 사용자가 로그인에 필요한 정보를 입력할 수 있는 UI를 준비합니다. + +> OAuth 로그인만 사용한다면, **2단계(credential 전송)** 에서 별도로 아이디/비밀번호를 보내지 않습니다. +> 이 경우 바로 [token 저장](#how-to-store-the-token-for-authenticated-requests) 단계로 넘어갑니다. + +### 1-1. 로그인 전용 페이지 + +웹 애플리케이션에서는 일반적으로 **/login** 같은 로그인 Form 전용 페이지를 만들어, 사용자가 **사용자 이름 / 이메일, 비밀번호**를 입력하도록 합니다. + +이 페이지는 하는 일이 단순하기 때문에, 추가적인 **decomposition(구조 분할)** 이 크게 필요하지 않습니다. +대신, 로그인 폼과 회원가입 폼을 각각 **하나의 컴포넌트**로 만들어 두고 재사용하는 방식이 적합합니다. + + + +- pages/ + - login/ + - ui/ + - LoginPage.tsx (or your framework's component file format) + - RegisterPage.tsx + - index.ts + - other pages... + + +LoginPage와 RegisterPage 컴포넌트는 서로 **분리** 된 컴포넌트로 구현하고, 다른 곳에서 사용할 필요가 있다면 index.ts에서 export 합니다. +각 컴포넌트는 form element와 form submit handler만 포함하도록 해서, +복잡한 비즈니스 로직은 다른 segment로 분리하고 UI는 단순하게 유지합니다. + +### 1-2. 로그인 dialog 만들기 + +어떤 페이지에서든 공통으로 사용할 수 있는 로그인 dialog가 필요하다면, 이를 **재사용 가능한 widget**으로 구현하는 것이 좋습니다. +**widget**으로 구현하면 페이지마다 로그인 로직을 따로 만들 필요 없이, 필요한 곳에서 동일한 dialog를 불러와 사용할 수 있고, +구조를 과하게 쪼개지 않으면서도 재사용성을 확보할 수 있습니다. + + +- widgets/ + - login-dialog/ + - ui/ + - LoginDialog.tsx + - index.ts + - other widgets... + + +> 이후 설명은 **로그인 전용 페이지** 를 기준으로 진행하지만, +> 여기서 다루는 원칙은 login dialog widget에도 동일하게 적용됩니다. + +### 1-3. Client-side Validation + +회원가입 페이지에서 잘못된 입력을 즉시 알려주면 UX가 훨씬 좋아집니다. +이를 위해 client-side validation을 적용할 수 있습니다. + +검증 규칙은 `pages/login/model` segment에 schema 형태로 정의하고,`ui` segment에서는 이 schema를 불러와 재사용합니다. +아래 예시는 [Zod][ext-zod]를 사용해 타입과 값을 동시에 검증하는 패턴입니다. + +```ts title="pages/login/model/registration-schema.ts" +import { z } from "zod"; + +export const registrationData = z.object({ + email: z.string().email(), + password: z.string().min(6), + confirmPassword: z.string(), +}).refine((data) => data.password === data.confirmPassword, { + message: "비밀번호가 일치하지 않습니다", + path: ["confirmPassword"], +}); +``` + +그런 다음, `ui` segment에서 이 schema를 사용해 form으로부터 받은 데이터를 검증할 수 있습니다: + +```tsx title="pages/login/ui/RegisterPage.tsx" +import { registrationData } from "../model/registration-schema"; + +function validate(formData: FormData) { + const data = Object.fromEntries(formData.entries()); + try { + registrationData.parse(data); + } catch (error) { + // TODO: Show error message to the user + } +} + +export function RegisterPage() { + return ( +
validate(new FormData(e.target))}> + + + + + + + + +
+ ) +} +``` + +## 2. Send credentials + +이 단계에서는 사용자가 입력한 **credentials**(e-mail, password 등)를 +백엔드 **endpoint**로 전송하는 **request 함수**를 만듭니다. + +이 함수는 다음과 같은 곳에서 호출할 수 있습니다. + +- Zustand +- Redux Toolkit +- TanStack Query의 useMutation +- 기타 state 관리/요청 로직 + +즉, **어디에서나 재사용 가능한 로그인 요청 함수** 를 만든다고 보면 됩니다. + +### 2-1. 함수 placement + +| 목적 | 권장 위치 | 이유 | +| ----------- | ----------------- | -------------------------- | +| 전역 재사용 | shared/api | 모든 slice에서 import 가능 | +| 로그인 전용 | pages/login/api | slice 내부 capsule 유지 | + +#### shared/api에 저장하기 + +로그인뿐 아니라 모든 API request를 shared/api에 모아두고, +각 요청을 endpoint별로 그룹화하는 방식입니다. + + +- shared/ + - api/ + - endpoints/ + - login.ts + - ... + - client.ts + - index.ts + + +`📄 client.ts`는 원시 request 함수(`fetch` 등)를 감싼 공용 API client로, +**기본 URL, 공통 헤더, request/response 직렬화** 등을 처리합니다. + +```ts title="shared/api/endpoints/login.ts" +import { POST } from "../client"; + +export function login({ email, password }: { email: string, password: string }) { + return POST("/login", { email, password }); +} +``` + +```ts title="shared/api/index.ts" +export { login } from "./endpoints/login"; +``` + +#### page의 api segment에 저장하기 + +로그인 request가 로그인 페이지에서만 사용된다면, +해당 페이지의 api segment에 login 함수를 두는 것도 가능합니다. + + +- pages/ + - login/ + - api/ + - login.ts + - ui/ + - LoginPage.tsx + - index.ts + - ... + + +```ts title="pages/login/api/login.ts" +import { POST } from "shared/api"; + +export function login({ email, password }: { email: string, password: string }) { + return POST("/login", { email, password }); +} +``` + +> 이 함수는 로그인 페이지 내부에서만 사용하므로, +> index.ts에서 다시 export할 필요는 없습니다. + +### Two-Factor Auth (2FA) + +2단계 인증(2FA)을 사용하는 경우에는 로그인 플로우에 한 단계가 더 추가됩니다. + +1. `/login` 응답에 `has2FA` 플래그가 있으면, `/login/2fa` 페이지로 redirect 합니다. +2. 2FA 페이지와 관련 API들은 모두 `pages/login` slice에 함께 둡니다. +3. `/2fa/verify`와 같이 별도의 endpoint를 호출하는 함수는 `shared/api` 또는 `pages/login/api`에 배치합니다. + +이렇게 하면, 일반 로그인과 2FA 관련 로직을 **login slice 내부**에 모아둘 수 있습니다. + +## Authenticated Requests를 위한 token 저장 \{#how-to-store-the-token-for-authenticated-requests} + +로그인, 비밀번호 변경, OAuth, 2단계 인증 등 어떤 방법으로 인증을 하든, +인증 API 호출의 **응답(response)** 으로 보통 token이 함께 내려옵니다. + +이 token을 어딘가에 저장해 두면, +이후 **모든 인증이 필요한 API 요청(request)** 에 token을 자동으로 포함시켜 백엔드 인증을 통과할 수 있습니다. + +웹 애플리케이션에서 token을 저장하는 방법 중 **가장 권장되는 방식은 cookie**입니다. + +cookie를 사용하면, 브라우저가 요청마다 token을 자동으로 넣어 주기 때문에 +프론트엔드에서 token을 직접 관리할 필요가 거의 없습니다. +따라서 프론트엔드 아키텍처 차원에서 신경 쓸 부분이 크게 줄어듭니다. + +사용 중인 프레임워크가 서버 사이드 기능을 제공한다면(예: [Remix][ext-remix]), +서버 측 cookie 관련 로직을 shared/api에 두는 것을 권장합니다. + +Remix에서의 구현 예시는 [튜토리얼의 Authentication 섹션][tutorial-authentication]을 참고하면 됩니다. + +하지만 cookie를 사용할 수 없는 환경도 있습니다. +이 경우에는 token을 클라이언트에서 직접 저장하고, token 만료를 감지하고, +refresh token을 사용해 새 token을 발급받고 기존 요청을 다시 실행하는 등의 로직을 함께 구현해야 합니다. + +FSD에서는 여기서 한 가지 추가 고민이 필요합니다. + +token을 **어느 layer 또는 어느 segment에** 저장할지, +그렇게 저장한 token을 앱 전역에서 **어떻게** 사용할 수 있게 할지에 따라 전체 구조가 달라지기 때문입니다. + +### 3-1. Shared + +Shared layer에 token을 두는 방식은 shared/api에 정의된 **공용 API 클라이언트**와 자연스럽게 결합되는 패턴입니다. + +token을 module scope나 어떤 reactive store에 저장해 두면, +인증이 필요한 다른 API 함수에서 이 token을 **그대로 참조**해 사용할 수 있습니다. + +token 자동 재발급(refresh)은 API client의 **middleware**에서 담당합니다. + +1. 로그인 시 **access token, refresh token**을 저장합니다. +2. 인증이 필요한 request를 보냅니다. +3. 응답에서 token 만료 코드를 받으면, refresh token으로 새 token을 발급해 저장한 뒤 실패한 request을 동일하게 다시 시도합니다. + +#### Token 관리 분리 전략 + +- **전담 segment 부재** + token 저장과 재발급 로직이 request 로직과 같은 파일에 뒤섞여 있으면, 코드가 많아질수록 유지보수가 점점 어려워집니다. + 이런 경우에는 **request 함수와 client는 `shared/api`에 두고**, + **token 관리 로직은 `shared/auth` segment로 분리**하는 방식을 권장합니다. + +- **token과 사용자 정보를 함께 받는 경우** + 백엔드가 token과 동시에 **현재 사용자 정보**를 반환하는 API를 제공하는 경우도 있습니다. + 이때는 다음 두 가지 방식 중 하나로 처리할 수 있습니다. + 1. 별도 store에 함께 저장하거나 + 2. `/me`·`/users/current` 같은 endpoint를 따로 호출해 user 정보를 가져올 수 있습니다. + +### 3-2. Entities + +FSD 프로젝트에서는 보통 **User entity**(또는 **Current User entity**)를 두는 경우가 많습니다. +두 entity를 하나로 합쳐서 사용하는 것도 전혀 문제 없습니다. + + + +#### Token을 User Entities에 저장하기 + +User entity의 model segment에 **reactive store**를 만들고, +이곳에 token과 user 객체를 함께 보관할 수 있습니다. + +이렇게 하면: **현재 로그인한 사용자 정보** 와 **그 사용자가 가진 token**을 한 곳에서 관리할 수 있어서, +인증과 관련된 비즈니스 로직을 작성할 때 구조를 이해하기 쉬워집니다. + +다만 API client는 보통 shared/api에 정의되거나, +여러 entity에 분산되어 있는 경우가 많습니다. + +따라서 layer의 import 규칙([import rule on layers][import-rule-on-layers])을 지키면서도 다른 request에서 이 token을 안전하게 사용할 수 있어야 합니다. + +> Layer 규칙 — Slice의 module은 **자기보다 아래 layer**의 Slice만 import할 수 있습니다. + +##### 해결 방법 + +1. **request마다 token을 직접 넘기기** + - 구현은 단순하지만 코드가 반복되기 쉽고, 타입 안전성이 없으면 실수 가능성이 커집니다. + - shared/api에 middleware pattern을 적용하기도 어렵습니다. + +2. **앱 전역(Context / localStorage)에 노출** + - token key는 shared/api에 두고, 실제 token 값이 담긴 store는 User entity에서 export 합니다. + - Context Provider는 App layer에 배치합니다. + - 설계 자유도가 높지만, 상위 layer에 **암묵적 의존성**이 생깁니다. + ⇒ Context나 localStorage가 누락된 경우 **명확한 에러**를 내도록 처리하는 것이 좋습니다. + +3. **token이 바뀔 때마다 API 클라이언트에 업데이트** + - store **subscription**으로 "token 변경 → 클라이언트 상태 업데이트”를 수행합니다. + - 방법 2와 마찬가지로 암묵적 의존성이 있으나, + - 방법 2는 필요할 때 값을 **가져오는(pull)** 방식이고, + - 방법 3은 변경될 때 값을 **밀어넣는(push)** 방식입니다. + +token을 이렇게 외부에서 사용할 수 있도록 노출한 뒤에는 +model segment에 **비즈니스 로직**을 더 추가할 수 있습니다. + +예를 들면, token 만료 시간에 맞춰 자동으로 갱신하거나, +일정 시간이 지나면 token을 자동으로 무효화하도록 만들 수 있습니다. + +실제 백엔드 호출은 **User entity의 api segment** 또는 shared/api에서 수행합니다. + +### 3-3. Pages / Widgets — 권장하지 않음 + +다음과 같은 이유로 page layer나 widget layer에 token을 저장하는 것은 권장하지 않습니다. + +page, widget layer에 token을 두면 전역에서 이 token에 의존하게 되는데, +이렇게 되면 다른 slice에서 재사용하기 어렵고, 구조가 쉽게 얽힙니다. + +따라서 token 저장 위치는 Shared 또는 Entities 중 하나로 결정하는 것을 권장합니다. + +## 4. Logout & Token Invalidation + +### 로그아웃과 token 무효화 + +대부분의 애플리케이션에는 **로그아웃 전용 페이지**는 따로 두지 않습니다. +대신, 어느 화면에서든 호출할 수 있는 로그아웃 기능을 두는 것이 일반적입니다. + +로그아웃은 일반적으로 다음 두 단계로 이루어집니다. + +1. 백엔드에 인증된 로그아웃 request 보내기 (예: `POST /logout`) +2. token store reset (access token / refresh token 모두 제거) + +> 모든 API request을 shared/api에 모아 관리하고 있다면, +> 로그아웃 API는 login() 근처, 예를 들어 shared/api/endpoints/logout.ts에 두는 것이 자연스럽습니다. +> +> 반대로 특정 UI(예: Header)에만 로그아웃 버튼이 있고, +> 그곳에서만 이 API를 호출한다면 widgets/header/api/logout.ts처럼 +> 버튼이 위치한 widget 근처에 두는 것도 가능합니다. + +token store reset은 실제로 로그아웃 버튼을 가진 UI에서 트리거됩니다. +로그아웃 request와 store reset을 같은 widget의 model segment에 함께 두어도 됩니다. + +### 자동 로그아웃 + +다음과 같은 경우에는 반드시 token store를 초기화해야 합니다. + +- 로그아웃 request가 실패했을 때 +- 로그인 token 갱신(`/refresh`)이 실패했을 때 + +이 상황에서 token이 그대로 남아 있으면, +화면 상으로는 **로그인된 것처럼** 보이지만 실제로는 대부분의 요청이 실패하는 애매한 상태가 될 수 있습니다. + +> token을 Entities(User)에 보관했다면, +> 해당 entity의 model segment에 token 초기화 코드를 두는 것이 좋습니다. +> Shared layer에서 token을 관리한다면, shared/auth segment로 분리해 두는 것도 좋은 선택입니다. + +[tutorial-authentication]: /docs/get-started/tutorial#authentication +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-remix]: https://remix.run +[ext-zod]: https://zod.dev diff --git a/src/content/docs/kr/docs/guides/examples/page-layout.mdx b/src/content/docs/kr/docs/guides/examples/page-layout.mdx new file mode 100644 index 0000000000..d1bd343709 --- /dev/null +++ b/src/content/docs/kr/docs/guides/examples/page-layout.mdx @@ -0,0 +1,159 @@ +--- +title: Page layouts +sidebar: + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + +여러 페이지에서 **같은 layout(header, sidebar, footer 등 공통 영역)** 을 사용하고, +그 안의 **Content 영역**(각 페이지에서 실제로 바뀌는 컴포넌트)만 달라질 때 사용하는 _page layout_ 개념을 설명합니다. + + + +## Simple layout + +먼저 가장 기본적인 **simple layout** 예시를 살펴보겠습니다. +이 layout은 다음과 같은 요소들로 구성됩니다. + +- 상단 header +- 좌우에 위치한 두 개의 sidebar +- 외부 링크(GitHub, Twitter)가 포함된 footer + +여기에는 복잡한 비즈니스 로직은 거의 없고, +레이아웃 자체에 필요한 최소한의 동작만 포함됩니다. + +- **정적 요소**: 고정된 menu, logo, footer 등 +- **동적 요소**: sidebar toggle, header 오른쪽의 theme switch button 등 + +이 Layout 컴포넌트는 보통 shared/ui 또는 app/layouts 같은 **common 폴더**에 두고 사용합니다. +이때, siblingPages(SiblingPageSidebar에서 사용할 데이터)와 headings(HeadingsSidebar에서 사용할 데이터)를 props로 받아서, +sidebar 내용은 **외부에서 주입(의존성 주입)** 받을 수 있도록 합니다. + + +```tsx title="shared/ui/layout/Layout.tsx" +import { Link, Outlet } from "react-router-dom"; +import { useThemeSwitcher } from "./useThemeSwitcher"; + +export function Layout({ siblingPages, headings }) { + const [theme, toggleTheme] = useThemeSwitcher(); + + return ( +
+
+ + +
+
+ + {/* 여기에 주요 콘텐츠가 들어갑니다 */} + +
+
+
    +
  • GitHub
  • +
  • Twitter
  • +
+
+
+ ); +} +``` + +```ts title="shared/ui/layout/useThemeSwitcher.ts" +export function useThemeSwitcher() { + const [theme, setTheme] = useState("light"); + + function toggleTheme() { + setTheme(theme === "light" ? "dark" : "light"); + } + + useEffect(() => { + document.body.classList.remove("light", "dark"); + document.body.classList.add(theme); + }, [theme]); + + return [theme, toggleTheme] as const; +} +``` + +위 예시에서 사이드바 UI 자체 구현 코드는 길어질 수 있으므로, 설명에서는 생략했습니다. +중요한 포인트는 layout이 **틀만 제공하고, 구체적인 내용은 props로 받아서 렌더링한다** 는 점입니다. + +## layout에 widget 적용하기 + +layout 컴포넌트에서 인증 처리나 데이터 로딩 같은 **비즈니스 로직**을 수행해야 할 때가 있습니다. +예를 들어, [React Router][ext-react-router]의 deeply nested routes 구조에서는 `/users`, `/users/:id`, `/users/:id/settings` 처럼 +공통된 URL prefix를 가진 여러 child routes가 존재합니다. + +이 경우, 인증 확인이나 공통 데이터 로딩 같은 로직을 +각 페이지마다 작성하기보다는 **layout 레벨에서 한 번에 처리**하는 방식이 훨씬 효율적입니다. + +다만 이런 layout을 shared나 widgets 폴더에 두면 +[layer에 대한 import 규칙][import-rule-on-layers]을 위반할 수 있습니다. + +> Slice의 module은 자신보다 **하위 layer**에 있는 Slice만 import할 수 있습니다. + +즉, layout에서 entity/feature/page를 직접 불러오게 되면 +**위에서 아래를 가져오는** 잘못된 의존성이 생길 수 있습니다. + +그래서 먼저 아래와 같은 점을 고려하는 것이 좋습니다. + +- _이 layout이 정말 필요한가?_ +- _꼭 widget 형태로 만들 필요가 있는가?_ + +layout이 적용되는 페이지 수가 2~3곳 정도라면, +이 layout이 사실상 **특정 페이지만을 위한 wrapper**일 수도 있으며 굳이 widget으로 승격시킬 필요가 없을 수 있습니다. + +이런 상황에서는 아래 두 가지 대안을 먼저 고려하세요. + +1. **App layer에서 inline으로 작성하기** +URL 패턴이 공통된 여러 경로를 Router의 nesting 기능으로 묶어 하나의 route group으로 만들 수 있습니다. +이 route group에 layout을 한 번만 지정하면, 해당 그룹 아래 모든 페이지에 자동으로 동일한 layout이 적용됩니다. + +1. **코드 복사 & 붙여넣기** +layout은 자주 변경되는 코드가 아니므로, +필요한 페이지만 layout 코드를 복사해 사용해도 큰 문제가 없습니다. +수정이 필요할 때만 해당 layout들을 개별적으로 업데이트하면 되고, +페이지 간 관계를 주석으로 남겨 두면 누락을 방지할 수 있습니다. + +--- + +위 방법들이 프로젝트에 맞지 않다면, +layout 안에서 widget을 사용하는 다음 두 가지 해결책을 고려할 수 있습니다. + +### 1. Render Props 또는 Slots 사용하기 + +React에서는 [render props][ext-render-props] 패턴을, Vue에서는 [slots][ext-vue-slots] 기능을 사용합니다. + +이 방식은 부모인 layout 컴포넌트가 **UI 틀을 제공하고**, +자식 컴포넌트가 전달한 UI를 layout 내부 특정 위치에 **주입(injection)** 하는 구조입니다. +Layout이 비즈니스 로직을 직접 수행하면서도, +UI 구성은 외부에서 유연하게 가져올 수 있다는 장점이 있습니다. + +### 2. layout을 App layer로 이동하기 + +layout을 app/layouts 같은 상위 layer로 옮기면, +App layer는 아래 layer(entities, features, shared)를 자유롭게 import할 수 있기 때문에 +Layer 규칙을 위반하지 않고 layout 안에서 widget을 사용할 수 있습니다. + +## 참고 자료 + +React 및 Remix(React Router와 구조가 유사)의 +인증 layout 구현 예시는 [튜토리얼][tutorial] 문서에서 확인할 수 있습니다. + +[tutorial]: /docs/get-started/tutorial +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-react-router]: https://reactrouter.com/ +[ext-render-props]: https://www.patterns.dev/react/render-props-pattern/ +[ext-vue-slots]: https://vuejs.org/guide/components/slots, diff --git a/src/content/docs/kr/docs/guides/examples/types.mdx b/src/content/docs/kr/docs/guides/examples/types.mdx new file mode 100644 index 0000000000..340aa12d5d --- /dev/null +++ b/src/content/docs/kr/docs/guides/examples/types.mdx @@ -0,0 +1,573 @@ +--- +title: Types +sidebar: + order: 2 +--- + +import { Aside, FileTree } from '@astrojs/starlight/components'; + +이 가이드는 TypeScript 같은 정적 타입 언어에서 **데이터를 어떻게 정의하고 활용할지**, +그리고 FSD 구조 안에서 **각 타입을 어디에 배치하는 것이 좋은지**를 설명합니다. + + + +## 유틸리티 타입 + +유틸리티 타입은 **그 자체로 큰 의미를 가지기보다는, 다른 타입과 함께 자주 사용되는 보조 타입**을 말합니다. +예를 들어, 배열에서 요소 타입만 추출하는 `ArrayValues` 같은 타입을 아래와 같이 정의할 수 있습니다. + +
+ +```ts +type ArrayValues = T[number]; +``` + +
+ Source: https://github.com/sindresorhus/type-fest/blob/main/source/array-values.d.ts +
+ +
+ +프로젝트 전체에서 유틸리티 타입을 사용하려면 두 가지 접근이 있습니다. + +1. **외부 라이브러리 설치** + 대표적으로 [`type-fest`](https://github.com/sindresorhus/type-fest)를 설치해서 사용합니다. + +2. **내부 유틸리티 타입 라이브러리 구축** + `shared/lib/utility-types` 폴더를 만들고, README에 다음 내용을 명확히 적어 두세요. + - 우리 팀에서 **유틸리티 타입**이라고 부르는 기준 + - 어떤 타입을 추가/제외할지에 대한 규칙 + +> 유틸리티 타입의 **재사용 가능성**을 과대평가하지 마세요. +> **재사용 가능하다**는 이유만으로 꼭 전역(`shared`)에 둘 필요는 없습니다. + +유틸리티 타입은 아래처럼 **실제 사용되는 위치 근처**에 두는 것이 오히려 유지보수에 유리한 경우가 많습니다. + + +- pages/ + - home/ + - api/ + - ArrayValues.ts (유틸리티 타입) + - getMemoryUsageMetrics.ts (유틸리티 타입을 사용하는 코드) + + + + +## 비즈니스 entity와 상호 참조 + +앱에서 가장 중요한 타입은 **비즈니스 entity**, 즉 도메인 객체 타입입니다. +예를 들어, 음악 스트리밍 서비스를 만든다고 하면 _Song_, _Album_ 같은 타입이 entity에 해당합니다. + +### 1. 백엔드 Response 타입 + +먼저 백엔드에서 내려오는 데이터를 기준으로 타입을 정의합니다. +필요하다면 [Zod][ext-zod] 같은 **schema 기반 유효성 검사 라이브러리**를 사용해 추가적인 타입 안전성을 확보할 수도 있습니다. + +```ts title="shared/api/songs.ts" +import type { Artist } from "./artists"; + +interface Song { + id: number; + title: string; + artists: Array; +} + +export function listSongs() { + return fetch("/api/songs").then( + (res) => res.json() as Promise>, + ); +} +``` + +예를 들어, `Song` 타입이 다른 entity인 `Artist`를 참조한다고 가정해 봅시다. + +이때 **Request/Response 관련 코드를 Shared layer에 두면**, +이러한 상호 참조 관계를 한곳에서 관리할 수 있어서 유지보수가 훨씬 쉬워집니다. + +반대로 이 Request 함수를 `entities/song/api` 내부에 두면 다음과 같은 문제가 생깁니다. + +`entities/artist` slice에서 `Song` 타입을 **참조하고 싶어도**, +FSD의 [layer별 import 규칙][import-rule-on-layers] 때문에 **동일 layer 간(import)** 의존은 금지됩니다. + +- 규칙 요약: + > _“한 slice의 모듈은 자신보다 **아래 layer**에 있는 slice만 import할 수 있다.”_ + +즉, 같은 layer에 있는 entity끼리는 직접 cross-import 할 수 없기 때문에 **Artist → Song** 의존을 바로 연결하기가 어렵습니다. +이런 경우에는 제네릭 타입 매개변수를 사용하거나, `@x` Public API 같은 패턴을 사용해 우회하는 전략이 필요합니다. + +### 2. 상호 참조 해결 전략 + +entity끼리 서로를 참조해야 할 때 사용할 수 있는 대표적인 전략은 다음 두 가지입니다. + +#### 1. 제네릭 타입 매개변수화 + +entity 간에 연결이 필요한 타입에 제네릭 타입 매개변수를 선언하고, 필요한 제약 조건을 부여합니다. +예를 들어, Song 타입에 `ArtistType`이라는 제네릭을 두고 제약을 걸 수 있습니다. + +```ts title="entities/song/model/song.ts" +interface Song { + id: number; + title: string; + artists: Array; +} +``` + +이 방식은 `Cart = { items: Product[] }`처럼 구조가 비교적 단순한 타입과 잘 어울립니다. +반면, `Country-City`처럼 서로 강하게 결합된 구조는 깔끔하게 분리하기 어려울 수 있습니다. + +#### 2. Cross-import (Public API(@x) 활용) + +FSD에서 entity 간 의존을 허용하려면, +참조 대상 entity 내부에 **다른 entity 전용 Public API**를 `@x` 디렉터리에 둡니다. + +예를 들어 `artist`와 `playlist`가 모두 `song`을 참조해야 한다면, +다음과 같은 구조를 만들 수 있습니다. + + +- entities/ + - song/ + - @x/ + - artist.ts (artist entity용 public API) + - playlist.ts (playlist entity용 public API) + - index.ts (기본 public API) + + +`📄 entities/song/@x/artist.ts` 파일의 내용은 +`📄 entities/song/index.ts`와 매우 비슷하지만, +**artist에서 사용할 수 있는 부분**만 노출하는 역할을 합니다. + +```ts title="entities/song/@x/artist.ts" +export type { Song } from "../model/song.ts"; +``` + +이렇게 분리해 두면 `📄 entities/artist/model/artist.ts`에서 `Song`을 가져올 때, +다음과 같이 **의존 대상이 명확한 import**를 사용할 수 있습니다. + +이 방식은 entity들의 의존 관계를 코드 구조 상에서 명확하게 보여 주고, +도메인 간 분리를 유지하는 데 도움이 됩니다. + +```ts title="entities/artist/model/artist.ts" +import type { Song } from "entities/song/@x/artist"; + +export interface Artist { + name: string; + songs: Array; +} +``` + +## 데이터 전송 객체와 mappers \{#data-transfer-objects-and-mappers} + +데이터 전송 객체(Data Transfer Object, DTO)는 +**백엔드에서 전달되는 데이터 구조 그대로를 표현한 타입**입니다. + +간단한 경우에는 DTO를 프론트엔드에서 그대로 사용해도 되지만, +실제 UI나 도메인 로직에서는 다루기 불편한 경우도 많습니다. +이럴 때 `mapper`를 사용해 DTO를 **프론트엔드 친화적인 형태**로 변환합니다. + +### DTO 배치 위치 + +DTO를 어디에 둘지는 백엔드와의 코드 공유 방식에 따라 달라집니다. + +- 백엔드 타입을 별도 패키지로 공유하고 있다면 + → 해당 패키지에서 DTO를 가져와서 사용하면 됩니다. +- 코드 공유가 없다면 + → 프론트엔드 코드베이스 안 어딘가에 DTO를 정의해야 합니다. + +Request 함수가 `shared/api`에 있다면, +DTO도 가능한 한 **바로 옆**에 두는 것을 권장합니다. + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + artist_ids: Array; +} + +export function listSongs() { + return fetch("/api/songs").then( + (res) => res.json() as Promise>, + ); +} +``` + +### mapper 배치 위치 + +mapper는 DTO를 인자로 받아 변환하는 함수이므로, +DTO 정의와 **최대한 가까운 위치**에 두는 것이 좋습니다. + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} + +interface Song { + id: string; + title: string; + /** 디스크 번호까지 포함한 전체 제목 */ + fullTitle: string; + artistIds: Array; +} + +function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} + +export function listSongs() { + return fetch("/api/songs").then(async (res) => + (await res.json()).map(adaptSongDTO), + ); +} +``` + +Request와 DTO가 `shared/api`에 있다면 → mapper도 `shared/api`에 둡니다. +Request와 store가 `entity slice` 내부에 있다면 → mapper도 해당 slice 안에 두되, +layer 간 cross-import 제한을 반드시 고려해야 합니다. + +```ts title="entities/song/api/dto.ts" +import type { ArtistDTO } from "entities/artist/@x/song"; + +export interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} +``` + +```ts title="entities/song/api/mapper.ts" +import type { SongDTO } from "./dto"; + +export interface Song { + id: string; + title: string; + /** 노래의 전체 제목, 디스크 번호까지 포함된 제목입니다. */ + fullTitle: string; + artistIds: Array; +} + +export function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} +``` + +```ts title="entities/song/api/listSongs.ts" +import { adaptSongDTO } from "./mapper"; + +export function listSongs() { + return fetch("/api/songs").then(async (res) => + (await res.json()).map(adaptSongDTO), + ); +} +``` + +```ts title="entities/song/model/songs.ts" +import { createSlice, createEntityAdapter } from "@reduxjs/toolkit"; + +import { listSongs } from "../api/listSongs"; + +export const fetchSongs = createAsyncThunk("songs/fetchSongs", listSongs); + +const songAdapter = createEntityAdapter(); +const songsSlice = createSlice({ + name: "songs", + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSongs.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload); + }); + }, +}); +``` + +### 중첩 DTO 처리 + +하나의 백엔드 Response 안에 여러 entity가 함께 포함되는 경우도 있습니다. +예를 들어 곡 정보에 저자(Author) 객체 전체가 포함되는 식입니다. + +이럴 때 entity들끼리는 **서로의 존재를 완전히 모른 채** +DTO 안에서만 연결될 수도 있습니다. + +이 경우 간접 연결(middleware 등)로 우회하는 것보다, +`@x` 표기법을 활용해 **명시적으로 cross-import**를 허용하는 편이 나을 때가 많습니다. +(예: Redux Toolkit + Normalizr를 조합해 사용하는 패턴) + +```ts title="entities/song/model/songs.ts" +import { + createSlice, + createEntityAdapter, + createAsyncThunk, + createSelector, +} from "@reduxjs/toolkit"; +import { normalize, schema } from "normalizr"; + +import { getSong } from "../api/getSong"; + +// Normalizr entity schema +export const artistEntity = new schema.Entity("artists"); +export const songEntity = new schema.Entity("songs", { + artists: [artistEntity], +}); + +const songAdapter = createEntityAdapter(); + +export const fetchSong = createAsyncThunk( + "songs/fetchSong", + async (id: string) => { + const data = await getSong(id); + // 데이터를 정규화하여 리듀서가 예측 가능한 payload를 로드할 수 있도록 합니다: + const normalized = normalize(data, songEntity); // `action.payload = { songs: {}, artists: {} }` + return normalized.entities; + }, +); + +export const slice = createSlice({ + name: "songs", + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload.songs); + }); + }, +}); + +const reducer = slice.reducer; +export default reducer; +``` + +```ts title="entities/song/@x/artist.ts" +export { fetchSong } from "../model/songs"; +``` + +```ts title="entities/artist/model/artists.ts" +import { createSlice, createEntityAdapter } from "@reduxjs/toolkit"; + +import { fetchSong } from "entities/song/@x/artist"; + +const artistAdapter = createEntityAdapter(); + +export const slice = createSlice({ + name: "users", + initialState: artistAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + // 같은 fetch 결과를 처리하며, 여기서 artists를 삽입합니다. + artistAdapter.upsertMany(state, action.payload.artists); + }); + }, +}); + +const reducer = slice.reducer; +export default reducer; +``` + +이 방법을 사용하면 slice 간 **완전한 독립성**은 다소 줄어들지만, +어차피 강하게 묶여 있는 두 entity의 관계를 +코드 상에서 명확하게 드러낼 수 있다는 장점이 있습니다. + +즉, 나중에 둘 중 하나를 수정할 때 +**연결된 entity까지 함께 리팩토링해야 한다는 사실**을 +더 쉽게 인지할 수 있습니다. + +## Global 타입과 Redux + +Global 타입은 애플리케이션 전역에서 사용되는 타입을 말하며, +크게 두 가지 종류로 나눌 수 있습니다. + +1. 애플리케이션에 특화되지 않은 **제너릭 타입** +2. 애플리케이션 전체가 알고 있어야 하는 **전역 도메인 타입** + +### 1) 제너릭 타입 + +첫 번째 경우(특정 도메인에 묶이지 않은 제너릭 타입)는 `Shared` 폴더 안의 적절한 segment에 배치하면 됩니다. +예를 들어, **분석(analytics) 관련 전역 인터페이스**라면 `shared/analytics`에 두는 식입니다. + + + +### 2) 애플리케이션 Global 타입 + +이 부분은 특히 `Redux(순수 Redux + RTK 미사용)` 프로젝트에서 자주 등장합니다. +모든 reducer를 합쳐야 비로소 store 타입이 완성되는데, 이 타입은 애플리케이션 전역에서 selector에 필요하게 됩니다. + +```ts title="app/store/index.ts" +import { combineReducers, createStore } from "redux"; + +import { songReducer } from "entities/song"; +import { artistReducer } from "entities/artist"; + +const rootReducer = combineReducers(songReducer, artistReducer); +const store = createStore(rootReducer); + +type RootState = ReturnType; +type AppDispatch = typeof store.dispatch; +``` + +이때, `shared/store`에서 `useAppDispatch`, `useAppSelector` 같은 +커스텀 훅을 만들고 싶어도, +[import 규칙][import-rule-on-layers]에 의해 +App layer에 있는 `RootState`, `AppDispatch` 타입을 바로 가져올 수 없습니다. + +> 한 slice의 module은 자신보다 하위 layer에 있는 slice만 import할 수 있습니다. + +#### 권장 해결책 + +이 경우에는 **Shared ↔ App layer 간에 한정된 암묵적 의존성을 허용**하는 것이 현실적인 해결책입니다. +`RootState`, `AppDispatch` 타입은 자주 바뀌지 않고, Redux 사용 경험이 있는 개발자에게는 매우 익숙한 개념이기 때문에, +이 정도의 의존성은 유지보수 부담이 크지 않습니다. + +```ts title="app/store/index.ts" +/* 이전 코드 블록과 동일한 내용입니다… */ + +declare type RootState = ReturnType; +declare type AppDispatch = typeof store.dispatch; +``` + +```ts title="shared/store/index.ts" +import { + useDispatch, + useSelector, + type TypedUseSelectorHook, +} from "react-redux"; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector: TypedUseSelectorHook = useSelector; +``` + +## 열거형(enum) + +enum 타입은 다음 원칙에 따라 배치하는 것을 권장합니다. + +- 가능한 한 **가장 가까운 사용 위치**에 정의합니다. +- 어떤 segment에 둘지는 **용도 기준**으로 결정합니다. + - UI toast 상태를 표현하는 enum → `ui` segment + - 백엔드 Response 상태를 표현하는 enum → `api` segment + +프로젝트 전역에서 공통으로 쓰는 값(예: Response 상태, 디자인 토큰 등)은 +`Shared` layer에 두고, +역할에 따라 `api`, `ui` 등 적절한 segment를 선택합니다. + +## 타입 검증 Schema와 Zod + +데이터의 형태와 제약 조건을 검증하려면 +[Zod][ext-zod] 같은 라이브러리로 **validation schema**를 정의합니다. + +schema의 위치는 **어디에서 쓰이는 데이터인지**에 따라 결정합니다. + +- 백엔드 Response 검증 → `api` segment 근처 +- 폼 입력 값 검증 → `ui` segment (또는 복잡한 경우 `model` segment) + +검증 schema는 DTO를 받아 파싱하고, +schema와 맞지 않으면 즉시 에러를 던집니다. +([Data transfer objects and mappers](#data-transfer-objects-and-mappers) 섹션도 참고하세요.) + +특히 백엔드 Response가 예상한 schema와 일치하지 않을 때 +request를 실패시키도록 구현하면, +버그를 비교적 이른 시점에 발견할 수 있습니다. + +이 때문에 검증 schema는 보통 `api` segment에 두는 편이 일반적입니다. + +## Component props, context 타입 + +일반적으로 Component의 props 타입과 context 타입은 +**해당 Component/Context를 정의한 파일과 같은 파일**에 둡니다. + +만약 단일 파일(Vue·Svelte 등)에서 +여러 Component가 같은 Interface를 공유해야 한다면, +같은 폴더(보통 `ui` segment)에 별도의 타입 파일을 만드는 방식도 사용할 수 있습니다. + +```ts title="pages/home/ui/RecentActions.tsx" +interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} + +export function RecentActions({ actions }: RecentActionsProps) { + /* … */ +} +``` + +Vue에서 Interface를 별도 파일에 저장하는 패턴이 대표적인 예입니다. + +```ts title="pages/home/ui/RecentActionsProps.ts" +export interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} +``` + +```html title="pages/home/ui/RecentActions.vue" + +``` + +## Ambient 선언 파일(\*.d.ts) + +[Vite][ext-vite]나 [ts-reset][ext-ts-reset] 같은 일부 패키지는 +전역 Ambient 선언이 필요합니다. +내용이 **단순하다면** `src/`에 바로 두어도 괜찮습니다. +디렉터리 구조를 **더 명확히** 하고 싶다면 `app/ambient/`에 두는 것도 좋습니다. + +타입 정의가 없는 외부 패키지에 대해서는 +`shared/lib/untyped-packages/%LIB%.d.ts` 파일을 만들고, +그 안에 직접 타입을 선언합니다. + +### 타입이 없는 외부 패키지 + +타입 정의가 없는 외부 라이브러리는 `declare module`을 사용해 미타입으로 선언하거나 직접 타입을 정의해야 합니다. +이때 권장 위치는 `shared/lib/untyped-packages`입니다. + +이 폴더 안에 **`%LIBRARY_NAME%.d.ts`** 파일을 만들고, +해당 라이브러리에 필요한 타입들을 선언하세요. + +```ts title="shared/lib/untyped-packages/use-react-screenshot.d.ts" +// 공식 타입 정의가 없는 라이브러리 예시 +declare module "use-react-screenshot"; +``` + +## 타입 자동 생성 + +외부 schema(OpenAPI 등)로부터 타입을 자동 생성하는 경우에는 전용 디렉터리를 두는 것이 좋습니다. +예를 들어 `shared/api/openapi`와 같은 폴더를 만들고, `README.md`에 다음 내용을 함께 기록해 두는 것을 추천합니다. + +- 이 폴더에 있는 파일들의 용도 +- 타입을 재생성하는 방법 (스크립트 명령어 등) + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-type-fest]: https://github.com/sindresorhus/type-fest +[ext-zod]: https://zod.dev +[ext-vite]: https://vitejs.dev +[ext-ts-reset]: https://www.totaltypescript.com/ts-reset diff --git a/src/content/docs/kr/docs/guides/issues/excessive-entities.mdx b/src/content/docs/kr/docs/guides/issues/excessive-entities.mdx new file mode 100644 index 0000000000..80e390be42 --- /dev/null +++ b/src/content/docs/kr/docs/guides/issues/excessive-entities.mdx @@ -0,0 +1,162 @@ +--- +title: Excessive Entities +sidebar: + order: 4 +--- + +import { Aside, FileTree } from '@astrojs/starlight/components'; + +Feature-Sliced Design에서 `entities` Layer는 하위 Layer에 속하며, 재사용 가능한 도메인(비지니스) 로직을 담는 곳입니다. +이 Layer는 접근성이 높아서, `shared`를 제외한 거의 모든 Layer가 `entities`를 참조할 수 있어 접근 범위가 넓습니다. + +다만 접근성이 높은 만큼 주의할 점도 있습니다. +`entities`에 코드가 추가/수정되거나 파일 경로가 바뀌면, 상위 Layer의 여러 Slice에서 그 변경을 함께 따라가야 할 수 있습니다. +그래서 리팩토링 비용이 커지기 전에, `entities`는 특히 경계와 역할을 더 명확하게 정의하고 관리하는 편이 좋습니다. + +`entities`에 코드가 불필요하게 많이 쌓이면 보통 다음 문제가 같이 나타납니다. + +- **경계가 모호해집니다**: “이 로직을 `entities`에 두는 게 맞나?” 같은 판단이 계속 필요해집니다. +- **결합도가 올라갑니다**: 여러 도메인이 서로 얽히면서 수정이 어려워집니다. +- **Import 딜레마가 생깁니다**: 코드가 동일 Layer의 다른 entity Slice로 흩어지면서, Import가 복잡해지고 선택이 어려워집니다. + +## How to keep `entities` Layer clean + +### 0. `entities` Layer 없이 시작하는 것도 가능합니다 + +`entities` Layer를 만들지 않으면 FSD가 아니라고 생각하기 쉽지만, 그렇지 않습니다. +애플리케이션에 `entities` Layer가 없어도 FSD 규칙이 깨지지 않습니다. +오히려 구조가 단순해지고, 나중에 규모가 커졌을 때 `entities`를 도입할 수 있도록 확장성을 확보할 수 있습니다. + +예를 들어 애플리케이션이 **thin client**에 가깝다면, 대부분 `entities` Layer가 필요하지 않습니다. + + + +### 1. Slice를 처음부터 잘게 나누지 않습니다 + +FSD 2.1은 Slice를 미리 잘게 쪼개기보다, 필요해졌을 때 분리하는 접근을 권장합니다. +이 원칙은 `entities` Layer에도 그대로 적용됩니다. + +처음에는 다음처럼 시작해도 됩니다. + +1. page 또는 widget/feature Slice의 `model` Segment에 로직을 둡니다. +2. 요구사항이 어느 정도 안정되고, “이 로직은 여러 곳에서 재사용된다”가 분명해졌을 때 `entities`로 옮기는 리팩토링을 고려합니다. + +여기서 중요한 점은 "언제 옮기느냐"입니다. +코드를 `entities`로 옮기는 시점이 늦을수록, 리팩토링 리스크가 줄어듭니다. +`entities`의 코드는 `shared`를 제외한 모든 Layer에서 쓰일 수 있어서, 변경이 여러 곳의 동작에 영향을 줄 수 있기 때문입니다. + +### 2. 불필요한 Entities를 만들지 않습니다 + +비즈니스 로직이 있다고 해서 항상 entity를 만들어야 하는 것은 아닙니다. +먼저 `shared/api`의 타입을 활용하고, 로직은 현재 Slice의 `model` Segment에 두는 방식을 우선 고려합니다. + +재사용 가능한 비즈니스 로직이 정말 필요하다면, 다음처럼 역할을 나누는 편이 좋습니다. + +- 데이터 정의(예: 백엔드 응답 타입)는 `shared/api`에 둡니다. +- 재사용 로직은 entity Slice의 `model` Segment에 둡니다. + + + +- entities/ + - order/ + - index.ts + - model/ + - apply-discount.ts Business logic using OrderDto from shared/api +- shared/ + - api/ + - index.ts + - endpoints/ + - order.ts + + +### 3. CRUD는 `entities`에 두지 않는 편이 좋습니다 + +CRUD는 필수지만, 많은 경우 비즈니스 의미가 크지 않은 반복 코드가 됩니다. +이런 코드가 `entities`에 쌓이면 Layer가 지저분해지고, 중요한 로직이 눈에 잘 띄지 않게 됩니다. + + +- shared/ + - api/ + - client.ts + - index.ts + - endpoints/ + - order.ts Contains all order-related CRUD operations + - products.ts + - cart.ts + + +대신 CRUD는 `shared/api`에 둡니다. + +CRUD가 단순 호출 수준을 넘어, 예를 들어 여러 요청을 묶어서 일관성을 보장해야 하거나, 실패 시 rollback, transaction 같은 처리가 필요한 경우에는 `entities`가 맞는지 다시 판단할 수 있지만, 신중하게 적용하는 편이 좋습니다. + +### 4. 인증 데이터는 `shared`에 둡니다 + +토큰이나 로그인 응답에 포함된 사용자 DTO처럼 인증 과정에서만 쓰이는 데이터는 `user` entity를 만들기보다 `shared`에 두는 편이 좋습니다. +이 데이터는 인증 Context에 종속적이며, 인증 범위를 벗어나 재사용될 가능성이 낮습니다. + +- 로그인 응답은 상황에 따라 포함하는 정보가 달라질 수 있습니다(예: 공개/비공개 프로필). +- 이런 데이터를 entity로 올려버리면, 다른 곳에서 재사용하려다가 `shared`와 `entities` 사이 의존 관계가 꼬이거나, +cross-import를 표시하기 위한 `@x` 사용이 늘면서 구조가 더 복잡해질 수 있습니다. + +따라서 인증과 직접 관련된 데이터는 `shared/auth` 또는 `shared/api`에 두는 방식을 권장합니다. + + + +- shared/ + - auth/ + - use-auth.ts authenticated user info or token + - index.ts + - api/ + - client.ts + - index.ts + - endpoints/ + - order.ts + + +인증 구현은 [Authentication 가이드](/docs/guides/examples/auth)를 참고하세요. + +### 5. Cross-import를 최소화합니다 + +FSD는 `@x` 표기를 통해 cross-import를 허용하지만, 이 방식은 기술적 문제(예: 순환 의존)를 만들 수 있습니다. +이를 피하려면 entity를 서로 섞이지 않게 분리된 도메인 단위로 설계해 cross-import 자체가 필요 없도록 만드는 편이 좋습니다. + +예를 들어 주문 아이템, 고객 정보처럼 항상 함께 움직이는 로직이 있다면, 이를 여러 entity로 쪼개기보다 order-info 같은 하나의 entity slice(모듈) 안에 캡슐화하는 방식이 더 낫습니다. + + +**Non-Isolated Business Context (Avoid):** + + +- entities/ + - order/ + - @x/ + - model/ + - order-item/ + - @x/ + - model/ + - order-customer-info/ + - @x/ + - model/ + + +**Isolated Business Context (Preferred):** + + +- entities/ + - order-info/ + - index.ts + - model/ + - order-info.ts + + +이렇게 하면 관련 코드가 한 곳에 모여 구조가 단순해지고, 강하게 결합된 로직이 여러 모듈로 흩어져 생기는 **변경 여파**도 줄일 수 있습니다. +또한 강하게 결합된 로직을 외부에서 수정/변경해야 하는 상황을 줄일 수 있습니다. diff --git a/src/content/docs/kr/docs/guides/migration/from-custom.mdx b/src/content/docs/kr/docs/guides/migration/from-custom.mdx new file mode 100644 index 0000000000..6a6bd582db --- /dev/null +++ b/src/content/docs/kr/docs/guides/migration/from-custom.mdx @@ -0,0 +1,247 @@ +--- +title: 기존 아키텍처에서 FSD로의 마이그레이션 +sidebar: + order: 1 + label: From a custom architecture +--- + +import { Aside, FileTree } from '@astrojs/starlight/components'; + +이 가이드는 기존 아키텍처를 **Feature-Sliced Design(FSD)** 으로 단계별 전환하는 방법을 설명합니다. +아래 폴더 구조를 예시로 살펴보세요. (파란 화살표를 클릭하면 펼쳐집니다). + + +- src/ + - actions/ + - product/ + - order/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - routes/ + - products.jsx + - products.[id].jsx + - utils/ + - reducers/ + - selectors/ + - styles/ + - App.jsx + - index.js + + +## 시작 전 체크리스트 \{#before-you-start} + +Feature-Sliced Design(FSD)이 **정말 필요한지 먼저 확인하세요.** +모든 프로젝트가 새로운 아키텍처를 요구하는 것은 아닙니다. + +### 전환을 고려해야 할 징후 + +1. 신규 팀원이 프로젝트에 적응하기 어려워하는 경우 +2. 코드 일부를 수정할 때, 관련 없는 다른 코드에 오류가 발생하는 경우가 **잦은** 경우 +3. 새 기능을 추가할 때 고려해야 할 사항이 너무 많아 어려움을 겪는 경우 + +**팀의 합의 없이 FSD 전환을 시작하지 마세요.** +팀 리더라도 전환의 이점이 학습/전환 비용을 상회한다는 점을 먼저 설득해야 합니다. +또한, 개선 효과가 바로 눈에 띄지 않을 수 있으므로 **팀원** 및 **프로젝트 매니저(PM)** 의 승인을 사전에 확보하고 이점을 공유하세요. + + + +--- + +마이그레이션을 시작하기로 결정했다면, `📁 src` 폴더에 별칭(alias)을 설정하는 것을 첫 단계로 삼으세요.
+ +## 1단계: 페이지 단위로 코드 분리하기 \{#divide-code-by-pages} + +대부분의 커스텀 아키텍처는 규모와 관계없이 이미 어느 정도 페이지 단위로 코드를 나누고 있습니다. +`📁 pages` 폴더가 있다면 이 단계를 건너뛰어도 됩니다. + +위에 예시 폴더처럼 `📁 routes`만 있다면 다음 순서를 따르세요. + +1. `📁 pages` 폴더를 새로 만듭니다. +2. `📁 routes`에 있던 **페이지용 컴포넌트**를 가능한 한 모두 `📁 pages` 폴더로 옮깁니다. +3. 코드를 옮길 때마다 해당 페이지 전용 폴더를 만들고 그 안에 `index.tsx` 파일을 추가해 **진입점(entry point)** 를 노출합니다. + + + +**📁 Route File** + +```js title="route file:src/routes/products.[id].js" +export { ProductPage as default } from "src/pages/product"; +``` + +**📁 Page Index File** + +```js title="src/pages/product/index.js" +export { ProductPage } from "./ProductPage.jsx"; +``` + +**📁 Page Component File** + +```jsx title="src/pages/product/ProductPage.jsx" +export function ProductPage(props) { + return
; +} +``` + +## 2단계: 페이지 외부 코드를 분리하기 \{#separate-everything-else-from-pages} + +**📁 src/shared 폴더를 만들고,** 📁 pages 또는 📁 routes를 import하지 않는 모든(파일)은 이 폴더로 모읍니다. +**📁 src/app 폴더를 만들고,** 📁 pages 또는 📁 routes를 import하는 모듈과 라우트 정의 파일은 이 폴더에 배치합니다. + +> **Shared layer는 slice 개념이 존재하지 않기 때문에,** 서로 다른 segment 간에도 자유롭게 import할 수 있습니다 + +이제 폴더 구조는 다음과 같아야 합니다: + + +- src/ + - app/ + - routes/ + - products.jsx + - products.[id].jsx + - App.jsx + - index.js + - pages/ + - product/ + - ui/ + - ProductPage.jsx + - index.js + - catalog/ + - shared/ + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## 3단계: 페이지 간 cross-imports 해결 \{#tackle-cross-imports-between-pages} + +한 페이지가 다른 페이지의 코드를 직접 import하고 있다면, 아래 두 가지 방식 중 하나로 의존성을 정리합니다. + +| 방법 | 사용 시점 | +| ----------------------------------- | -------------------------------------------------------------- | +| **A. 코드 복사하여 독립시키기** | 페이지별로 로직이 달라질 가능성이 높거나, 재사용성이 낮은 경우 | +| **B. Shared로 이동하여 공통화하기** | 여러 페이지에서 반복적으로 사용되는 경우 | + +- Shared 이동 위치 예시 + - UI 구성 요소 → `📁 shared/ui` + - 설정 상수   → `📁 shared/config` + - 백엔드 호출  → `📁 shared/api` + + + +## 4단계: Shared Layer 정리하기 \{#unpack-shared-layer} + +**한 페이지에서만 사용되는 코드**는 해당 페이지의 **slice**로 이동합니다. +`actions, reducers, selectors` 역시 예외가 아니며, **사용되는 위치와 가까운 곳**에 두는 것이 가장 좋습니다. + +Shared는 모든 layer가 의존할 수 있는 **공통 의존 지점이**기 때문에, +이곳에 코드를 과도하게 쌓아두지 않고 최소한으로 유지하는 것이 변경 위험을 줄이는 핵심 원칙입니다. + +이 단계를 마치면 폴더 구조는 아래와 같은 형태가 되는 것이 자연스럽습니다: + + +- src/ + - app/ (unchanged) + - pages/ + - product/ + - actions/ + - reducers/ + - selectors/ + - ui/ + - Component.jsx + - Container.jsx + - ProductPage.jsx + - index.js + - catalog/ + - shared/ (only objects that are reused) + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## 5단계: 기술적 목적별 segment 정리 \{#organize-by-technical-purpose} + +| segment | 용도 예시 | +| -------- | ---------------------------------- | +| `ui` | Components, formatters, styles | +| `api` | Backend requests, DTOs, mappers | +| `model` | Store, schema, business logic | +| `lib` | Shared utilities / helpers | +| `config` | Configuration files, feature flags | + +> **무엇인지**가 아니라 **무엇을 위해 존재하는지**를 기준으로 폴더를 구분합니다. +> 따라서 `components`, `utils`, `types`처럼 목적이 모호한 폴더 이름은 지양합니다. + +1. **각 페이지 내부**에서, 필요한 `segment(ui, model, api 등)`를 구성합니다. +2. **Shared 폴더는 공통 기능만 남기도록 정리합니다.** + - `components/containers` → `shared/ui` + - `helpers/utils` → `shared/lib` (기능별 그룹화 후) + - `constants` → `shared/config` + +## 선택 단계 \{#optional-steps} + +### 6단계: 여러 페이지에서 재사용되는 Redux slice를 Entities / Features layer로 분리하기 \{#form-entities-features-from-redux} + +여러 페이지에서 반복적으로 사용되는 Redux **slice**는 대부분 **product, user**처럼 명확한 **business entity**를 표현합니다. +이러한 slice는 **Entities layer**로 이동하며, **entity**마다 별도의 폴더를 구성합니다. +반대로, 댓글 작성처럼 **사용자의 특정 행동(action)** 을 중심으로 한 **slice**는 **Features layer**로 옮겨 독립적으로 관리합니다. + +**Entities**와 **Features**는 서로 의존하지 않고 사용할 수 있도록 설계해야 합니다. +Entity 간의 관계가 필요하다면 [Business-Entities Cross-Relations 가이드][business-entities-cross-relations]를 참고해 구조화하면 됩니다. +해당 **slice**와 연관된 API 함수는 `📁 shared/api`에 그대로 두어도 괜찮습니다. + +### 7단계: modules 폴더 리팩터링 \{#refactor-your-modules} + +`📁 modules`는 과거에 비즈니스 로직을 모아두던 공간으로, 성격상 **Features layer**와 비슷합니다. +다만, 앱 Header처럼 **large UI block**(예: global Header, Sidebar)이라면 **Widgets layer**로 옮기는 편이 좋습니다. + +### 8단계: shared/ui에 presentational UI 기반 마련하기 \{#form-clean-ui-foundation} + +`📁 shared/ui`에는 비즈니스 로직이 전혀 없는, 재사용 가능한 presentational UI 컴포넌트만 남겨야 합니다. +기존 `📁 components / 📁 containers`에 있던 컴포넌트에서 비즈니스 로직을 분리해 상위 layer로 이동시킵니다. +여러 곳에서 쓰이지 않는 부분은 **복사(paste)** 해서 각 layer에서 독립적으로 관리해도 문제 없습니다. + +## 참고 자료 \{#see-also} + +- [(러시아어 영상) Ilya Klimov — "끝없는 리팩터링의 악순환에서 벗어나기: 기술 부채가 동기와 제품에 미치는 영향](https://youtu.be/aOiJ3k2UvO4) + +[ext-steiger]: https://github.com/feature-sliced/steiger +[business-entities-cross-relations]: /docs/guides/examples/types#business-entities-and-their-cross-references diff --git a/src/content/docs/kr/docs/guides/migration/from-v1.mdx b/src/content/docs/kr/docs/guides/migration/from-v1.mdx new file mode 100644 index 0000000000..85cd677e4a --- /dev/null +++ b/src/content/docs/kr/docs/guides/migration/from-v1.mdx @@ -0,0 +1,152 @@ +--- +title: Migration from v1 to v2 +sidebar: + order: 2 +--- + +## v2 도입 배경 + +**feature-slices** 개념은 2018년 [첫 발표][ext-tg-spb]된 이후 다양한 프로젝트 경험과 커뮤니티 피드백을 통해 지속적으로 발전해 왔습니다. +그 과정에서도 **[기본 원칙][ext-v1]**-표준화된 프로젝트 구조, 비즈니스 로직 기반 분리, isolated features, Public API—는 그대로 유지되었습니다. + +그러나 v1에는 다음과 같은 한계가 존재했습니다: + +- 과도한 **boilerplate** 발생 +- 추상화 규칙이 모호해 **코드베이스 복잡도** 상승 +- 암묵적 설계로 **확장/온보딩 어려움** + +이를 해결하기 위해 등장한 것이 **[v2][ext-v2]** 입니다. +v2는 기존 장점을 유지하는 동시에 이러한 문제들을 보완하도록 설계되었습니다. +또한 [Oleg Isonen][ext-kof]이 발표한 [feature-driven][ext-fdd] 등 유사 방법론의 장점을 반영해 더 **유연하고**, **명확하며**, **효율적인** 구조로 발전했습니다. + +> 이 과정에서 방법론의 공식 명칭은 feature-slice에서 **feature-sliced**로 정식화되었습니다. + +## v2 마이그레이션 이유 + +> `WIP:` 문서는 계속 업데이트 중이며, 일부 내용은 변경될 수 있습니다. + +### 직관적 구조 제공 + +v2는 **layer → slice → segment** 라는 세 가지 개념만 이해하면 구조적 결정을 쉽게 내릴 수 있습니다. +덕분에 신규 팀원이 **어디에 어디에 둘지** 고민할 필요가 줄어들어 온보딩 속도가 크게 향상됩니다. + +### 유연한 모듈화 + +- **독립 영역**은 slice 단위로, **전역 흐름**은 Processes layer로 분리해 확장성을 확보합니다. +- 새로운 module을 추가할 때 _(layer → slice → segment)_ 규칙만 따르면 폴더 재배치나 리팩터링 부담이 크게 줄어듭니다. + +#### 커뮤니티/도구 지원 확대 + +v2는 **코어 팀** 과 커뮤니티가 함께 발전시키고 있으며, 다음과 같은 리소스도 제공됩니다. +다음 리소스를 활용해 보세요: + +- **실제 사례 공유**: 다양한 프로젝트 환경에서의 적용 사례 +- **단계별 가이드**: 설정·구성·운영 전 과정을 담은 튜토리얼 +- **코드 템플릿 & 예제**: 시작부터 배포까지 참고할 수 있는 실전 코드 +- **온보딩 문서**: 신규 개발자를 위한 개념 요약 및 학습 자료 +- **검증 툴킷**: steiger CLI 등 정책 준수/lint를 지원하는 유틸리티 + +> v1도 계속 지원되지만, 새로운 기능과 개선은 **v2**에 우선 적용됩니다. +> 주요 업데이트 시 **안정적인 마이그레이션 경로**도 함께 제공합니다. + +## 주요 변경 사항 + +### Layer 구조 명확화 + +v2는 layer를 다음과 같이 명확히 구분합니다: + +`/app` > `/processes` > **`/pages`** > **`/features`** > `/entities` > `/shared` + +모든 모듈이 `pages, features`에만 속하지 않습니다. +이 구조는 [layer 의존 규칙][ext-tg-v2-draft]을 명확히 설정할 수 있도록 돕습니다. +**상위 layer**는 더 넓은 **context**를 제공하며, **하위 layer**는 더 낮은 **변경 리스크와 높은 재사용성**을 갖습니다. + +### Shared 통합 + +`src` 루트에 흩어져 있던 UI, lib, API 인프라 추상화를 `/src/shared`로 통합했습니다. + +- `shared/ui` - 공통 UI components(선택 사항) + - _기존 `Atomic Design` 사용도 가능합니다._ +- `shared/lib` - 재사용 가능한 helper libraries + - _무분별한 helper dump 지양_ +- `shared/api` - API entry points + - _각 feature/page 내 local 정의 가능하지만, 전역 entry point 집중을 권장_ +- `shared` 폴더에는 **business logic** 의존을 두지 않습니다 + - _불가피할 경우 `entities` layer 이상으로 로직을 옮기세요._ + +### Entities / Processes Layer 추가 + +v2에서는 로직 복잡성과 높은 결합을 줄이기 위한 **새로운 추상화**가 추가되었습니다. + +- **`/entities`** + 프론트엔드에서 사용되는 **business entities**(예: `user`, `order`, `i18n`, `blog`)를 담당하는 layer입니다. +- **`/processes`** + 애플리케이션 전반에 걸친 **비즈니스 process**(예: `payment`, `auth`, `quick-tour`)를 캡슐화하는 선택적 layer입니다. + process _로직이 여러 페이지에 분산될 때_ 도입을 권장합니다. + +### 추상화/네이밍 가이드 + +아래는 v2 권장 네이밍과 이전 명칭 간의 대응 관계입니다. +아래에서는 v2 권장 layer·segment 명칭을 이전 명칭과 대응하여 정리했습니다. +추상화/네이밍 관련 상세 가이드는 [명확한 네이밍 권장사항][refs-adaptability]을 참고하세요. + +[disc-process]: https://github.com/feature-sliced/documentation/discussions/20 +[disc-features]: https://github.com/feature-sliced/documentation/discussions/23 +[disc-entities]: https://github.com/feature-sliced/documentation/discussions/18#discussioncomment-422649 +[disc-shared]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453020 +[disc-ui]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453132 +[disc-model]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-472645 +[disc-api]: https://github.com/feature-sliced/documentation/discussions/66 + +#### Layer + +- `/app` — **Application init** + - _이전 명칭: `app`, `core`,`init`, `src/index` (가끔 사용됨)_ +- `/processes` — [**Business process**][disc-process] + - _이전 명칭: `processes`, `flows`, `workflows`_ +- `/pages` — **Application page** + - _이전 명칭: `pages`, `screens`, `views`, `layouts`, `components`, `containers`_ +- `/features` — [**Feature module**][disc-features] + - _이전 명칭: `features`, `components`, `containers`_ +- `/entities` — [**Business entity**][disc-entities] + - _이전 명칭: `entities`, `models`, `shared`_ +- `/shared` — [**Infrastructure**][disc-shared] 🔥 + - _이전 명칭: `shared`, `common`, `lib`_ + +#### Segment + +- `/ui` — [**UI segment**][disc-ui] 🔥 + - _이전 명칭: `ui`, `components`, `view`_ +- `/model` — [**비즈니스 로직 segment**][disc-model] 🔥 + - _이전 명칭: `model`, `store`, `state`, `services`, `controller`_ +- `/lib` — **보조 코드 segment** + - _이전 명칭: `lib`, `libs`, `utils`, `helpers`_ +- `/api` — [**API segment**][disc-api] + - _이전 명칭: `api`, `service`, `requests`, `queries`_ +- `/config` — **애플리케이션 설정 segment** + - _이전 명칭: `config`, `env`, `get-env`_ + +## 낮은 결합 원칙 강화 + +Layer 구조가 명확해지면서 [Zero-Coupling, High-Cohesion 원칙][refs-low-coupling]을 보다 쉽게 지킬 수 있게 되었습니다. +완전한 분리가 어렵다면 Public API 등 명확하게 드러나는 인터페이스를 두어 경계를 명확히 하고, +가능한 한 하위 layer에서 의존성이 내려가도록 구조화할 것을 권장합니다. + +## 참고 자료 + +- [React SPB Meetup #1 발표 노트][ext-tg-spb] +- [React Berlin Talk - Oleg Isonen Feature Driven Architecture][ext-kof-fdd] +- [v1↔v2 구조 비교(텔레그램)](https://t.me/feature_sliced/493) +- [v2에 대한 새로운 아이디어와 설명 (atomicdesign 채팅)][ext-tg-v2-draft] +- [v2 추상화·네이밍 공식 논의](https://github.com/feature-sliced/documentation/discussions/31) + +[refs-low-coupling]: /docs/reference/slices-segments#zero-coupling-high-cohesion +[refs-adaptability]: /docs/about/understanding/naming +[ext-v1]: https://feature-sliced.github.io/featureslices.dev/v1.0.html +[ext-tg-spb]: https://t.me/feature_slices +[ext-fdd]: https://github.com/feature-sliced/documentation/tree/rc/feature-driven +[ext-fdd-issues]: https://github.com/kof/feature-driven-architecture/issues +[ext-v2]: https://github.com/feature-sliced/documentation +[ext-kof]: https://github.com/kof +[ext-kof-fdd]: https://www.youtube.com/watch?v=BWAeYuWFHhs +[ext-tg-v2-draft]: https://t.me/atomicdesign/18708 diff --git a/src/content/docs/kr/docs/guides/migration/from-v2-0.mdx b/src/content/docs/kr/docs/guides/migration/from-v2-0.mdx new file mode 100644 index 0000000000..5d04f5bc01 --- /dev/null +++ b/src/content/docs/kr/docs/guides/migration/from-v2-0.mdx @@ -0,0 +1,97 @@ +--- +title: Migration from v2.0 to v2.1 +sidebar: + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + +v2.1의 핵심 변화는 Page 중심(Page-First) 접근 방식을 기반으로 인터페이스 구조를 재정비한 것입니다. + +## v2.0 접근 방식 + +v2.0에서는 애플리케이션을 **Entity**와 **Feature** 단위로 세분화하여 구성했습니다. +화면을 이루는 가장 작은 단위(entity 표현, 상호작용 요소 등)를 잘게 나눈 뒤, +이를 **Widget**으로 조합하고, 최종적으로 **Page**를 구성하는 방식이었습니다. + +이 방식은 재사용성과 모듈화 측면에서 장점이 있었지만, 다음과 같은 문제가 발생했습니다: +비즈니스 로직이 대부분 **entity/feature** layer에 과도하게 집중되었고, +Page는 단순한 조합 계층으로 남아 고유한 책임이 약해지는 문제가 나타났습니다. + +## v2.1 접근 방식 + +v2.1은 **Pages-First** 사고방식을 도입합니다. +개발자가 실제로 코드베이스를 탐색할 때 Page 단위로 구조를 파악하는 것이 더 자연스럽고, +구성 요소를 찾는 출발점도 대부분 Page이기 때문입니다. + +v2.1의 핵심 원칙은 다음과 같습니다: + +- Page 내부에 주요 UI와 비즈니스 로직을 배치합니다. +- Shared layer에는 순수 재사용 요소만 유지합니다. +- 여러 Page에서 실제로 공유되는 로직만 Feature/Entity로 분리합니다. + +이 접근 방식의 장점 + +1. **Page가 명확한 책임 단위**가 되어, 코드의 역할이 분명해집니다. +2. **Shared** layer가 불필요하게 비대해지는 것을 방지해 의존성이 간결해집니다. +3. 공통 로직을 실제로 재사용할 때만 분리하므로 과도한 추상화가 줄어듭니다. + +또한 v2.1에서는 **Entity 간 cross-import**를 위한 `@x` 표기법이 **표준화**되었습니다. +이를 통해 import 경로를 더 명확하고 일관되게 관리할 수 있습니다. + + +## 마이그레이션 프로세스 \{#how-to-migrate} + +v2.1은 하위 호환성을 제공합니다. +즉, 기존 v2.0 프로젝트는 **수정 없이** 그대로 동작합니다. +다만 v2.1의 구조적 장점을 활용하려면 아래 단계를 차례로 적용하면 됩니다. + +### 1. Slice 병합 + +v2.1 Page-First 모델에서는 **여러 Page에서 재사용되지 않는** slice는 Page 내부로 병합하는 것을 권장합니다. +이렇게 하면 코드 탐색이 빨라지고 유지보수 비용도 줄어듭니다. + +#### Steiger로 자동 탐지하기 + +프로젝트 루트에서 [Steiger][steiger]를 실행하면 v2.1 mental model에 맞추어 slice 사용 여부를 자동으로 분석해줍니다: + +```bash +npx steiger src +``` + +- [`insignificant-slice`][insignificant-slice] + 단일 Page에서만 사용되는 slice를 탐지합니다. + → **Page 내부로 병합하는 것을 권장**합니다. +- [`excessive-slicing`][excessive-slicing] + 지나치게 잘게 나뉜 slice를 찾아줍니다. + → **유사한 slice를 통합하거나 그룹화하여 탐색성**을 높입니다. + + +이 명령으로 `한 번만 쓰이는 slice` 목록이 출력됩니다. +이제 각 slice의 재사용 여부를 검토하고, 과하다면 해당 page로 병합하거나 비슷한 역할끼리 묶어 보세요. + + + +### 2. Cross Import 표준화 + +v2.1에서는 Entity 간 cross-import를 위해 `@x-` 표기법을 사용합니다. + +```ts title="entities/B/some/file.ts" +// v2.1 권장 방식 +import type { EntityA } from "entities/A/@x/B"; +``` + +자세한 내용은 [Public API for cross-imports][public-api-for-cross-imports] 문서를 참고하세요. + +[insignificant-slice]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice +[steiger]: https://github.com/feature-sliced/steiger +[excessive-slicing]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/kr/docs/guides/tech/with-electron.mdx b/src/content/docs/kr/docs/guides/tech/with-electron.mdx new file mode 100644 index 0000000000..2627b21581 --- /dev/null +++ b/src/content/docs/kr/docs/guides/tech/with-electron.mdx @@ -0,0 +1,143 @@ +--- +title: Usage with Electron +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +Electron 애플리케이션은 역할이 다른 여러 **프로세스**(Main, Renderer, Preload)로 구성됩니다. +따라서 FSD를 적용하려면 Electron 특성에 맞게 구조를 조정해야 합니다. + + +- src + - app Common app layer + - main Main process + - index.ts Main process entry point + - preload Preload script and Context Bridge + - index.ts Preload entry point + - renderer Renderer process + - index.html Renderer process entry point + - main + - features + - user + - ipc + - get-user.ts + - send-user.ts + - entities + - shared + - renderer + - pages + - settings + - ipc + - get-user.ts + - save-user.ts + - ui + - user.tsx + - index.ts + - home + - ui + - home.tsx + - index.ts + - widgets + - features + - entities + - shared + - shared Common code between main and renderer + - ipc IPC description (event names, contracts) + + +## Public API 규칙 + +- 각 프로세스는 자신만의 Public API를 가져야 합니다. + - 예) `renderer` 코드가 `main` 폴더 모듈을 직접 import 하면 안 됩니다. + +- 단, `src/shared` 폴더는 두 프로세스 모두에게 공개됩니다. + - (프로세스 간 통신 계약과 타입 정의를 위해 필요합니다) + +## 표준 구조의 추가 변경 사항 + +- **`ipc` segment**를 새로 만들어, 프로세스 간 통신(채널, 핸들러)을 한곳에 모읍니다. +- `src/main`에는 이름 그대로 **`pages`, `widgets` layer를 두지 않습니다.** + 대신 `features`, `entities`, `shared`만 사용합니다. +- `src/app` layer는 **Main, Renderer entry**와 **IPC initialization code**만 담는 전용 영역입니다. +- `app` layer 내부의 각 segment는 서로 **교차 의존**이 발생하지 않도록 구성하는 것이 좋습니다. + +## Interaction example + +```typescript title="src/shared/ipc/channels.ts" +export const CHANNELS = { + GET_USER_DATA: 'GET_USER_DATA', + SAVE_USER: 'SAVE_USER', +} as const; + +export type TChannelKeys = keyof typeof CHANNELS; +``` + +```typescript title="src/shared/ipc/events.ts" +import { CHANNELS } from './channels'; + +export interface IEvents { + [CHANNELS.GET_USER_DATA]: { + args: void, + response?: { name: string; email: string; }; + }; + [CHANNELS.SAVE_USER]: { + args: { name: string; }; + response: void; + }; +} +``` + +```typescript title="src/shared/ipc/preload.ts" +import { CHANNELS } from './channels'; +import type { IEvents } from './events'; + +type TOptionalArgs = T extends void ? [] : [args: T]; + +export type TElectronAPI = { + [K in keyof typeof CHANNELS]: (...args: TOptionalArgs) => IEvents[typeof CHANNELS[K]]['response']; +}; +``` + +```typescript title="src/app/preload/index.ts" +import { contextBridge, ipcRenderer } from 'electron'; +import { CHANNELS, type TElectronAPI } from 'shared/ipc'; + +const API: TElectronAPI = { + [CHANNELS.GET_USER_DATA]: () => ipcRenderer.sendSync(CHANNELS.GET_USER_DATA), + [CHANNELS.SAVE_USER]: args => ipcRenderer.invoke(CHANNELS.SAVE_USER, args), +} as const; + +contextBridge.exposeInMainWorld('electron', API); +``` + +```typescript title="src/main/features/user/ipc/send-user.ts" +import { ipcMain } from 'electron'; +import { CHANNELS } from 'shared/ipc'; + +export const sendUser = () => { + ipcMain.on(CHANNELS.GET_USER_DATA, ev => { + ev.returnValue = { + name: 'John Doe', + email: 'john.doe@example.com', + }; + }); +}; +``` + +```typescript title="src/renderer/pages/user-settings/ipc/get-user.ts" +import { CHANNELS } from 'shared/ipc'; + +export const getUser = () => { + const user = window.electron[CHANNELS.GET_USER_DATA](); + + return user ?? { name: 'John Donte', email: 'john.donte@example.com' }; +}; +``` + +## 참고 자료 +- [Process Model Documentation](https://www.electronjs.org/docs/latest/tutorial/process-model) +- [Context Isolation Documentation](https://www.electronjs.org/docs/latest/tutorial/context-isolation) +- [Inter-Process Communication Documentation](https://www.electronjs.org/docs/latest/tutorial/ipc) +- [Example](https://github.com/feature-sliced/examples/tree/master/examples/electron) diff --git a/src/content/docs/kr/docs/guides/tech/with-nextjs.mdx b/src/content/docs/kr/docs/guides/tech/with-nextjs.mdx new file mode 100644 index 0000000000..5d1288d05c --- /dev/null +++ b/src/content/docs/kr/docs/guides/tech/with-nextjs.mdx @@ -0,0 +1,134 @@ +--- +title: Usage with Next.js +sidebar: + order: 1 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +NextJS 프로젝트에도 FSD 아키텍처를 적용할 수 있지만, 구조적 차이로 두 가지 충돌이 발생합니다. + +- **`pages` layer 라우팅 파일** +- **NextJS에서 `app` layer의 충돌 또는 미지원** + +## `pages` layer 충돌 \{#pages-conflict} + +NextJS는 파일 시스템 기반 라우팅을 위해 **`pages` 폴더**의 파일을 URL에 매핑합니다. +그러나 이 방식은 FSD에서 권장하는 **평탄(flat)한 slice 구조**와 맞지 않아 충돌이 발생합니다. + +### NextJS `pages` 폴더를 Project Root로 이동 (권장) + +`pages` 폴더를 **프로젝트 최상위**로 옮긴 뒤,
+FSD `src/pages`의 각 페이지 컴포넌트를 `pages` 폴더에서 **re-export** 하면 NextJS 라우팅과 FSD 구조를 모두 유지할 수 있습니다. + + + +- pages/ NextJS 라우팅 폴더 (FSD pages를 재-export) + - index.tsx + - about.tsx +- src/ + - app/ + - entities/ + - features/ + - pages/ FSD pages layer + - shared/ + - widgets/ + + +### FSD pages layer 이름 변경 + +FSD의 `pages` layer 이름을 변경해 NextJS `pages` 폴더와 충돌을 방지할 수 있습니다. +예를 들어, `pages`를 `views`로 바꾸면 라우팅 폴더와 FSD 페이지 layer를 동시에 사용할 수 있습니다. + + +- app/ +- entities/ +- features/ +- pages/ NextJS 라우팅 폴더 +- views/ 변경된 FSD pages layer +- shared/ +- widgets/ + + +폴더 이름을 변경했다면 프로젝트 README나 내부 문서에 반드시 기록해야 합니다. +이 내용을 [프로젝트 지식][project-knowledge]에 포함해 팀원들이 쉽게 확인할 수 있도록 하세요. + +## NextJS에서 `app` layer 구현하기 \{#app-absence} + +NextJS 13 이전 버전에는 FSD app layer에 대응하는 전용 폴더가 없습니다. +대신 pages/_app.tsx가 모든 페이지의 wrapping component로 작동합니다. +이 파일에서 전역 상태 관리(global state management)와 레이아웃 구성(layout)을 담당합니다. + +### `pages/_app.tsx`에 app layer 기능 통합하기 + +먼저 `src/app/providers/index.tsx`에 `App` 컴포넌트를 정의합니다. +이 컴포넌트에서 전체 애플리케이션의 provider와 layout을 설정합니다. + +```tsx +// app/providers/index.tsx + +const App = ({ Component, pageProps }: AppProps) => { + return ( + + + + + + + + ); +}; + +export default App; +``` + +다음으로 `pages/_app.tsx`에서 위 `App` 컴포넌트를 export합니다. +이 과정에서 global style도 함께 import할 수 있습니다. + +```tsx +// pages/_app.tsx + +import 'app/styles/index.scss' + +export { default } from 'app/providers'; +``` + +## App Router 사용하기 \{#app-router} + +NextJS 13.4부터 `app` 폴더 기반 App Router를 지원합니다. +FSD 아키텍처를 App Router와 함께 사용하려면 다음 구조를 적용하세요. + +`app` 폴더는 NextJS App Router 전용입니다. +`src/app`은 FSD의 app layer를 유지합니다. +필요에 따라 App Router와 Pages Router를 함께 사용할 수 있습니다. + + +- app/ NextJS의 App Router용 폴더 +- pages/ NextJS의 Pages Router용 폴더 (선택적) + - README.md 폴더의 용도 설명 +- src/ + - app/ FSD의 app layer + - entities/ + - features/ + - pages/ FSD의 pages layer + - shared/ + - widgets/ + + +`app` 폴더에서 `src/pages`의 컴포넌트를 re-export하세요. +App Router만 사용해도 `Pages Router`와의 호환성을 위해 `root pages` 폴더를 유지합니다. + + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)][ext-app-router-stackblitz] + +## Middleware \{#middleware} + +NextJS middleware 파일은 반드시 프로젝트 root 폴더(`app` 또는 `pages` 폴더와 동일 수준)에 둬야 합니다. +`src` 아래에 두면 NextJS가 인식하지 않으므로, middleware 파일을 root로 이동하세요. + +## 참고 자료 \{#see-also} + +- [(스레드) NextJS의 pages 폴더에 대한 토론](https://t.me/feature_sliced/3623) + +[project-knowledge]: /docs/about/understanding/knowledge-types +[ext-app-router-stackblitz]: https://stackblitz.com/edit/stackblitz-starters-aiez55?file=README.md diff --git a/src/content/docs/kr/docs/guides/tech/with-nuxtjs.mdx b/src/content/docs/kr/docs/guides/tech/with-nuxtjs.mdx new file mode 100644 index 0000000000..fd8ba2f886 --- /dev/null +++ b/src/content/docs/kr/docs/guides/tech/with-nuxtjs.mdx @@ -0,0 +1,195 @@ +--- +title: Usage with NuxtJS +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +NuxtJS 프로젝트에 FSD(Feature-Sliced Design)를 도입할 때는 기본 구조와 FSD 원칙 간에 다음과 같은 차이를 고려해야 합니다: + +- NuxtJS는 `src` 폴더 없이 project root에서 파일을 관리합니다. +- NuxtJS는 `pages` 폴더 기반 파일 라우팅을 사용하지만, FSD는 slice 관점에서 폴더를 구성합니다. + +## `src` 폴더 alias 설정하기 + +NuxtJS 프로젝트에도 `src` 폴더를 두고 싶다면, `nuxt.config.ts`의 `alias`에 매핑을 추가하세요. + +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // 개발 도구 활성화(선택 사항) + alias: { + "@": '../src' // root의 src 폴더를 @로 참조 + }, +}) +``` +## 라우터 설정 방법 선택하기 + +NuxtJS에서는 두 가지 라우팅 방식을 지원합니다: + +- **파일 기반 라우팅**: `src/app/routes` 폴더 내 `.vue` 파일을 자동으로 라우트로 등록 +- **설정 기반 라우팅**: `src/app/router.options.ts`에서 라우트를 직접 정의 + +### 설정 기반 라우팅 + +`src/app/router.options.ts` 파일을 생성한 뒤, 아래와 같이 `RouterConfig`를 정의하세요: + +```ts title="app/router.options.ts" +import type { RouterConfig } from '@nuxt/schema'; + +export default { + routes: (_routes) => [], +}; +``` + +Home 페이지를 추가하려면 다음 순서로 진행합니다. + +1. `pages` layer에 Home page slice를 생성합니다. +2. `app/router.options.ts`에 Home 라우트를 등록합니다. + +page slice는 [CLI](https://github.com/feature-sliced/cli)를 사용하여 생성할 수 있습니다: + +```shell +fsd pages home +``` + +`src/pages/home/ui/home-page.vue`를 만든 뒤, Public API로 노출합니다. + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +프로젝트 구조는 다음과 같습니다. + + +- src + - app + - router.options.ts + - pages + - home + - ui + - home-page.vue + - index.ts + + +이제 `router.options.ts`의 routes 배열에 Home 라우트를 추가합니다. + +```ts title="app/router.options.ts" +import type { RouterConfig } from '@nuxt/schema' + +export default { + routes: (_routes) => [ + { + name: 'home', + path: '/', + component: () => import('@/pages/home.vue').then(r => r.default || r) + } + ], +} +``` + +### 파일 기반 라우팅 + +#### `src` 폴더와 라우트 폴더 구성 + +루트에 `src` 폴더를 만들고 그 안에 `app`과 `pages` layer를 생성합니다. +`app` layer에 `routes` 폴더를 추가해 Nuxt 라우트를 관리합니다. + + +- src + - app + - routes + - pages FSD Pages layer + + +#### nuxt.config.ts에서 라우트 폴더 변경 + +`pages` 폴더 대신 `app/routes` 폴더를 라우트 폴더로 사용하도록 설정하려면, `nuxt.config.ts` 파일을 수정해야 합니다. + +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // 개발 도구 활성화 (FSD와 무관) + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes' + } +}) +``` + +이제 `app/routes`에서 라우트를 만들고 `pages`의 컴포넌트를 연결할 수 있습니다. + +`Home` 페이지를 추가하려면: +- `pages` layer에 slice를 생성합니다. +- `app/routes`에 라우트를 생성합니다. +- page slice의 컴포넌트를 라우트에서 사용할 수 있도록 연결합니다. + + +#### 1. page slice 생성 + +page slice는 [CLI](https://github.com/feature-sliced/cli)를 사용하여 간편하게 생성할 수 있습니다: + +```shell +fsd pages home +``` + +이제 `ui` segment 내에 `home-page.vue` 파일을 생성하고, Public API를 통해 이를 노출합니다: + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +#### 2. `app/routes` 내에 라우트 추가 + +생성한 page를 라우트와 연결하려면, `app/routes/index.vue` 파일을 생성하고 `HomePage` 컴포넌트를 등록해야 합니다. + + +- src + - app + - routes + - index.vue + - pages + - home + - ui + - home-page.vue + - index.ts + + +#### 3. `index.vue`에서 page 컴포넌트 등록 + +```html title="src/app/routes/index.vue" + + + +``` + +이제 `HomePage`가 Nuxt 라우팅으로 정상 렌더링됩니다. + +## `layouts` 관리하기 + +레이아웃 파일을 `src/app/layouts`에 두고, `nuxt.config.ts`의 `dir.layouts`에 경로를 지정합니다. + +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // 개발 도구 활성화 (FSD와 무관) + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes', + layouts: './src/app/layouts' + } +}) +``` + + +## 참고 자료 + +- [NuxtJS dir 설정 문서](https://nuxt.com/docs/api/nuxt-config#dir) +- [NuxtJS 라우터 설정 변경 문서](https://nuxt.com/docs/guide/recipes/custom-routing#router-config) +- [NuxtJS 별칭(alias) 설정 문서](https://nuxt.com/docs/api/nuxt-config#alias) diff --git a/src/content/docs/kr/docs/guides/tech/with-react-query.mdx b/src/content/docs/kr/docs/guides/tech/with-react-query.mdx new file mode 100644 index 0000000000..96fb9d81ba --- /dev/null +++ b/src/content/docs/kr/docs/guides/tech/with-react-query.mdx @@ -0,0 +1,453 @@ +--- +title: Usage with React Query +sidebar: + order: 10 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## Query Key 배치 문제 + +### entities별 분리 + +각 요청이 특정 entity에 대응한다면, +`src/entities/{entity}/api` 폴더에 관련 코드를 모아두세요: + + +- src/ + - app/ + - ... + - pages/ + - ... + - entities/ + - \{entity\}/ + - ... + - api/ + - `{entity}.query` Query-factory where are the keys and functions + - `get-{entity}` Entity getter function + - `create-{entity}` Entity creation function + - `update-{entity}` Entity update function + - `delete-{entity}` Entity delete function + - ... + - features/ + - ... + - widgets/ + - ... + - shared/ + - ... + + +entities 간에 데이터를 참조해야 하면 [공용 Public API][public-api-for-cross-imports]를 사용하거나, +아래 예시처럼 `shared/api/queries`에 모아두는 방법도 있습니다. + +### 대안 — shared에 모아두기 + +entity별 분리가 어려울 때는 예시 처럼 `src/shared/api/queries`에 Query Factory를 정의하세요. + + +- src/ + - ... + - shared/ + - api/ + - ... + - queries Query-factories + - document.ts + - background-jobs.ts + - ... + - index.ts + + +이후 `@/shared/api/index.ts`에서 다음과 같이 사용합니다: + +```ts title="@/shared/api/index.ts" +export { documentQueries } from "./queries/document"; +``` + +## Mutation 배치 문제 + +Query와 Mutation을 같은 위치에 두는 것은 권장하지 않습니다. +두 가지 방안을 제안합니다: + +### 사용 위치 근처 api 폴더에 Custom Hook 정의 + +```tsx title="@/features/update-post/api/use-update-title.ts" +export const useUpdateTitle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, newTitle }) => + apiClient + .patch(`/posts/${id}`, { title: newTitle }) + .then((data) => console.log(data)), + + onSuccess: (newPost) => { + queryClient.setQueryData(postsQueries.ids(id), newPost); + }, + }); +}; +``` + +### entities 또는 shared에 함수만 정의하고, 컴포넌트에서 `useMutation` 사용 + +```tsx +const { mutateAsync, isPending } = useMutation({ + mutationFn: postApi.createPost, +}); +``` + +```tsx title="@/pages/post-create/ui/post-create-page.tsx" +export const CreatePost = () => { + const { classes } = useStyles(); + const [title, setTitle] = useState(""); + + const { mutate, isPending } = useMutation({ + mutationFn: postApi.createPost, + }); + + const handleChange = (e: ChangeEvent) => + setTitle(e.target.value); + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutate({ title, userId: DEFAULT_USER_ID }); + }; + + return ( +
+ + + Create + + + ); +}; +``` + +## Request 조직화 + +### Query Factory + +Query Factory는 Query Key와 Query Function을 한곳에서 관리합니다. +다음 예시처럼 객체로 정의하세요: + +```ts +const keyFactory = { + all: () => ["entity"], + lists: () => [...postQueries.all(), "list"], +}; +``` + + + +### Query Factory 생성 예시 + +```tsx title="@/entities/post/api/post.queries.ts" +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; +import { getDetailPost } from "./get-detail-post"; +import { PostDetailQuery } from "./query/post.query"; + +export const postQueries = { + all: () => ["posts"], + + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), + + details: () => [...postQueries.all(), "detail"], + detail: (query?: PostDetailQuery) => + queryOptions({ + queryKey: [...postQueries.details(), query?.id], + queryFn: () => getDetailPost({ id: query?.id }), + staleTime: 5000, + }), +}; +``` + +### 애플리케이션 코드에서의 Query Factory 사용 예시 +```tsx +import { useParams } from "react-router-dom"; +import { postApi } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; + +type Params = { + postId: string; +}; + +export const PostPage = () => { + const { postId } = useParams(); + const id = parseInt(postId || ""); + const { + data: post, + error, + isLoading, + isError, + } = useQuery(postApi.postQueries.detail({ id })); + + if (isLoading) { + return
Loading...
; + } + + if (isError || !post) { + return <>{error?.message}; + } + + return ( +
+

Post id: {post.id}

+
+

{post.title}

+
+

{post.body}

+
+
+
Owner: {post.userId}
+
+ ); +}; +``` + +### Query Factory 사용의 장점 +- **Request 구조화**: 모든 API 호출을 Factory 패턴으로 통합 관리해, 코드 가독성과 유지보수성을 개선합니다. +- **Query와 Key에 대한 편리한 접근**: 다양한 Query Type과 해당 Key를 메서드로 제공해, 언제든 간편하게 참조할 수 있습니다. +- **Query Invalidation 용이성**: Query Key를 직접 수정하지 않고도 원하는 Query를 손쉽게 무효화할 수 있습니다. + +## Pagination + +Pagination을 적용해 `getPosts` 함수로 게시물 목록을 가져오는 과정을 설명합니다. + +### `getPosts` 함수 생성하기 + +`src/pages/post-feed/api/get-posts.ts` 파일에 다음과 같이 정의됩니다. + +```tsx title="@/pages/post-feed/api/get-posts.ts" +import { apiClient } from "@/shared/api/base"; + +import { PostWithPaginationDto } from "./dto/post-with-pagination.dto"; +import { PostQuery } from "./query/post.query"; +import { mapPost } from "./mapper/map-post"; +import { PostWithPagination } from "../model/post-with-pagination"; + +const calculatePostPage = (totalCount: number, limit: number) => + Math.floor(totalCount / limit); + +export const getPosts = async ( + page: number, + limit: number, +): Promise => { + const skip = page * limit; + const query: PostQuery = { skip, limit }; + const result = await apiClient.get("/posts", query); + + return { + posts: result.posts.map((post) => mapPost(post)), + limit: result.limit, + skip: result.skip, + total: result.total, + totalPages: calculatePostPage(result.total, limit), + }; +}; +``` + +### 페이지네이션용 Query Factory 정의 + +페이지 번호(`page`)와 한도(`limit`)를 인자로 받아 게시물 목록을 가져오는 Query를 설정합니다. + +```tsx +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; + +export const postQueries = { + all: () => ["posts"], + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), +}; +``` + + +### 애플리케이션 코드 사용 예시 + +페이지네이션된 게시물을 화면에 렌더링하는 방법입니다. +`useQuery` 훅으로 `postQueries.list`를 호출하고, `Pagination` 컴포넌트와 연동하세요. + +```tsx title="@/pages/home/ui/index.tsx" +export const HomePage = () => { + const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN; + const [page, setPage] = usePageParam(DEFAULT_PAGE); + const { data, isFetching, isLoading } = useQuery( + postApi.postQueries.list(page, itemsOnScreen), + ); + return ( + <> + setPage(page)} + page={page} + count={data?.totalPages} + variant="outlined" + color="primary" + /> + + + ); +}; +``` + + + +## Query 관리를 위한 QueryProvider + +QueryProvider 구성 방법을 안내합니다. + +### `QueryProvider` 생성하기 + +`src/app/providers/query-provider.tsx`에 QueryProvider 컴포넌트를 정의합니다. + +```tsx title="@/app/providers/query-provider.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; + client: QueryClient; +}; + +export const QueryProvider = ({ client, children }: Props) => { + return ( + + {children} + + + ); +}; +``` + +### 2. `QueryClient` 생성 + +React Query의 캐싱과 기본 옵션을 설정할 `QueryClient` 인스턴스를 만듭니다. +아래 코드를 `@/shared/api/query-client.ts`에 정의하세요. + +```tsx title="@/shared/api/query-client.ts" +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }, + }, +}); +``` + +## 코드 자동 생성 + +API 코드 자동 생성 도구를 사용하면 반복 작업을 줄일 수 있습니다. +다만, 직접 작성하는 방식보다 유연성이 떨어질 수 있습니다. +Swagger 파일이 잘 정의되어 있다면 자동 생성 도구를 활용해 코드를 생성하세요. +생성된 코드는 `@/shared/api` 디렉토리에 배치해 일관되게 관리합니다. + +## React Query를 조직화하기 위한 추가 조언 + +### API Client + +`shared/api`에 커스텀 APIClient 클래스를 정의하면 다음 기능을 한곳에서 일괄 설정할 수 있습니다: + +- response, request 로깅 및 에러 처리를 일관되게 적용 +- 공통 헤더와 인증 설정, 데이터 직렬화 방식을 한곳에서 설정 +- API endpoint 변경이나 옵션 업데이트를 단일 수정 지점에서 반영 + + +```tsx title="@/shared/api/api-client.ts" +import { API_URL } from "@/shared/config"; + +export class ApiClient { + private baseUrl: string; + + constructor(url: string) { + this.baseUrl = url; + } + + async handleResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + try { + return await response.json(); + } catch (error) { + throw new Error("Error parsing JSON response"); + } + } + + public async get( + endpoint: string, + queryParams?: Record, + ): Promise { + const url = new URL(endpoint, this.baseUrl); + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.append(key, value.toString()); + }); + } + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + return this.handleResponse(response); + } + + public async post>( + endpoint: string, + body: TData, + ): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return this.handleResponse(response); + } +} + +export const apiClient = new ApiClient(API_URL); +``` + +## 참고 자료 \{#see-also} + +- [(GitHub) 예제 프로젝트](https://github.com/ruslan4432013/fsd-react-query-example) +- [(CodeSandbox) 예제 프로젝트](https://codesandbox.io/p/github/ruslan4432013/fsd-react-query-example/main) +- [Query Options 가이드](https://tkdodo.eu/blog/the-query-options-api) + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/kr/docs/guides/tech/with-sveltekit.mdx b/src/content/docs/kr/docs/guides/tech/with-sveltekit.mdx new file mode 100644 index 0000000000..bf4c31d834 --- /dev/null +++ b/src/content/docs/kr/docs/guides/tech/with-sveltekit.mdx @@ -0,0 +1,102 @@ +--- +title: Usage with SvelteKit +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +SvelteKit 프로젝트에 FSD(Feature-Sliced Design)를 적용할 때는 다음 차이를 유의하세요: + +- SvelteKit은 routing 파일을 `src/routes`에 두지만, FSD는 routing을 `app` 레이어에 포함합니다. +- SvelteKit은 라우트 외 파일을 `src/lib`에 두도록 권장합니다. + +## 구성 설정 + +`svelte.config.ts`에서 기본 경로를 변경해 `app` layer로 라우팅과 템플릿을 이동하고, `src/lib`를 설정합니다. + +```ts title="svelte.config.ts" +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config}*/ +const config = { + preprocess: [vitePreprocess()], + kit: { + adapter: adapter(), + files: { + routes: 'src/app/routes', // routing을 app layer로 이동 + lib: 'src', + appTemplate: 'src/app/index.html', // application entry point를 app layer로 이동 + assets: 'public' + }, + alias: { + '@/*': 'src/*' // src directory alias 설정 + } + } +}; + +export default config; +``` + +## File Routing을 `src/app`으로 이동 + +설정 변경 후 폴더 구조는 다음과 같습니다: + + +- src + - app + - index.html + - routes + - pages FSD Pages folder + + +이제 `app/routes` 폴더에 라우트 파일을 두고, `pages` layer의 컴포넌트를 연결할 수 있습니다. + +예시) Home 페이지 추가 예시 + +1. pages layer에 새 page slice 생성 +2. `app/routes`에 route 파일 추가 +3. page component를 route와 연결 + +[CLI 도구](https://github.com/feature-sliced/cli)로 page slice를 생성합니다: + +```shell +fsd pages home +``` + +`pages/home/ui/home-page.svelte`를 생성하고 public API로 노출하세요: + + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +`app/routes`에 route 파일을 추가합니다: + + +- src + - app + - routes + - +page.svelte + - index.html + - pages + - home + - ui + - home-page.svelte + - index.ts + + +`+page.svelte`에서 page component를 import 후 렌더링합니다: + +```html title="src/app/routes/+page.svelte" + + + +``` + +## 참고 자료 + +- [SvelteKit Directory Structure 문서](https://kit.svelte.dev/docs/configuration#files) diff --git a/src/content/docs/kr/docs/reference/layers.mdx b/src/content/docs/kr/docs/reference/layers.mdx new file mode 100644 index 0000000000..733d4eb8fa --- /dev/null +++ b/src/content/docs/kr/docs/reference/layers.mdx @@ -0,0 +1,213 @@ +--- +title: Layers +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +Layer는 Feature-Sliced Design에서 코드를 나눌 때 사용하는 **가장 큰 구분 단위**입니다. +코드를 나눌 때는 각 부분이 **어떤 역할을 맡는지**, 그리고 **다른 코드에 얼마나 의존하는지**를 기준으로 합니다. +각 Layer는 **이 Layer에는 어떤 코드가 와야 하는지**에 대해 **공통된 의미와 책임**이 정해져 있습니다. + +총 **7개의 Layer**가 있으며, 아래로 내려갈수록 **담당하는 기능과 의존성이 줄어드는 순서**입니다. + +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. + +1. App +2. Processes (deprecated) +3. Pages +4. Widgets +5. Features +6. Entities +7. Shared + +> 모든 Layer를 반드시 사용해야 하는 것은 아닙니다. +> **필요한 경우에만** Layer를 추가하세요. +> 대부분의 프론트엔드 프로젝트는 보통 최소한 `shared`, `page`, `app` 정도는 사용합니다. + +실무에서는 폴더명을 보통 소문자로 작성합니다. (예: `📁 shared`, `📁 page`, `📁 app`) +또한, **새로운 Layer를 직접 정의해서 사용하는 것은 권장하지 않습니다.** +(각 Layer의 역할이 이미 표준으로 충분히 정리되어 있기 때문입니다.) + +## Import 규칙 + +각 Layer는 여러 개의 **Slice(서로 밀접하게 연관된 모듈 묶음)** 로 구성됩니다. +Slice들 사이의 연결은 **Layer Import 규칙**을 통해 제한합니다. + +> **규칙:** +> 하나의 Slice 안에서 작성된 코드는 +> **자신이 속한 Layer보다 아래 Layer**에 있는 *다른 Slice*만 import할 수 있습니다. + +예를 들어, `📁 ~/features/aaa/api/request.ts` 파일은 다음과 같습니다. + +- 같은 Layer의 `📁 ~/features/bbb` → **import 불가능** +- 더 아래 Layer(`📁 ~/entities`, `📁 ~/shared`) → **import 가능** +- 같은 Slice(`📁 ~/features/aaa/lib/cache.ts`) → **import 가능** + +`app`과 `shared`는 조금 특이한 Layer입니다. +두 Layer는 **Layer이면서 동시에 하나의 큰 Slice처럼 동작**하고 내부 구조는 **Segment**로 나뉩니다. + +이 경우에는 Layer 내부에서 Segment끼리는 자유롭게 import할 수 있습니다. +(`shared`는 비즈니스 도메인이 없고, `app`은 모든 도메인을 묶는 상위 조정자 역할을 합니다.) + +## Layer별 역할 + +이제 각 Layer가 어떤 의미를 가지는지, +그리고 보통 어떤 종류의 코드가 해당 Layer에 들어오는지 정리해 보겠습니다. + +### Shared + +Shared Layer는 앱의 **기본 구성 요소와 기반 도구들을 모아두는 곳**입니다. +백엔드, 서드파티 라이브러리, 실행 환경과의 연결, +그리고 여러 곳에서 사용하는 **응집도 높은 내부 라이브러리**가 여기에 위치합니다. + +`app`과 마찬가지로 **Slice 없이 Segment로만 구성**합니다. +비즈니스 도메인이 없기 때문에, **Shared 내부의 파일들은 서로 자유롭게 import**할 수 있습니다. + +Segment 예시: + +- `📁 api` — API 클라이언트와 공통 백엔드 요청 함수 +- `📁 ui` — 공통 UI 컴포넌트 + - **비즈니스 로직은 포함하지 않지만**, **브랜드 테마는 적용 가능** + - 로고, 레이아웃, 자동완성/검색창 등 **UI 자체 로직**을 포함하는 컴포넌트는 허용 +- `📁 lib` — 내부 라이브러리 + - 단순히 `utils/helpers`를 모아두는 폴더가 아닙니다. ([이 글 참고][ext-sova-utility-dump]) + - 날짜, 색상, 텍스트 등 **하나의 주제에 집중**해야 합니다. + - README를 통해 역할과 범위를 문서화하는 것을 권장합니다. +- `📁 config` — 환경변수, 전역 Feature Flag +- `📁 routes` — 라우트 상수/패턴 +- `📁 i18n` — 번역 설정, 전역 문자열 + +> Segment 이름은 **이 폴더가 무엇을 하는지**를 명확하게 드러내야 합니다. +> `components`, `hooks`, `types`처럼 역할이 모호한 이름은 가급적 피하세요. + +### Entities + +Entities Layer는 프로젝트에서 다루는 **핵심 비즈니스 개념**을 표현합니다. +대부분의 경우, 실제 도메인 용어(예: `User`, `Post`, `Product`)와 일치합니다. + +각 Entity Slice에는 다음과 같은 것들을 포함할 수 있습니다. + +구성: + +- `📁 model` — 데이터 상태, 도메인 로직, 검증 스키마 +- `📁 api` — 해당 Entity와 관련된 API 요청 +- `📁 ui` — Entity의 시각적 표현 + - 완성된 큰 UI 블록이 아니어도 됩니다. + - 여러 페이지에서 재사용 가능한 형태로 설계합니다. + - 비즈니스 로직은 가능하면 props/slot으로 외부에서 주입하는 방식을 권장합니다. + +#### Entity 간 관계 + +원칙적으로는 Entity Slice끼리는 서로 **서로를 모르는 상태**가 이상적입니다. +하지만 실제 애플리케이션에서는 한 Entity가 다른 Entity를 **포함하거나** +여러 Entity가 서로 **상호작용**하는 일이 자주 발생합니다. + +이런 경우, 두 Entity 간의 구체적인 상호작용 로직은 +**상위 Layer(Feature 또는 Page)** 로 올려서 처리하는 것이 좋습니다. + +만약 한 Entity의 데이터 안에 다른 Entity가 포함되어야 한다면, +`@x` 표기법을 사용해 **교차 Public API**를 통해 연결되었음을 명시해 주세요. + +```ts title="entities/artist/model/artist.ts" +import type { Song } from "entities/song/@x/artist"; + +export interface Artist { + name: string; + songs: Array; +} +``` + +자세한 내용은 [Cross-Import를 위한 Public API][public-api-for-cross-imports] 문서를 참고하세요. + +### Feature + +Features Layer에는 **사용자가 애플리케이션에서 수행하는 주요 기능**이 들어갑니다. +보통 하나 이상의 Entity와 연관되어 동작합니다. + +- 모든 동작을 무조건 Feature로 만들 필요는 없습니다. +- **여러 페이지에서 재사용되는 기능**일 때 Feature로 추출하는 것을 고려하세요. +- 예: 여러 종류의 에디터에서 동일한 댓글 기능을 사용한다면, `comments`를 Feature로 만들 수 있습니다. +- Feature가 너무 많아지면, 중요한 기능이 어디 있는지 찾기 어려워질 수 있습니다. + +구성: + +- `📁 ui` — 상호작용 UI (예: 폼, 검색 바 등) +- `📁 api` — 해당 기능과 직접 관련된 API 요청 +- `📁 model` — 검증 로직, 내부 상태 관리 +- `📁 config` — Feature Flag 등 기능별 설정 + +> 새로운 팀원이 프로젝트에 합류했을 때, +> Page와 Feature만 훑어봐도 **이 앱이 어떤 기능을 제공하는지**를 대략 이해할 수 있도록 구성하는 것이 목표입니다. + +### Widget + +Widgets Layer는 **독립적으로 동작하는 비교적 큰 UI 블록**을 두는 곳입니다. +여러 페이지에서 재사용되거나, 한 페이지에서 **큰 섹션 단위로 나누어지는 UI 블록**이 있을 때 유용합니다. + + + +### Page + +Pages Layer는 웹/앱에서 보이는 **화면(screen) 또는 액티비티(activity)** 에 해당합니다. +일반적으로 “페이지 1개 = Slice 1개” 구조를 많이 사용하지만, +구조가 유사한 페이지들은 하나의 Slice로 묶는 것도 가능합니다. + +코드를 찾기만 쉽다면, Page Slice의 크기에 특별한 제한은 없습니다. +재사용되지 않는 UI는 그대로 Page 내부에 두면 됩니다. +Page Layer에는 보통 전용 model이 없으며, 필요한 경우 간단한 상태만 컴포넌트 내부에서 관리합니다. + +구성 예: + +- `📁 ui` — 페이지 UI, 로딩 상태, 에러 상태 처리 +- `📁 api` — 페이지에서 사용하는 데이터 패칭/변경 요청 + +### Process + + + +과거에는 여러 페이지를 넘나드는 복잡한 기능을 처리하기 위한 **탈출구 같은 Layer**로 사용되었습니다. +하지만 역할이 모호하고, 대부분의 애플리케이션에서는 굳이 사용하지 않아도 충분히 설계가 가능합니다. + +라우터, 서버 연동 같은 전역적인 로직은 보통 App Layer에 둡니다. +App Layer가 너무 복잡해질 때 정말 필요한 경우에만 제한적으로 고려할 수 있습니다. + +### App + +App Layer는 앱 전역에서 동작하는 **환경 설정**과 **공용 로직**을 관리하는 곳입니다. +예를 들어 라우터 설정, 전역 상태 관리(Store 설정), 글로벌 스타일 앱 진입점(Entry Point) 설정 등과 같이 +**앱 전체에 영향을 주는 코드**가 위치합니다. +`shared`와 마찬가지로 Slice 없이 **Segment만으로 구성**합니다. + +대표적인 Segment 예: + +- `📁 routes` — Router 설정 +- `📁 store` — Global State Store 설정 +- `📁 styles` — Global Style +- `📁 entrypoint` — Application Entry Point와 Framework 설정 + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports +[ext-remix]: https://remix.run +[ext-sova-utility-dump]: https://dev.to/sergeysova/why-utils-helpers-is-a-dump-45fo diff --git a/src/content/docs/kr/docs/reference/public-api.mdx b/src/content/docs/kr/docs/reference/public-api.mdx new file mode 100644 index 0000000000..d5b19b42e0 --- /dev/null +++ b/src/content/docs/kr/docs/reference/public-api.mdx @@ -0,0 +1,231 @@ +--- +title: Public API +sidebar: + order: 3 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Public API는 **Slice 기능을 외부에서 사용할 수 있는 공식 경로**입니다. +외부 코드는 반드시 이 경로를 통해서만 Slice 내부의 특정 객체에 접근할 수 있습니다. +즉, **Slice와 외부 코드 간의 계약(Contract)** 이자 **접근 게이트(Gate)** 역할을 합니다. + +일반적으로 Public API는 **Re-export를 모아둔 `index` 파일**로 만듭니다. +예를 들어 `pages/auth/index.js` 파일에서 `LoginPage`, `RegisterPage` 등을 다시 내보내는 방식입니다. + +```js title="pages/auth/index.js" +export { LoginPage } from "./ui/LoginPage"; +export { RegisterPage } from "./ui/RegisterPage"; +``` + +## 좋은 Public API의 조건 + +좋은 Public API는 Slice를 **다른 코드와 통합하기 쉽고, 안정적으로 유지보수**할 수 있게 해줍니다. +이를 위해 다음 세 가지 목표를 충족하는 것이 이상적입니다. + +1. **내부 구조 변경에 영향 없음** + - Slice 내부 폴더 구조를 바꾸더라도, 외부 코드는 그대로 동작해야 합니다. +2. **주요 동작 변경 = API 변경** + - Slice의 동작이 크게 바뀌어 기존 기대가 깨진다면, Public API도 함께 변경되어야 합니다. +3. **필요한 부분만 노출** + - Slice 전체 구현을 공개하는 것이 아니라, 외부에서 꼭 필요한 기능만 선별해서 노출합니다. + +### 안 좋은 예: 무분별한 Wildcard Re-export + +개발 초기에는 편의상 한 줄로 모든 것을 export하고 싶어서 +`export *` 같은 와일드카드 Re-export를 사용하고 싶을 수 있습니다. +하지만 이런 방식은 Slice의 인터페이스를 흐리게 만들고, 나중에 큰 부담이 됩니다. + +예를 들어 `features/comments/index.js`에서 `./ui/Comment` 전체를 그대로 export 하거나 +`./model/comments` 내부 모델을 통째로 export 하는 식입니다. + +```js title="Bad practice, features/comments/index.js" +// ❌ 이렇게 하지 마세요 +export * from "./ui/Comment"; // 👎 무분별한 UI export +export * from "./model/comments"; // 💩 내부 모델 노출 +``` + +이 방식이 문제가 되는 이유: + +- **발견 가능성 저하** + - Public API에서 어떤 기능을 제공하는지 한눈에 파악하기 어렵습니다. +- **내부 구현 노출** + - 원래 외부에서 알 필요가 없는 내부 코드를 외부에서 직접 사용하게 되고, + 이 코드에 대한 의존성이 생기면 리팩터링이 매우 어려워집니다. + +## Cross-Import를 위한 Public API \{#public-api-for-cross-imports} + +**Cross-import**는 같은 Layer 안에서 한 Slice가 다른 Slice를 import하는 것을 말합니다. +[Layer Import Rule][import-rule-on-layers]에 따라 원칙적으로는 금지되지만, +**Entity 간 참조**처럼 현실적으로 불가피한 경우가 있습니다. + +예를 들어, 도메인 모델에서 `Artist`와 `Song`이 서로 연관 관계를 가진다면 +이를 억지로 숨기기보다는 코드에도 그 관계를 드러내는 편이 낫습니다. + +이럴 때는 `@x` 표기를 사용해 **교차 참조 전용 Public API**를 명시적으로 만듭니다. + +폴더 예시: + + +- entities/ + - artist/ + - @x/ + - song.ts entities/song 전용 Public API + - index.ts 일반 Public API + + +`entities/song`에서는 다음과 같이 import 합니다. + +```typescript +import type { Artist } from "entities/artist/@x/song"; +``` + +여기서 `artist/@x/song`은 **Artist와 Song의 교차 지점**을 의미합니다. + + + +## Index File 사용 시 주의사항 + +### Circular Import (순환 참조) + +Circular Import는 두 개 이상의 파일이 서로를 참조하는 구조를 말합니다. +이 구조는 Bundler가 처리하기 어렵고, 디버그하기도 힘든 런타임 오류를 만들 수 있습니다. + +순환 참조는 Index 파일이 없어도 발생할 수 있지만, +Index 파일은 특히 이런 실수를 만들기 쉬운 환경을 제공합니다. + +예를 들어 Slice의 Public API(`pages/home/index.js`)에서 `HomePage`와 `loadUserStatistics`를 export합니다. + +
+ 세 파일이 서로 원형으로 import하는 모습 + 세 파일이 서로를 원형으로 import하고 있는 예시입니다. +
+ 위 그림: `fileA.js`, `fileB.js`, `fileC.js` 파일의 Circular Import 예시 +
+
+ +```jsx title="pages/home/ui/HomePage.jsx" +import { loadUserStatistics } from "../"; // pages/home/index.js에서 import + +export function HomePage() { + /* … */ +} +``` + +```js title="pages/home/index.js" +export { HomePage } from "./ui/HomePage"; +export { loadUserStatistics } from "./api/loadUserStatistics"; +``` + +`HomePage` 컴포넌트(`HomePage.jsx`)는 다시 Public API를 통해 `loadUserStatistics`를 import합니다. + +이 경우 의존 관계는 다음과 같이 순환합니다. + +- `index.js` → `HomePage.jsx` → 다시 `index.js` + +이렇게 되면, 빌드 시점이나 런타임 시점에 예측하기 어려운 문제가 생길 수 있습니다. + +#### 예방 원칙 + +- **같은 Slice 내부**에서 가져올 때는 + - 상대 경로(`../api/loadUserStatistics`)를 사용해서 + - 어느 파일을 참조하는지 명확히 작성합니다. +- **다른 Slice**에서 가져올 때는 + - 절대 경로(예: `@/features/...`)나 Alias를 사용합니다. +- Index에서 export한 모듈이 다시 Index를 참조하지 않도록 주의합니다. + +### Large Bundle & Tree-shaking 문제 \{#large-bundles} + +일부 Bundler는 Index 파일에서 여러 모듈을 한 번에 export할 경우, +실제로 사용하지 않는 코드(Dead code)를 제대로 제거(Tree-shaking)하지 못할 수 있습니다. + +대부분의 Public API에서는 모듈 간 연관성이 높아 크게 문제되지 않지만, +`shared/ui`, `shared/lib`처럼 **서로 관련성이 낮은 모듈 묶음**에서는 문제가 커집니다. + +예시 구조: + + +- shared/ui/ + - button/ + - text-field/ + - carousel/ + - accordion/ + + +이 상황에서 단순히 `Button` 하나만 사용하고 싶어도, +만약 `shared/ui` 전체를 통째로 export하는 큰 Index가 있다면 + +- `carousel`, `accordion` 등 무거운 의존성까지 +- 함께 번들에 포함될 수 있습니다. + +특히 Syntax Highlighter, Drag-and-Drop 라이브러리처럼 +용량이 큰 의존성은 최종 번들 크기에 큰 영향을 줍니다. + +#### 해결 방법 + +각 컴포넌트/라이브러리별로 **별도의 작은 Index 파일**을 만듭니다. + +예시: + + +- shared/ui/ + - button/ + - index.ts + - text-field/ + - index.ts + + +그리고 사용하는 쪽에서는 `@/shared/ui/button`, `@/shared/ui/text-field`와 같이 **컴포넌트 단위로 직접 import**합니다. + +```js title="pages/sign-in/ui/SignInPage.jsx" +import { Button } from "@/shared/ui/button"; +import { TextField } from "@/shared/ui/text-field"; +``` + +이렇게 하면 Bundler가 사용되지 않는 컴포넌트 코드를 제거할 수 있어 +Tree-shaking이 더 잘 동작하게 됩니다. + +### Public API 우회 방지의 한계 + +Slice에 Index 파일을 만들어도, +개발자가 직접 내부 경로를 입력해 import하는 것을 완전히 막을 수는 없습니다. +특히 IDE의 Auto Import 기능이 내부 파일 경로를 자동으로 선택해 버리면, +Public API 규칙을 모르는 상태에서 내부 구현을 바로 import하게 될 수 있습니다. + +#### 해결 방법 + +- [Steiger][ext-steiger] 같은 **FSD 전용 아키텍처 린터**를 사용해 + 프로젝트의 import 경로를 검사하고, 규칙을 강제합니다. + +## 대규모 프로젝트에서의 Bundler 성능 문제 + +[TkDodo 글][ext-please-stop-using-barrel-files]에서도 언급되듯, +Index 파일(일명 **barrel 파일**)이 너무 많아지면 +개발 서버 실행 속도나 HMR(Hot Module Replacement) 성능이 저하될 수 있습니다. + +#### 최적화 방법 + +1. [Large Bundle & Tree-shaking 문제](#large-bundles)에서 설명한 것처럼, + `shared/ui`, `shared/lib`에 있는 큰 Index를 없애고 + 컴포넌트/모듈 단위로 쪼갠 작은 Index를 사용합니다. + +2. Segment 단위로 불필요한 Index 파일을 만들지 않습니다. + - 예: `features/comments/index.ts`가 이미 Slice의 Public API 역할을 하고 있다면, + `features/comments/ui/index.ts` 같이 중첩된 Index는 굳이 만들 필요가 없습니다. + +3. 큰 프로젝트는 **기능 단위 Chunk 또는 패키지**로 나눕니다. + - 예: Google Docs처럼 Document Editor와 File Browser를 서로 다른 Chunk/패키지로 분리 + - Monorepo에서는 각 패키지를 독립적인 FSD Root로 구성할 수 있습니다. + - 일부 패키지는 Shared·Entity Layer만 포함 + - 다른 패키지는 Page·App Layer만 포함 + - 필요한 경우, 작은 Shared를 각 패키지에 두고 + 다른 패키지의 큰 Shared를 참조하는 방식으로 설계 + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-please-stop-using-barrel-files]: https://tkdodo.eu/blog/please-stop-using-barrel-files diff --git a/src/content/docs/kr/docs/reference/slices-segments.mdx b/src/content/docs/kr/docs/reference/slices-segments.mdx new file mode 100644 index 0000000000..f8f6241b7d --- /dev/null +++ b/src/content/docs/kr/docs/reference/slices-segments.mdx @@ -0,0 +1,99 @@ +--- +title: Slices and segments +sidebar: + order: 2 + label: Slice와 Segment +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## Slice + +Slice는 Feature-Sliced Design 조직 구조에서 **두 번째 계층**입니다. +역할은 제품, 비즈니스, 또는 애플리케이션 관점에서 **서로 관련 있는 코드를 하나로 묶는 것**입니다. + +Slice 이름은 고정된 규칙이 없으며, 애플리케이션의 **비즈니스 도메인**에 맞춰 정합니다. + +예를 들어: + +- 사진 갤러리: `photo`, `effects`, `gallery-page` +- 소셜 네트워크: `post`, `comments`, `news-feed` + +`Shared` Layer와 `App` Layer는 Slice를 가지지 않습니다. + +- `Shared` Layer는 비즈니스 로직이 전혀 없으므로, 제품 관점에서 Slice로 나눌 의미가 없습니다. +- `App` Layer는 애플리케이션 전체를 다루기 때문에, 여기서 다시 Slice로 나눌 필요가 없습니다. + +### Zero 결합도와 높은 응집도 \{#zero-coupling-high-cohesion} + +Slice는 **다른 Slice와 최대한 독립적**이어야 하고, +또한 **자신의 핵심 목적과 직접 관련된 코드 대부분을 내부에 포함**해야 합니다. + +아래 그림은 **응집도(cohesion)** 와 **결합도(coupling)** 개념을 시각적으로 보여 줍니다. + +
+ + +
+ Image inspired by + https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/ +
+
+ +Slice 간 독립성은 [Layer Import Rule][layers--import-rule]로 보장됩니다. + +> _Slice 내부 모듈(파일)은 자신보다 아래 계층(Layer)에 있는 Slice만 import할 수 있습니다._ + +### Slice의 Public API 규칙 + +Slice 내부 구조는 **팀이 원하는 방식으로 자유롭게** 설계할 수 있습니다. +하지만 다른 Slice에서 사용할 수 있도록 **명확한 Public API**를 반드시 제공해야 합니다. +이 규칙을 **Slice Public API Rule**이라고 부릅니다. + +> _모든 Slice(또는 Slice가 없는 Layer의 Segment)는 Public API를 정의해야 합니다._ +> _외부 모듈은 Slice/Segment의 내부 구조에 직접 접근하지 않고, Public API를 통해서만 접근해야 합니다._ + +Public API의 역할과 작성 방법은 [Public API Reference][ref-public-api]에서 자세히 설명합니다. + +### Slice Group + +서로 연관성이 높은 Slice들은 폴더로 묶어 **그룹처럼** 관리할 수 있습니다. +다만, 그룹으로 묶더라도 각 Slice에 대해 기존과 동일한 **격리 규칙**이 적용되며, +**그룹 내부라고 해서 코드 공유가 허용되는 것은 아닙니다.** + +![Features "compose", "like" 그리고 "delete"가 "post" 폴더에 그룹화되어 있습니다. 해당 폴더에는 허용되지 않음을 나타내기 위해 취소선이 그어진 "some-shared-code.ts" 파일도 있습니다.](/img/graphic-nested-slices.svg) + +## Segment + +Segment는 FSD 구조에서 **세 번째이자 마지막 계층**으로, +코드를 **기술적인 역할과 성격**에 따라 나누는 기준입니다. + +표준 Segment는 다음과 같습니다. + +- `ui` — UI 관련 코드: Component, Date Formatter, Style 등 +- `api` — Backend 통신: Request Function, Data Type, Mapper 등 +- `model` — Data Model: Schema, Interface, Store, Business Logic 등 +- `lib` — Slice 내부에서 사용하는 Library 코드 +- `config` — Configuration, Feature Flag 등 설정 관련 코드 + +각 Layer에서 Segment를 어떻게 사용하는지는 [Layer 페이지][layers--layer-definitions]에서 자세히 설명합니다. + +또한 프로젝트에 맞게 **커스텀 Segment**를 정의할 수도 있습니다. +특히 `App` Layer와 `Shared` Layer는 Slice가 없기 때문에, +이 두 Layer에서는 커스텀 Segment를 자주 사용하게 됩니다. + +Segment 이름을 정할 때는, +폴더 안에 **무슨 파일이 들어 있는지**가 아니라 **무엇을 위해 존재하는지(목적)** 가 드러나도록 작성하는 것이 좋습니다. + +예를 들어 `components`, `hooks`, `types` 같은 이름은 성격만 나타낼 뿐, +**역할이나 목적을 알기 어렵기 때문에** 가능한 한 피하는 편이 좋습니다. + +[layers--layer-definitions]: /docs/reference/layers#layer-definitions +[layers--import-rule]: /docs/reference/layers#import-rule-on-layers +[ref-public-api]: /docs/reference/public-api diff --git a/src/content/docs/ru/docs/about/alternatives.mdx b/src/content/docs/ru/docs/about/alternatives.mdx new file mode 100644 index 0000000000..829747b0f8 --- /dev/null +++ b/src/content/docs/ru/docs/about/alternatives.mdx @@ -0,0 +1,141 @@ +--- +title: Альтернативы +sidebar: + order: 3 + badge: + text: WIP + variant: caution +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +История архитектурных подходов + +## Big Ball of Mud + +> Что это; Почему так распространено; Когда это начинает приносить проблемы; Как быть; И как помогает в этом FSD + +- [(Статья) Oleg Isonen - Last words on UI architecture before an AI takes over](https://oleg008.medium.com/last-words-on-ui-architecture-before-an-ai-takes-over-468c78f18f0d) +- [(Доклад) Юлия Николаева, iSpring - Big Ball of Mud и другие проблемы монолита, с которыми мы справились](https://youtu.be/gna4Ynz1YNI) +- [(Статья) DDD - Big Ball of mud](https://thedomaindrivendesign.io/big-ball-of-mud/) + + +## Smart & Dumb components + +> О подходе; О применимости в фронтенде; позиция FSD + +Про устарелость, про новый взгляд со стороны методологии + +Почему компонентно/контейнерный подход - зло + + +- [(Статья) Dan Abramov - Presentational and Container Components (TLDR: deprecated)](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) + + +## Design Principles + +> Про что речь; позиция FSD + +SOLID, GRASP, KISS, YAGNI, ... - и почему они плохо работают вместе на практике + +И как она агрегирует эти практики + +- [(Доклад) Илья Азин - Feature-Sliced Design (фрагмент про Принципы проектирования)](https://youtu.be/SnzPAr_FJ7w?t=380) + + +## DDD + +> О подходе; Почему плохо работает на практике + +В чем отличие, чем улучшает применимость, где перенимает практики + +## См. также \{#see-also\} + +- [(Статья) DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) +- [(Доклад) Илья Азин - Feature-Sliced Design (фрагмент про Clean Architecture, DDD)](https://youtu.be/SnzPAr_FJ7w?t=528) + +## Clean Architecture + +> О подходе; О применимости в фронтенде; позиция FSD + +В чем схожи (многим), чем отличаются + +- [(Тред) Про use-case/interactor в методологии](https://t.me/feature_sliced/3897) +- [(Тред) Про DI в методологии](https://t.me/feature_sliced/4592) +- [(Статья) Александр Беспоясов - Чистая архитектура на фронтенде](https://bespoyasov.ru/blog/clean-architecture-on-frontend/) +- [(Статья) DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) +- [(Доклад) Илья Азин - Feature-Sliced Design (фрагмент про Clean Architecture, DDD)](https://youtu.be/SnzPAr_FJ7w?t=528) +- [(Статья) Заблуждения Clean Architecture](https://habr.com/ru/company/mobileup/blog/335382/) + + +## Frameworks + +> О применимости в фронтенде; Почему они не решают проблему; Почему это не единственный подход; позиция FSD + +Framework-agnostic, conventional-подход + +- [(Статья) Про причины создания методологии (фрагмент про фреймворки)](/docs/about/motivation) +- [(Тред) Про применимость методологии для разных фреймворков](https://t.me/feature_sliced/3867) + + +## Atomic Design + +### Что это? + +В Atomic Design сфера ответственности разделена на стандартизированные слои. + +Atomic Design разбивается на **5 слоев** (сверху вниз): + +1. `pages` - Назначение аналогично слою `pages` в FSD. +2. `templates` - Компоненты задающие структуру страницы, без привязки к контенту. +3. `organisms` - Модули состоящие из молекул, обладающие бизнес логикой. +4. `moleculs` - Более сложные компоненты, в которых, как правило, нет бизнес логики. +5. `atoms` - UI компоненты без бизнес логики. + +Модули на одном слое взаимодействуют только с модулями, находящимися на слоях ниже, как в FSD. +То есть, молекулы строятся из атомов, организмы из молекул, шаблоны из организмов, страницы из шаблонов. +Также Atomic Design подразумевает использование **Public API** внутри модулей для изоляции. + +### Применимость во фронтенде +Atomic Design относительно часто встречается в проектах. Atomic Design популярнее среди веб-дизайнеров, +нежели в разработке. Веб-дизайнеры часто используют Atomic Design для создания масштабируемых и легко поддерживаемых дизайнов. +В разработке Atomic Design часто смешивается с другими архитектурными методологиями. + +Однако, так как Atomic Design концентрирует внимание на UI компонентах и их композицию, возникает проблема реализации +бизнес логики в рамках архитектуры. + +Проблема заключается в том, что Atomic Design не предусматривает четкого уровня ответственности для бизнес-логики, +что приводит к распределению по различным компонентам и уровням, усложняя поддержку и тестирование. +Бизнес-логика становится размыта, что затрудняет четкое разделение ответственности и делает код менее +модульным и переиспользуемым. + +### Как оно сочетается с FSD? +В контексте FSD некоторые элементы Atomic Design могут быть применены для +создания гибких и масштабируемых UI компонентов. Слои `atoms` и `molecules` можно реализовать в +`shared/ui` в FSD, что упрощает переиспользование и поддержку базовых UI элементов. + + +- shared + - ui + - atoms/ + - molecules/ + - ... + + +Сравнение FSD и Atomic Design показывает, что обе методологии стремятся к модульности и переиспользованию, +но акцентируют внимание на разных аспектах. Atomic Design ориентирован на визуальные компоненты +и их композицию. FSD фокусируется на разбиении функциональности приложения на независимые модули и их взаимосвязи. + +- [Методология Atomic Design](https://atomicdesign.bradfrost.com/table-of-contents/) +- [(Тред) Про применимость в shared/ui](https://t.me/feature_sliced/1653) +- [(Видео) Кратко о Atomic Design](https://youtu.be/Yi-A20x2dcA) +- [(Доклад) Илья Азин - Feature-Sliced Design (фрагмент про Atomic Design)](https://youtu.be/SnzPAr_FJ7w?t=587) + +## Feature Driven + +> О подходе; О применимости в фронтенде; позиция FSD + +Про совместимость, историческое развитие и сравнение + +- [(Доклад) Oleg Isonen - Feature Driven Architecture](https://youtu.be/BWAeYuWFHhs) +- [Feature Driven - Краткая спецификация (с точки зрения FSD)](https://github.com/feature-sliced/documentation/tree/rc/feature-driven) diff --git a/src/content/docs/ru/docs/about/mission.mdx b/src/content/docs/ru/docs/about/mission.mdx new file mode 100644 index 0000000000..8e82481c9d --- /dev/null +++ b/src/content/docs/ru/docs/about/mission.mdx @@ -0,0 +1,50 @@ +--- +title: Миссия +sidebar: + order: 1 +--- + +Здесь описаны цели и ограничения применимости методологии, которыми мы руководствуемся при разработке методологии + +- Мы видим нашу цель, как баланс между идеологией и простотой +- Мы не сможем сделать серебряную пулю, которая подходит всем + +**Тем не менее, хотелось бы, чтобы методология была близка и доступна достаточно обширному кругу разработчиков** + +## Цели \{#goals} + +### Интуитивная понятность для широкого круга разработчиков \{#intuitive-clarity-for-a-wide-range-of-developers} + +Методология должна быть доступна - большей части команды в проектах + +*Т.к. даже со всем будущим инструментарием - будет недостаточно того, чтобы методологию понимали только прожженные сеньоры/лиды* + +### Решение повседневных проблем \{#solving-everyday-problems} + +В методологии должны быть изложены причины и решения наших повседневных проблем при разработке проектов + +**А также - приложить ко всему этому инструментарий (cli, линтеры)** +Чтобы разработчики могли использовать *выверенный опытом* подход, позволяющий обходить давние проблемы архитектуры и разработки + +> *@sergeysova: Представьте: разработчик пишет код в рамках методологии, и у него проблемы возникают раз в 10 реже, просто потому что другие люди продумали решение многих проблем.* + +## Ограничения \{#limitations} + +Мы не хотим *навязывать нашу точку зрения*, и одновременно понимаем - что *многие наши привычки, как разработчиков, мешают изо дня в день* + +У всех свой уровень опыта проектирования и разработки систем, **поэтому стоит понимать следующее:** + +- **Не выйдет**: очень просто, очень понятно, для всех + > *@sergeysova: Некоторые концепции невозможно интуитивно понять, пока не столкнешься с проблемами и не проведешь за решением годы.* + > + > - *Пример из математики — теория графов.* + > - *Пример из физики — квантовая механика.* + > - *Пример из программирования — архитектура приложений.* + > +- **Возможны и желательны**: простота, расширяемость + +## См. также \{#see-also} + +- [Проблемы архитектуры][refs-architecture--problems] + +[refs-architecture--problems]: /docs/about/understanding/architecture#problems diff --git a/src/content/docs/ru/docs/about/motivation.mdx b/src/content/docs/ru/docs/about/motivation.mdx new file mode 100644 index 0000000000..b5e7d37218 --- /dev/null +++ b/src/content/docs/ru/docs/about/motivation.mdx @@ -0,0 +1,150 @@ +--- +title: Мотивация +sidebar: + order: 2 +--- + +import { Aside, FileTree } from '@astrojs/starlight/components'; + +Главная идея **Feature-Sliced Design** - облегчить и удешевить разработку комплексных и развивающихся проектов, на основании [объединения результатов исследований, обсуждения опыта разного рода широкого круга разработчиков][ext-discussions]. + +Очевидно, что это не будет серебряной пулей, и само собой, у методологии будут свои [границы применимости][refs-mission]. + +Тем не менее, возникают резонные вопросы, касаемо *целесообразности такой методологии в целом* + + + +## Почему не хватает существующих решений? \{#intuitive-clarity-for-a-wide-range-of-developers} + +> Речь обычно о таких аргументах: +> +> - *"Зачем нужна отдельная новая методология, если уже есть давно зарекомендовавшие себя подходы и принципы проектирования `SOLID`, `KISS`, `YAGNI`, `DDD`, `GRASP`, `DRY` и т.д."* +> - *"Все проблемы проекта решаются хорошей документацией, тестами и выстроенными процессами"* +> - *"Проблем бы и не было - если бы все разработчики следовали всему выше перечисленному"* +> - *"Все придумано уже до вас, вы просто не можете этим пользоваться"* +> - *"Возьмите \{FRAMEWORK_NAME\} - там решено уже все за вас"* + +### Одних принципов недостаточно \{#principles-alone-are-not-enough} + +**Только существования принципов недостаточно для проектирования хорошей архитектуры** + +Не все их знают до конца, еще меньше правильно понимают и применяют + +*Принципы проектирования слишком общие, и не дают конкретного ответа на вопрос: "А как спроектировать структуру и архитектуру масштабируемого и гибкого приложения?"* + +### Процессы не всегда работают \{#processes-dont-always-work} + +*Документация/Тесты/Процессы* - это, конечно, хорошо, но увы, даже при больших затратах на них - **они не всегда решают поставленных проблем по архитектуре и внедрению новых людей в проект** + +- Время входа каждого разработчика в проект не сильно уменьшается, т.к. документация чаще всего выйдет огромной / устаревшей +- Постоянно следить за тем, что каждый понимает архитектуру одинаково - требует также колоссального количества ресурсов +- Не забываем и про bus-factor + +### Существующие фреймворки не везде могут быть применены \{#existing-frameworks-cannot-be-applied-everywhere} + +- Имеющиеся решения, как правило, имеют высокий порог входа, из-за чего сложно найти новых разработчиков +- Также, чаще всего, выбор технологии уже определен до наступления серьезных проблем в проекте, а потому нужно уметь "работать с тем что есть" - **не привязываясь к технологии** + +> Q: *"У меня в проекте `React/Vue/Redux/Effector/Mobx/{YOUR_TECH}` - как мне лучше выстроить структуру сущностей и связи между ними?"* + +### По итогу \{#as-a-result} + +Получаем *"уникальные как снежинки"* проекты, каждый из которых требует длительного погружения сотрудника, и знания, которые вряд ли будут применимы на другом проекте + +> @sergeysova: *"Это ровно та ситуация, которая сейчас есть в нашей сфере frontend разработки: каждый лид напридумает себе различных архитектур и структур проекта, при этом не факт, что эти структуры пройдут проверку временем, в итоге кроме него развивать проект могут максимум два человека и каждого нового разработчика нужно погружать снова."* + +## Зачем методология разработчикам? \{#why-do-developers-need-the-methodology} + +### Концентрация на бизнес-фичах, а не на проблемах архитектуры \{#focus-on-business-features-not-on-architecture-problems} + +Методология позволяет экономить ресурсы на проектировании масштабируемой и гибкой архитектуры, вместо этого направляя внимание разработчиков на разработку основной функциональности. При этом стандартизируются и сами архитектурные решения из проекта в проект. + +*Отдельный вопрос, что методология должна заслужить доверие комьюнити, чтобы другой разработчик мог в имеющиеся у него сроки ознакомиться и положиться на нее при решении проблем своего проекта* + +### Проверенное опытом решение \{#an-experience-proven-solution} + +Методология рассчитана на разработчиков, нацеленных на *проверенное опытом решение по проектированию комплексной бизнес-логики* + +*Однако ясно, что методология - это в целом про набор best-practices, статьи, рассматривающие определенные проблемы и кейсы при разработке. Поэтому - польза от методологии будет и для остального круга разработчиков - кто так или иначе сталкивается с проблемами при разработке и проектировании* + +### Здоровье проекта \{#project-health} + +Методология позволит *заблаговременно решать и отслеживать проблемы проекта, не требуя огромного количества ресурсов* + +**Чаще всего тех.долг копится и копится со временем, и ответственность за его разрешение лежит и на лиде, и на команде** + +Методология же позволит *заранее предупреждать* возможные проблемы при масштабировании и развитии проекта + +## Зачем методология бизнесу? \{#why-does-a-business-need-a-methodology} + +### Быстрый onboarding \{#fast-onboarding} + +С методологией можно нанять человека в проект, который **уже предварительно знаком с таким подходом, а не обучать заново** + +*Люди начинают быстрее вникать и приносить пользу проекту, а также появляются дополнительные гарантии найти людей на следующие итерации проекта* + +### Проверенное опытом решение \{#an-experience-proven-solution-1} + +С методологией бизнес получит *решение для большинства вопросов, возникающих при разработке систем* + +Поскольку чаще всего бизнес хочет получить фреймворк/решение, которое бы решало львиную долю проблем при развитии проекта + +### Применимость для разных стадий проекта \{#applicability-for-different-stages-of-the-project} + +Методология может принести пользу проекту *как на этапе поддержки и развития проекта, так и на этапе MVP* + +Да, на MVP чаще всего важнее *"фичи, а не заложенная на будущее архитектура"*. Но даже в условиях ограниченных сроков, зная best-practices из методологии - можно *"обойтись малой кровью"*, при проектировании MVP-версии системы, находя разумный компромисс +(нежели лепить фичи "как попало") + +*То же самое можно сказать и про тестирование* + +## Когда наша методология не нужна? \{#when-is-our-methodology-not-needed} + +- Если проект будет жить короткое время +- Если проект не нуждается в поддерживаемой архитектуре +- Если бизнес не воспринимает связь кодовой базы и скорости доставки фич +- Если бизнесу важнее поскорей закрыть заказы, без дальнейшей поддержки + +### Размеры бизнеса \{#business-size} + +- **Малый бизнес** - чаще всего нуждается в готовом и очень быстром решении. Только при росте бизнеса (хотя бы до почти среднего), он понимает - чтобы клиенты продолжали пользоваться, нужно в том числе уделить время качеству и стабильности разрабатываемых решений +- **Средний бизнес** - обычно понимает все проблемы разработки, и даже если приходится *"устраивать гонку за фичами"*, он все равно уделяет время на доработки по качеству, рефакторинг и тесты (и само собой - на расширяемую архитектуру) +- **Большой бизнес** - обычно уже имеет обширную аудиторию, штат сотрудников, и гораздо более обширный набор своих практик, и наверное даже - свой подход к архитектуре, поэтому идея взять чужую - им приходит не так часто + +## Планы \{#plans} + +Основная часть целей [изложена здесь][refs-mission--goals], но помимо этого, стоит проговорить и наши ожидания от методологии в будущем + +### Объединение опыта \{#combining-experience} + +Сейчас мы пытаемся объединить весь наш разнородный опыт `core-team`, и получить по итогу закаленную практикой методологию + +Конечно, мы можем получить по итогу Angular 3.0., но гораздо важней здесь - **исследовать саму проблему проектирования архитектуры сложных систем** + +*И да - у нас есть претензии и к текущей версии методологии, но мы хотим общими усилиями прийти к единому и оптимальному решению (учитывая, в том числе, и опыт комьюнити)* + +### Жизнь вне спецификации \{#life-outside-the-specification} + +Если все сложится хорошо, то методология не будет ограничиваться только спецификацией и тулкитом + +- Возможно будут и доклады, статьи +- Возможно будут `CODE_MODEs` для миграций на другие технологии проектов, написанных согласно методологии +- Не исключено, что по итогу сможем дойти и до мейнтейнеров крупных технологических решений + - *Особенно для React, по сравнению с другими фреймворками - это главная проблема, т.к. он не говорит как решать определенные проблемы* + +## См. также \{#see-also} + +- [(Обсуждение) Методология не нужна?][disc-src] +- [О миссии методологии: цели и ограничения][refs-mission] +- [Типы знаний в проекте][refs-knowledge] + +[refs-mission]: /docs/about/mission +[refs-mission--goals]: /docs/about/mission#goals +[refs-knowledge]: /docs/about/understanding/knowledge-types + +[disc-src]: https://github.com/feature-sliced/documentation/discussions/27 +[ext-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/ru/docs/about/promote/for-company.mdx b/src/content/docs/ru/docs/about/promote/for-company.mdx new file mode 100644 index 0000000000..5612a38111 --- /dev/null +++ b/src/content/docs/ru/docs/about/promote/for-company.mdx @@ -0,0 +1,16 @@ +--- +title: Продвижение в компании +sidebar: + order: 4 + badge: + text: WIP + variant: caution +--- + +## Нужна ли методология проекту и компании? \{#do-the-project-and-the-company-need-a-methodology} + +> Про оправданность применения и техдолг + +## Как подать методологию бизнесу? \{#how-can-i-submit-a-methodology-to-a-business} + +## Как подготовить и оправдать план по переезду на методологию? \{#how-to-prepare-and-justify-a-plan-to-move-to-the-methodology} diff --git a/src/content/docs/ru/docs/about/promote/for-team.mdx b/src/content/docs/ru/docs/about/promote/for-team.mdx new file mode 100644 index 0000000000..5f3ff60346 --- /dev/null +++ b/src/content/docs/ru/docs/about/promote/for-team.mdx @@ -0,0 +1,16 @@ +--- +title: Продвижение в команде +sidebar: + order: 3 + badge: + text: WIP + variant: caution +--- + +- Онбординг новых людей +- Гайдлайны по разработке (где искать модули и т.п.) +- Новый подход к задачам + +## См. также \{#see-also} +- [(Тред) Простота старых подходов и значение осознанности](https://t.me/feature_sliced/3360) +- [(Тред) Про удобство поиска по слоям](https://t.me/feature_sliced/1918) diff --git a/src/content/docs/ru/docs/about/promote/integration.mdx b/src/content/docs/ru/docs/about/promote/integration.mdx new file mode 100644 index 0000000000..00f74debf0 --- /dev/null +++ b/src/content/docs/ru/docs/about/promote/integration.mdx @@ -0,0 +1,25 @@ +--- +title: Аспекты интеграции +sidebar: + order: 1 +--- + +## Что получаем в конечном счете? \{#summary} + +См. первые 5 минут: + + + +## Также \{#also} + +**Преимущества**: +- [Overview](/docs/get-started/overview#advantages) +- CodeReview +- Onboarding + + +**Недостатки:** +- Ментальная сложность +- Высокий порог входа +- "Layers hell" +- Типичные проблемы feature-based подхода diff --git a/src/content/docs/ru/docs/about/promote/partial-application.mdx b/src/content/docs/ru/docs/about/promote/partial-application.mdx new file mode 100644 index 0000000000..f54ea19496 --- /dev/null +++ b/src/content/docs/ru/docs/about/promote/partial-application.mdx @@ -0,0 +1,10 @@ +--- +title: Частичное применение +sidebar: + order: 2 + badge: + text: WIP + variant: caution +--- + +> Как частично применять методологию? Имеет ли смысл? Что если игнорировать? diff --git a/src/content/docs/ru/docs/about/understanding/abstractions.mdx b/src/content/docs/ru/docs/about/understanding/abstractions.mdx new file mode 100644 index 0000000000..23516d8432 --- /dev/null +++ b/src/content/docs/ru/docs/about/understanding/abstractions.mdx @@ -0,0 +1,24 @@ +--- +title: Абстракции +sidebar: + order: 6 + badge: + text: WIP + variant: caution +--- + +## Закон дырявых абстракций \{#the-law-of-leaky-abstractions} + +## Почему так много абстракций \{#why-are-there-so-many-abstractions} + +> Абстракции помогают справляться со сложностью проекта. Вопрос в том - будут ли эти абстракции специфичны только для этого проекта, или же мы попытаемся вывести общие абстракции на основании специфики фронтенда + +> Архитектура и приложения в целом изначально сложны, и вопрос только в том как эту сложность лучше распределять и описывать + +## Про скоупы ответственности \{#about-scopes-of-responsibility} + +> Про опциональные абстракции + +## См. также \{#see-also} +- [Про необходимость в новых слоях](https://t.me/feature_sliced/2801) +- [Про сложность в понимании методологии и слоев](https://t.me/feature_sliced/2619) diff --git a/src/content/docs/ru/docs/about/understanding/architecture.mdx b/src/content/docs/ru/docs/about/understanding/architecture.mdx new file mode 100644 index 0000000000..f494f84f61 --- /dev/null +++ b/src/content/docs/ru/docs/about/understanding/architecture.mdx @@ -0,0 +1,99 @@ +--- +title: Об архитектуре +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +## Проблемы \{#problems} + +Обычно, разговор об архитектуре поднимается, когда разработка стопорится из-за тех или иных проблем в проекте. + +### Bus-factor & Onboarding + +Проект и его архитектуру понимает лишь ограниченный круг людей + +**Примеры:** + +- *"Сложно добавить человека в разработку"* +- *"На каждую проблему - у каждого свое мнение как обходить" (позавидуем ангуляру)* +- *"Не понимаю что происходит в этом большом куске монолита"* + +### Неявные и неконтролируемые последствия \{#implicit-and-uncontrolled-consequences} + +Множество неявных сайд-эффектов при разработке/рефакторинге *("все зависит от всего")* + +**Примеры:** + +- *"Фича импортирует фичу"* +- *"Я обновил(а) стор одной страницы, а отвалилась функциональность на другой"* +- *"Логика размазана по всему приложению, и невозможно отследить - где начало, где конец"* + +### Неконтролируемое переиспользование логики \{#uncontrolled-reuse-of-logic} + +Сложно переиспользовать/модифицировать существующую логику + +При этом, обычно есть [две крайности](https://github.com/feature-sliced/documentation/discussions/14): + +- Либо под каждый модуль пишется логика полностью с нуля *(с возможными повторениями в имеющейся кодовой базе)* +- Либо идет тенденция переносить все-все реализуемые модули в `shared` папки, тем самым создавая из нее большую свалку из модулей *(где большинство используется только в одном месте)* + +**Примеры:** + +- *"У меня в проекте есть n-реализаций одной и той же бизнес-логики, за что приходится ежедневно расплачиваться"* +- *"В проекте есть 6 разных компонентов кнопки/попапа/..."* +- *"Свалка хелперов"* + +## Требования \{#requirements} + +Поэтому кажется логичным предъявить желаемые *требования к идеальной архитектуре:* + + + +### Explicitness + +- Должно быть **легко осваивать и объяснять** команде проект и его архитектуру +- Структура должна отображать реальные **бизнес-ценности проекта** +- Должны быть явными **сайд-эффекты и связи** между абстракциями +- Должно быть **легко обнаруживать дублирование логики**, не мешая уникальным реализациям +- Не должно быть **распыления логики** по всему проекту +- Не должно быть **слишком много разнородных абстракций и правил** для хорошей архитектуры + +### Control + +- Хорошая архитектура должна **ускорять решение задач, внедрение фич** +- Должна быть возможность контролировать разработку проекта +- Должно быть легко **расширять, модифицировать, удалять код** +- Должна соблюдаться **декомпозиция и изолированность** функциональности +- Каждый компонент системы должен быть **легко заменяемым и удаляемым** + - *[Не нужно оптимизировать под изменения][ext-kof-not-modification] - мы не можем предсказывать будущее* + - *[Лучше - оптимизировать под удаление][ext-kof-but-removing] - на основании того контекста, который уже имеется* + +### Adaptability + +- Хорошая архитектура должна быть применима **к большинству проектов** + - *С уже существующими инфраструктурными решениями* + - *На любой стадии развития* +- Не должно быть зависимости от фреймворка и платформы +- Должна быть возможность **легко масштабировать проект и команду**, с возможностью параллелизации разработки +- Должно быть легко **подстраиваться под изменяющиеся требования и обстоятельства** + +## См. также \{#see-also} + +- [(React Berlin Talk) Oleg Isonen - Feature Driven Architecture][ext-kof] +- [(React SPB Meetup #1) Sergey Sova - Feature Slices][ext-slices-spb] +- [(Статья) Про модуляризацию проектов][ext-medium] +- [(Статья) Про Separation of Concerns и структурирование по фичам][ext-ryanlanciaux] + +[ext-kof-not-modification]: https://youtu.be/BWAeYuWFHhs?t=1631 +[ext-kof-but-removing]: https://youtu.be/BWAeYuWFHhs?t=1666 + +[ext-slices-spb]: https://t.me/feature_slices +[ext-kof]: https://youtu.be/BWAeYuWFHhs +[ext-medium]: https://alexmngn.medium.com/why-react-developers-should-modularize-their-applications-d26d381854c1 +[ext-ryanlanciaux]: https://ryanlanciaux.com/blog/2017/08/20/a-feature-based-approach-to-react-development/ diff --git a/src/content/docs/ru/docs/about/understanding/knowledge-types.mdx b/src/content/docs/ru/docs/about/understanding/knowledge-types.mdx new file mode 100644 index 0000000000..924224965c --- /dev/null +++ b/src/content/docs/ru/docs/about/understanding/knowledge-types.mdx @@ -0,0 +1,31 @@ +--- +title: Типы знаний в проекте +sidebar: + order: 3 + label: Типы знаний +--- + +import { Aside } from '@astrojs/starlight/components'; + +В любом проекте можно выделить следующие "типы знаний": + +* **Фундаментальные знания** + Знания, которые не сильно меняются со временем, такие как алгоритмы, computer science, механизмы работы языка программирования и его API. + +* **Технологический стек** + Знания о наборе технических решений, используемых в проекте, включая языки программирования, фреймворки и библиотеки. + +* **Проектные знания** + Знания, специфичные для текущего проекта и бесполезные вне этого проекта. Эти знания необходимы новым членам команды, чтоб вносить эффективный вклад. + + + +## См. также \{#see-also} + +- [(Видео) Илья Климов — О типах знаний][ext-klimov] + +[ext-klimov]: https://youtu.be/4xyb_tA-uw0?t=249 diff --git a/src/content/docs/ru/docs/about/understanding/naming.mdx b/src/content/docs/ru/docs/about/understanding/naming.mdx new file mode 100644 index 0000000000..7214b080ce --- /dev/null +++ b/src/content/docs/ru/docs/about/understanding/naming.mdx @@ -0,0 +1,48 @@ +--- +title: Нейминг +sidebar: + order: 4 +--- + +У разных разработчиков различный опыт и контекст, что может привести к недопониманию в команде, когда одни и те же сущности называются по-разному. Например: + +- Компоненты для отображения могут называться "ui", "components", "ui-kit", "views", … +- Код, который повторно используется во всем приложении, может называться "core", "shared", "app", … +- Код бизнес-логики может называться "store", "model", "state", … + +## Нейминг в Feature-Sliced Design \{#naming-in-fsd} + +В методологии используются такие специфические термины, как: + +- "app", "process", "page", "feature", "entity", "shared" как имена слоев, +- "ui', "model", "lib", "api", "config" как имена сегментов. + +Очень важно придерживаться этих терминов, чтобы предотвратить путаницу среди членов команды и новых разработчиков, присоединяющихся к проекту. Использование стандартных названий также помогает при обращении за помощью к сообществу. + +## Конфликты нейминга \{#when-can-naming-interfere} + +Конфликты нейминга могут возникать, когда термины, которые используются в методологии FSD, пересекаются с терминами, используемыми в бизнесе: + +- `FSD#process` vs моделируемый процесс в приложении, +- `FSD#page` vs страница журнала, +- `FSD#model` vs модель автомобиля. + +Например, разработчик, увидев в коде слово "процесс", потратит лишнее время, пытаясь понять, какой процесс подразумевается. Такие **коллизии могут нарушить процесс разработки**. + +Когда глоссарий проекта содержит терминологию, характерную для FSD, крайне важно проявлять осторожность при обсуждении этих терминов с командой и техническими незаинтересованными сторонами. + +Чтобы эффективно общаться с командой, рекомендуется использовать аббревиатуру "FSD" для префиксации терминов методологии. Например, когда речь идет о процессе, можно сказать: "Мы можем поместить этот процесс на слой FSD features". + +И наоборот, при общении с нетехническими заинтересованными сторонами лучше ограничивать использование терминологии FSD, а также воздержаться от упоминания внутренней структуры кодовой базы. + +## См. также \{#see-also} + +- [(Обсуждение) Адаптивность нейминга][disc-src] +- [(Обсуждение) Опрос по неймингу сущностей][disc-naming] +- [(Обсуждение) "processes" vs "flows" vs ...][disc-processes] +- [(Обсуждение) "model" vs "store" vs ...][disc-model] + +[disc-model]: https://github.com/feature-sliced/documentation/discussions/68 +[disc-naming]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-464894 +[disc-processes]: https://github.com/feature-sliced/documentation/discussions/20 +[disc-src]: https://github.com/feature-sliced/documentation/discussions/16 \ No newline at end of file diff --git a/src/content/docs/ru/docs/about/understanding/needs-driven.mdx b/src/content/docs/ru/docs/about/understanding/needs-driven.mdx new file mode 100644 index 0000000000..ca58abb8b4 --- /dev/null +++ b/src/content/docs/ru/docs/about/understanding/needs-driven.mdx @@ -0,0 +1,161 @@ +--- +title: О понимании потребностей и о формулировке задач +sidebar: + order: 2 + label: Понимание потребностей +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +## Зачем? \{#why} + +Чтобы подобрать четкое имя сущности и понять ее составляющие, **нужно отчетливо понимать - какая задача будет решена с помощью всего этого кода.** + +> _@sergeysova: Во время разработки, мы пытаемся каждой сущности или функции дать имя, которое четко отражает намерения и смысл выполняемого кода._ + +_Ведь, без понимания задачи, нельзя написать правильные тесты, покрывающие самые важные кейсы, проставить ошибки помогающие пользователю в нужных местах, даже банально не прерывать флоу пользователя из-за исправимых не критичных ошибок._ + +## О каких задачах речь? \{#what-tasks-are-we-talking-about} + +Frontend занимается разработкой приложений и интерфейсов для конечных пользователей, значит мы решаем задачи этих потребителей. + +Когда к нам приходит человек, **он хочет решить какую-то свою боль или закрыть потребность.** + +_Задача менеджеров и аналитиков - сформулировать эту потребность, а разработчиков реализовать с учетом особенностей веб-разработки (потеря связи, ошибка бэкенда, опечатка, промазал курсором или пальцем)._ + +**Эта самая цель, с которой пришёл пользователь и есть задача разработчиков.** + +> _Одна маленькая решенная задача и есть feature в методологии Feature-Sliced Design — нужно нарезать весь скоуп задач проекта на маленькие цели._ + +## Как это влияет на разработку? \{#how-does-this-affect-development} + +### Декомпозиция задачи \{#task-decomposition} + +Когда разработчик принимается реализовывать задачу, для упрощения понимания и поддержки кода, он мысленно **нарезает ее на этапы**: + +- сначала _разбить на верхнеуровневые сущности_ и _реализовать их_, +- затем эти сущности _разбить на более мелкие_ +- и так далее + +_В процессе разбиения на сущности, разработчик вынужден дать им название, которое четко отражало бы его замысел и при чтении листинга помогало понять какую задачу решает код_ + +_При этом не забываем, что пытаемся помочь пользователю уменьшить боль или реализовать потребности_ + +### Понимание сути задачи \{#understanding-the-essence-of-the-task} + +Но чтобы дать четкое название сущности, **разработчик должен знать предостаточно о ее назначении** + +- как он собирается использовать эту сущность, +- какую часть задачи пользователя она реализует, где ещё эту сущность можно применить, +- в каких ещё задачах она может поучаствовать, +- и так далее + +Сделать вывод не сложно: **пока разработчик будет размышлять над названием сущностей в рамках методологии, он сможет найти плохо сформулированные задачи ещё до написания кода.** + +> Как дать название сущности, если плохо понимаешь, какие задачи она может решать, как вообще можно разбить задачу на сущности, если плохо ее понимаешь? + +## Как сформулировать? \{#how-to-formulate-it} + +**Чтобы сформулировать задачу, которая решается фичей, нужно понимать саму задачу**, а это уже область ответственности менеджера проекта и аналитиков. + +_Методология может лишь подсказать разработчику, на какие задачи стоит обратить пристальное внимание менеджеру продукта._ + +> _@sergeysova: Весь frontend это в первую очередь отображение информации, любой компонент в первую очередь что-то отображает, а значит задача "показать пользователю что-то" не имеет практической ценности._ +> +> _Даже без учета специфики frontend можно спросить "а зачем это нужно показывать", так можно продолжать спрашивать до тех пор пока не вылезет боль или потребность потребителя._ + +Как только мы смогли дойти до базовых потребностей или болей, можно идти обратно и разбираться, **а как именно ваш продукт или сервис может помочь пользователю с его целями** + +Любая новая задача в вашем трекере направлена на решение задач бизнеса, а бизнес пытается решить задачи пользователя одновременно заработав на нём. А значит, каждая задача несёт в себе определенные цели, даже если они не прописаны в тексте описания. + +_**Разработчик должен четко понимать, какую цель преследует та или иная задача**, но при этом не каждая компания может позволить себе идеально выстроить процессы, хоть это и отдельный разговор, тем не менее, разработчик вполне может сам "пингануть" нужных менеджеров, чтобы выяснить это и сделать свою часть работы эффективно._ + +## А в чем выгода? \{#and-what-is-the-benefit} + +Посмотрим теперь на весь процесс от начала до конца. + +### 1. Понимание задач пользователей \{#1-understanding-user-tasks} + +Когда разработчик понимает его боли и то, как бизнес их закрывает, он может предлагать решения, которые бизнесу не доступны в силу специфики веб-разработки. + +> Но конечно, все это может работать только если разработчику небезразлично то, что он делает и ради чего, а иначе _зачем тогда методология и какие-то подходы?_ + +### 2. Структуризация и упорядочивание \{#2-structuring-and-ordering} + +С пониманием задач приходит **четкая структура как в голове, так и в задачах вместе с кодом** + +### 3. Понимание фичи и ее составляющих \{#3-understanding-the-feature-and-its-components} + +**Одна фича - это одна полезная функциональность для пользователя** + +- Когда в одной фиче - реализуется несколько - это и есть **нарушение границ** +- Фича может быть неделимой и разрастающейся - **и это неплохо** +- **Плохо** - когда фича не отвечает на вопрос _"А в чем бизнес-ценность для пользователя?"_ + - Не может быть фичи `карта-офиса` + - А вот `бронирование-переговорки-на-карте`, `поиск-сотрудника`, `смена-рабочего-места` - **да** + +> _@sergeysova: Смысл в том, чтобы в фиче лежал только код, реализующий непосредственно саму функциональность, без лишних подробностей и внутренних решений (в идеале)_ +> +> *Открываешь код фичи **и видишь только то, что относится к задаче** - не больше* + +### 4. Profit \{#4-profit} + +Бизнес крайне редко разворачивает свой курс кардинально в другую сторону, а значит **отражение задач бизнеса в коде frontend приложения это весьма существенный профит.** + +_Тогда не придётся объяснять каждому новому члену команды, что делает тот или иной код, и вообще ради чего он добавлялся - **все будет объясняться через задачи бизнеса, которые уже отражены в коде.**_ + +> То, что называется ["Язык бизнеса" в Domain Driven Development][ext-ubiq-lang] + +--- + +## Вернемся к реальности \{#back-to-reality} + +Если бизнес-процессы осмыслены и на стадии дизайна даны хорошие имена - _то перенести это понимание и логику в код не особо проблемно._ + +**Однако на деле**, задачи и функциональность обычно развиваются "слишком" итеративно и (или) нет времени продумывать дизайн. + +**В итоге фича сегодня имеет смысл, а при расширении этой фичи через месяц можно переписать пол проекта.** + +> *[[Из обсуждения][disc-src]]: Разработчик пытается думать на 2-3 шага вперед, учитывая будущие хотелки, но тут упирается в собственный опыт* +> +> _Прожженный опытом инженер обычно сразу смотрит на 10 шагов вперед, и понимает где одну фичу разделить, а где объединить с другой_ +> +> _Но бывает и так, что приходит задача, с которой не приходилось сталкиваться по опыту, и неоткуда взять понимание - как грамотней декомпозировать, с наименьшими печальными последствиями в будущем_ + +## Роль методологии \{#the-role-of-methodology} + +**Методология помогает решить проблемы разработчиков, чтобы тем было проще решать проблемы пользователей.** + +Нет решения задач разработчиков только ради разработчиков + +Но чтобы разработчик решил свои задачи, **нужно понять задачи пользователя** - наоборот не выйдет + +### Требования к методологии \{#methodology-requirements} + +Становится ясно, что нужно выделить как минимум два требования для **Feature-Sliced Design**: + +1. Методология должна рассказывать **как создавать фичи, процессы и сущности** + - А значит должна четко объяснять _как разделять код между ними_, из чего следует, что именование этих сущностей также должно быть заложено в спецификации. +2. Методология должна помогать архитектуре **[легко адаптироваться под изменяющиеся требования проекта][refs-arch--adaptability]** + +## См. также \{#see-also} + +- [(Пост) Стимуляция к четкой формулировке задач (+ обсуждение)][disc-src] + > _**Текущая статья** является адаптацией этого обсуждения, по ссылке можно ознакомиться с полной неурезанной версией_ +- [(Обсуждение) Как разбить функциональность и что из себя она представляет][tg-src] +- [(Статья) "How to better organize your applications"][ext-medium] + +[refs-arch--adaptability]: architecture#adaptability + +[ext-medium]: https://alexmngn.medium.com/how-to-better-organize-your-react-applications-2fd3ea1920f1 +[disc-src]: https://t.me/sergeysova/318 +[tg-src]: https://t.me/atomicdesign/18972 +[ext-ubiq-lang]: https://thedomaindrivendesign.io/developing-the-ubiquitous-language diff --git a/src/content/docs/ru/docs/about/understanding/signals.mdx b/src/content/docs/ru/docs/about/understanding/signals.mdx new file mode 100644 index 0000000000..50cbff3bcc --- /dev/null +++ b/src/content/docs/ru/docs/about/understanding/signals.mdx @@ -0,0 +1,19 @@ +--- +title: Сигналы архитектуры +sidebar: + order: 5 + badge: + text: WIP + variant: caution +--- + +> Если есть ограничение со стороны архитектуры - значит на то есть явные причины, и последствия, если их игнорировать + +> Методология и архитектура дает сигналы, а то как с этим справляться - зависит от того, какие риски готовы взять на себя и что наиболее подойдет вашей команде) + +## См. также \{#see-also} + +- [(Тред) Про сигналы от архитектуры и дата-флоу](https://t.me/feature_sliced/2070) +- [(Тред) Про фундаментальность архитектуры](https://t.me/feature_sliced/2492) +- [(Тред) Про подсвечивание слабых мест](https://t.me/feature_sliced/3979) +- [(Тред) Как понять, что модель данных разбухла](https://t.me/feature_sliced/4228) diff --git a/src/content/docs/ru/docs/get-started/faq.mdx b/src/content/docs/ru/docs/get-started/faq.mdx new file mode 100644 index 0000000000..dd35bf8c7e --- /dev/null +++ b/src/content/docs/ru/docs/get-started/faq.mdx @@ -0,0 +1,69 @@ +--- +title: FAQ +sidebar: + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +### Существует ли тулкит или линтер? \{#is-there-a-toolkit-or-a-linter\} + +Да! У нас есть линтер [Steiger][ext-steiger] для проверки архитектуры вашего проекта и [генераторы папок][ext-tools] через CLI или IDE. + +### Где хранить layout/template страниц? \{#where-to-store-the-layouttemplate-of-pages\} + +Если вам нужны простые шаблоны разметки, вы можете хранить их в `shared/ui`. Если вам нужно использовать более высокие слои, есть несколько вариантов: + +- Возможно, вам вообще не нужны лейауты? Если макет состоит всего из нескольких строк, разумно будет дублировать код в каждой странице, а не пытаться абстрагировать его. +- Если вам нужны лейауты, вы можете хранить их как отдельные виджеты или страницы, и компоновать их в конфигурации роутера в App. Вложенный роутинг — еще один вариант. + +### В чем отличие feature от entity? \{#what-is-the-difference-between-feature-and-entity\} + +_Entity_ — это понятие из реальной жизни, с которым работает ваше приложение. _Feature_ — это взаимодействие, представляющее реальную ценность для пользователей; что-то, что люди хотят делать с сущностями. + +Для получения дополнительной информации, а также примеров, см. страницу [про слайсы][reference-entities] в разделе Reference. + +### Могу ли я вкладывать страницы/фичи/сущности друг в друга? \{#can-i-embed-pagesfeaturesentities-into-each-other\} + +Да, но это вложение должно происходить в более высоких слоях. Например, внутри виджета вы можете импортировать обе фичи, а затем вставить одну фичу в другую через пропсы/вложение. + +Вы не можете импортировать одну фичу из другой фичи, это запрещено [**правилом импортов для слоёв**][import-rule-layers]. + +### А что с Atomic Design? \{#what-about-atomic-design\} + +Текущая версия методологии не обязывает, но и не запрещает использовать Atomic Design вместе с Feature-Sliced Design. + +При этом Atomic Design [хорошо применяется](https://t.me/feature_sliced/1653) для `ui` сегмента модулей. + +### Есть ли какие-нибудь полезные ресурсы/статьи/т.д. по FSD? \{#are-there-any-useful-resourcesarticlesetc-about-fsd\} + +Да! https://github.com/feature-sliced/awesome + +### Зачем мне нужен Feature-Sliced Design? \{#why-do-i-need-feature-sliced-design\} + +Он помогает вам и вашей команде быстро ознакомиться с проектом с точки зрения его основных компонентов, приносящих бизнес-ценность. Стандартизированная архитектура помогает ускорить онбординг и разрешать споры о структуре кода. См. страницу [Мотивация][motivation], чтобы узнать больше о том, почему FSD был создан. + +### Нужна ли архитектура/методология начинающему разработчику? \{#does-a-novice-developer-need-an-architecturemethodology\} + +Скорее да, чем нет + +*Обычно, если проектировать и разрабатывать проект в одиночку - все идет гладко. Но если появляются паузы в разработке, добавляются новые разработчики в команду - тогда-то и наступают проблемы* + +### Как мне работать с контекстом авторизации? \{#how-do-i-work-with-the-authorization-context\} + +Ответили [здесь](/docs/guides/examples/auth) + +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[import-rule-layers]: /docs/reference/layers#import-rule-on-layers +[reference-entities]: /docs/reference/layers#entities +[motivation]: /docs/about/motivation +[telegram]: https://t.me/feature_sliced +[discord]: https://discord.gg/S8MzWTUsmp +[github-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/ru/docs/get-started/overview.mdx b/src/content/docs/ru/docs/get-started/overview.mdx new file mode 100644 index 0000000000..4c2911bc68 --- /dev/null +++ b/src/content/docs/ru/docs/get-started/overview.mdx @@ -0,0 +1,147 @@ +--- +title: Обзор +sidebar: + order: 1 +--- + +import { Aside, FileTree } from '@astrojs/starlight/components'; + +**Feature-Sliced Design** (FSD) — это архитектурная методология для проектирования фронтенд-приложений. Проще говоря, это набор правил и соглашений по организации кода. Главная цель этой методологии — сделать проект понятнее и стабильнее в условиях постоянно меняющихся бизнес-требований. + +Помимо набора правил, FSD — это также целый инструментарий. У нас есть [линтер][ext-steiger] для проверки архитектуры вашего проекта, [генераторы папок][ext-tools] через CLI или IDE, а также богатая библиотека [примеров][examples]. + +## Подходит ли FSD мне? \{#is-it-right-for-me} + +FSD можно внедрять в проектах и командах любого размера. Она подходит для вашего проекта, если: + +- Вы занимаетесь **фронтенд**-разработкой (интерфейсы для сайтов, мобильных/десктопных приложений, и т. д.) +- Вы разрабатываете **приложение**, а не библиотеку + +И это все! Нет никаких ограничений на используемый вами язык программирования, фреймворк или стейт-менеджер. Ещё вы можете внедрять FSD постепенно, использовать его в монорепозиториях, и масштабировать его хоть до луны, разделяя ваше приложение на пакеты и внедряя FSD в каждом из них по отдельности. + +Если у вас уже есть архитектура, и вы подумываете перейти на FSD, убедитесь, что текущая архитектура **создает проблемы** в вашей команде. Например, если ваш проект стал слишком большим и переплетённым, чтобы эффективно разрабатывать новые функции, или если вы ожидаете, что в команду придет много новых участников. Если текущая архитектура работает, возможно, ее не стоит менять. Но если вы всё же решите перейти, ознакомьтесь с рекомендациями в разделе [Миграция][migration]. + +## Базовый пример \{#basic-example} + +Вот простой проект, реализующий FSD: + + +- app/ +- pages/ +- shared/ + + +Эти папки верхнего уровня называются _слоями_. Давайте посмотрим глубже: + + +- app/ + - routes/ + - analytics/ +- pages + - home/ + - article-reader/ + - ui/ + - api/ + - settings/ +- shared/ + - ui/ + - api/ + + +Папки внутри `📂 pages` называются _слайсами_. Они делят слой по домену (в данном случае, по страницам). + +Папки внутри `📂 app`, `📂 shared` и `📂 pages/article-reader` называются _сегментами_, и они делят слайсы (или слои) по техническому назначению, то есть по тому, для чего предназначен код. + +## Понятия \{#concepts} + +Слои, слайсы и сегменты образуют иерархию, как показано на схеме: + +
+ ![Иерархия концепций FSD, описанная ниже](../../../../../../static/img/visual_schema.jpg) + +
+

На картинке выше: три столбика, обозначенные слева направо как "Слои", "Слайсы" и "Сегменты" соответственно.

+

Столбик "Слои" содержит семь делений, расположенных сверху вниз и обозначенных "app", "processes", "pages", "widgets", "features", "entities" и "shared". Деление "processes" зачеркнуто. Деление "entities" соединено со вторым столбиком "Слайсы", показывая, что второй столбик является содержимым "entities".

+

Столбик "Слайсы" содержит три деления, расположенных сверху вниз и обозначенных "user", "post" и "comment". Деление "post" соединено со столбиком "Сегменты" таким же образом, что и содержимое "post".

+

Столбик "Сегменты" содержит три деления, расположенных сверху вниз и обозначенных "ui", "model" и "api".

+
+
+ +### Слои \{#layers} + +Слои стандартизированы во всех проектах FSD. Вам не обязательно использовать все слои, но их названия важны. На данный момент их семь (сверху вниз): + +1. **App** — всё, благодаря чему приложение запускается — роутинг, точки входа, глобальные стили, провайдеры и т. д. +2. **Processes** (процессы, устаревший) — сложные межстраничные сценарии. +3. **Pages** (страницы) — полные страницы или большие части страницы при вложенном роутинге. +4. **Widgets** (виджеты) — большие самодостаточные куски функциональности или интерфейса, обычно реализующие целый пользовательский сценарий. +5. **Features** (фичи) — _повторно используемые_ реализации целых фич продукта, то есть действий, приносящих бизнес-ценность пользователю. +6. **Entities** (сущности) — бизнес-сущности, с которыми работает проект, например `user` или `product`. +7. **Shared** — переиспользуемый код, особенно когда он отделён от специфики проекта/бизнеса, хотя это не обязательно. + + + +Фишка слоев в том, что модули на одном слое могут знать только о модулях со слоев строго ниже, и как следствие, импортировать только из них. + +### Слайсы \{#slices} + +Дальше идут слайсы, они делят слой по предметной области. Вы можете называть ваши слайсы как угодно, и создавать их сколько угодно. Слайсы помогают не теряться в проекте, потому что группируют тесно связанный по смыслу код. + +Слайсы не могут использовать другие слайсы на том же слое, и это обеспечивает сильную связанность кода внутри слайса и слабую сцепленность между слайсами. + +### Сегменты \{#segments} + +Слайсы, а также слои App и Shared, состоят из сегментов, а сегменты группируют код по его назначению. Имена сегментов не зафиксированы стандартом, но существует несколько общепринятых имен для наиболее распространенных целей: + +- `ui` — всё, что связано с отображением: UI-компоненты, форматтеры дат, стили и т.д. +- `api` — взаимодействие с бэкендом: функции запросов, типы данных, мапперы. +- `model` — модель данных: схемы валидации, интерфейсы, хранилища и бизнес-логика. +- `lib` — библиотечный код, который нужен другим модулям этого слайса. +- `config` — файлы конфигурации и фиче-флаги. + +Обычно этих сегментов достаточно для большинства слоев, поэтому свои собственные сегменты обычно создают только в Shared или App, но это не жёсткое правило. + +## Преимущества \{#advantages} + +- **Однородность** + Поскольку структура стандартизирована, проекты становятся более единообразными, что облегчает набор новых участников в команду. + +- **Устойчивость к изменениям и рефакторингу** + Модуль на одном слое не может использовать другие модули на том же слое или слоях выше. + Это позволяет вам вносить изолированные правки без непредвиденных последствий для остальной части приложения. + +- **Контролируемое переиспользование логики** + В зависимости от уровня вы можете сделать код либо очень переиспользуемым, либо очень локальным. + Это сохраняет баланс между соблюдением принципа **DRY** и практичностью. + +- **Ориентация на потребности бизнеса и пользователей** + Приложение разделено на бизнес-домены, и при именовании поощряется использование терминологии бизнеса, чтобы вы могли делать полезную работу в продукте, не вникая полностью во все другие несвязанные части проекта. + +## Постепенное внедрение \{#incremental-adoption} + +Если у вас есть существующая кодовая база, которую вы хотите перенести на FSD, мы предлагаем следующую стратегию. На нашем собственном опыте миграции она хорошо себя зарекомендовала. + +1. Начните постепенно формировать слои App и Shared, чтобы создать фундамент. + +2. Раскидайте весь существующий интерфейсный код по виджетам и страницам, даже если у них пока что есть зависимости, нарушающие правила FSD. + +3. Постепенно исправляйте нарушения правил на импорты, а по ходу извлекайте сущности и, возможно, фичи. + +Рекомендуется воздержаться от добавления новых крупных сущностей во время рефакторинга, а также рефакторинга по частям. + +## Следующие шаги \{#next-steps} + +- **Хотите разобраться в том, как мыслить по-FSD-шному?** Прочтите [Туториал][tutorial]. +- **Предпочитаете учиться на примерах?** У нас их много в разделе [Примеры][examples]. +- **Есть вопросы?** Загляните в наш [чат Telegram][ext-telegram] и спросите у сообщества. + +[tutorial]: /docs/get-started/tutorial +[examples]: /examples +[migration]: /docs/guides/migration/from-custom +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[ext-telegram]: https://t.me/feature_sliced diff --git a/src/content/docs/ru/docs/get-started/tutorial.mdx b/src/content/docs/ru/docs/get-started/tutorial.mdx new file mode 100644 index 0000000000..3c3d614e8e --- /dev/null +++ b/src/content/docs/ru/docs/get-started/tutorial.mdx @@ -0,0 +1,2262 @@ +--- +title: Туториал +sidebar: + order: 2 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## Часть 1. На бумаге + +В этом руководстве мы рассмотрим приложение Real World App, также известное как Conduit. Conduit является упрощённым клоном [Medium](https://medium.com/) — он позволяет вам читать и писать статьи в блогах, а также комментировать статьи других людей. + +![Главная страница Conduit](../../../../../../static/img/tutorial/realworld-feed-anonymous.jpg) + +Это довольно небольшое приложение, поэтому мы не станем сильно усложнять разработку излишней декомпозицией. Вероятнее всего, что всё приложение поместится в три слоя: **App**, **Pages** и **Shared**. Если нет, будем вводить дополнительные слои по ходу. Готовы? + +### Начните с перечисления страниц + +Если мы посмотрим на скриншот выше, мы можем предположить, что по крайней мере, есть следующие страницы: + +- Домашняя (лента статей) +- Войти и зарегистрироваться +- Просмотр статей +- Редактор статей +- Просмотр профилей людей +- Редактор профиля (настройки) + +Каждая из этих страниц станет отдельным *слайсом* на *слое* Pages. Вспомните из обзора, что слайсы — это просто папки внутри слоев, а слои — это просто папки с заранее определенными названиями, например, `pages`. + +Таким образом, наша папка Pages будет выглядеть так: + + +- pages/ + - feed/ лента + - sign-in/ войти/зарегистрироваться + - article-read/ просмотр статей + - article-edit/ редактор статей + - profile/ профиль + - settings/ настройки + + +Ключевое отличие Feature-Sliced Design от произвольной структуры кода заключается в том, что страницы не могут зависеть друг от друга. То есть одна страница не может импортировать код с другой страницы. Это связано с **правилом импорта для слоёв**: + +*Модуль (файл) в слайсе может импортировать другие слайсы только в том случае, если они расположены на слоях строго ниже.* + +В этом случае страница является слайсом, поэтому модули (файлы) внутри этой страницы могут импортировать код только из слоев ниже, а не из других страниц. + +### Пристальный взгляд на ленту + +
+ ![Перспектива анонимного посетителя](../../../../../../static/img/tutorial/realworld-feed-anonymous.jpg) +
+ _Перспектива анонимного посетителя_ +
+
+ +
+ ![Перспектива авторизованного пользователя](../../../../../../static/img/tutorial/realworld-feed-authenticated.jpg) +
+ _Перспектива авторизованного пользователя_ +
+
+ +На странице ленты есть три динамических области: + +1. Ссылки для логина, показывающие статус авторизации +2. Список тэгов, фильтрующих ленту +3. Одна—две ленты статей, у каждой статьи кнопка лайка + +Ссылки для логина — часть заголовка, общего для всех страниц, так что пока что отложим их. + +#### Список тэгов + +Чтобы создать список тэгов, нам нужно получить все доступные тэги, отобразить каждый тэг как чип ([chip](https://m3.material.io/components/chips/overview)) и сохранить выбранные тэги в хранилище на стороне клиента. Эти операции относятся к категориям «взаимодействие с API», «пользовательский интерфейс» и «хранение данных». В Feature-Sliced Design код делится по назначению с помощью *сегментов*. Сегменты — это папки в слайсах, и они могут иметь произвольные названия, описывающие их цель, но некоторые цели настолько распространены, что существует несколько общепринятых названий: + +- 📂 `api/` для взаимодействия с бэкендом +- 📂 `ui/` для кода, отвечающего за отображение и внешний вид +- 📂 `model/` для хранения данных и бизнес-логики +- 📂 `config/` для фиче-флагов, переменных окружения и других форм конфигурации + +Мы поместим код, который получает тэги, в `api`, сам компонент тэга в `ui`, а взаимодействие с хранилищем в `model`. + +#### Статьи + +Следуя той же логике, мы можем разбить ленту статей на те же три сегмента: + +- 📂 `api/`: получить постраничный список статей с количеством лайков, оставить лайк +- 📂 `ui/`: + - список вкладок, который может отображать дополнительную вкладку при выборе тэга + - отдельная статья + - рабочая пагинация +- 📂 `model/`: клиентское хранилище загруженных постов и текущей страницы (при необходимости) + +### Переиспользование общего кода + + Страницы, как правило, очень отличаются по своей цели, но что-то остается одинаковым по всему приложению — например, UI-кит, соответствующий языку дизайна, или соглашение на бэкенде, что все делается через REST API с конкретным методом аутентификации. Поскольку слайсы должны быть изолированными, переиспользование кода происходит за счёт слоя ниже, **Shared**. + +Shared отличается от других слоев тем, что он содержит сегменты, а не слайсы. Таким образом, слой Shared представляет собой гибрид между слоем и слайсом. + +Обычно код в Shared не планируется заранее, а извлекается по ходу разработки, потому что только во время разработки становится ясно, какие части кода действительно переиспользуются. Тем не менее, полезно держать в голове, какой код имеет смысл хранить в Shared: + +- 📂 `ui/` — UI-кит, только внешний вид, без бизнес-логики. Например, кнопки, диалоги, поля форм. +- 📂 `api/` — удобные обёртки вокруг запросов на бэкенд (например, обёртка над `fetch()` в случае веба) +- 📂 `config/` — обработка переменных окружения +- 📂 `i18n/` — конфигурация поддержки разных языков +- 📂 `router/` — примитивы и константы маршрутизации + +Это лишь примеры сегментов в Shared, вы можете опустить любой из них или создать свой собственный. Единственное, что нужно помнить при создании новых сегментов — названия сегментов должны описывать **цель (почему), а не суть (что)**. Такие названия как `components` , `hooks` или `modals` *не стоит* использовать, потому что они описывают, что содержат эти файлы по сути, а не то, с какой целью писался этот код. Как следствие таких названий, команде приходится копаться в таких папках, чтоб найти нужное. Помимо этого, несвязанный код лежит рядом, из-за чего при рефакторинге затрагивается большая часть приложения, что усложняет ревью и тестирование. + +### Определите строгий публичный API + +В контексте Feature-Sliced Design термин *публичный API* означает, что слайс или сегмент объявляет, что из него могут импортировать другие модули в проекте. Например, в JavaScript это может быть файл `index.js`, который переэкспортирует объекты из других файлов в слайсе. Это обеспечивает свободу рефакторинга внутри слайса до тех пор, пока контракт с внешним миром (т.е. публичный API) остается неизменным. + +Для слоя Shared, на котором нет слайсов, обычно удобнее определить публичный API (он же индекс) на уровне сегментов, а не один индекс на весь слой. В таком случае импорты из Shared естественным образом организуются по назначению. Для других слоев, на которых слайсы есть, верно обратное — обычно практичнее определить один индекс на слайс и позволить слайсу самому контролировать набор сегментов внутри, потому что другие слои обычно имеют гораздо меньше экспортов и чаще рефакторятся. + +Наши слайсы/сегменты будут выглядеть друг для друга следующим образом: + + +- pages/ + - feed/ + - index + - sign-in/ + - index + - article-read/ + - index + - ... +- shared/ + - ui/ + - index + - api/ + - index + - ... + + +Все, что находится внутри папок типа `pages/feed` или `shared/ui` , известно только этим папкам, и нет никаких гарантий по содержанию этих папок. + +### Крупные переиспользуемые блоки интерфейса + +Ранее мы хотели отдельно вернуться к переиспользуемому заголовку приложения. Собирать его заново на каждой странице было бы непрактично, поэтому мы его переиспользуем. У нас уже есть слой Shared для переиспользования кода, однако, в случае крупных блоков интерфейса в Shared есть нюанс — слой Shared не должен знать о слоях выше. + +Между слоями Shared и Pages есть три других слоя: Entities, Features и Widgets. В других проектах на этих слоях может лежать что-то, что хочется использовать в крупном переиспользуемом блоке, и тогда мы не сможем поместить этот блок в Shared, потому что тогда ему придется импортировать со слоёв выше, а это запрещено. Тут приходит на помощь слой Widgets. Он расположен выше Shared, Entities и Features, поэтому он может использовать их всех. + +В нашем случае заголовок очень простой — это статический логотип и навигация верхнего уровня. Навигация должна спросить у API, авторизован ли сейчас пользователь, но это может быть решено простым импортом из сегмента `api`. Поэтому мы оставим наш заголовок в Shared. + +### Пристальный взгляд на страницу с формой + +Давайте также рассмотрим страницу, на которой можно не только читать, но и редактировать. К примеру, редактор статей: + +![Редактор статей в Conduit](../../../../../../static/img/tutorial/realworld-editor-authenticated.jpg) + +Она выглядит тривиально, но содержит несколько аспектов разработки приложений, которые мы еще не исследовали — валидацию форм, состояние ошибки и постоянное хранение данных. + +Для создания этой страницы нам нужно несколько полей и кнопок из Shared, которые мы соберём в форму в сегменте `ui` этой страницы. Затем, в сегменте `api` мы определим изменяющий запрос, чтобы создать статью на бэкенде. + +Чтобы проверить запрос перед отправкой, нам нужна схема валидации, и хорошим местом для нее является сегмент `model` , поскольку это модель данных. Там же мы сгенерируем сообщение об ошибке, а отобразим его с помощью ещё одного компонента в сегменте `ui`. + +Чтобы улучшить пользовательский опыт, мы также можем сохранять введённые данные постоянно, чтобы предотвратить случайную потерю при закрытии браузера. Это тоже подходит под сегмент `model`. + +### Итоги + +Мы разобрали несколько страниц и пришли к базовой структуре нашего приложения: + +1. Слой Shared + 1. `ui` будет содержать наш переиспользуемый UI-кит + 2. `api` будет содержать наши примитивы для взаимодействия с бэкендом + 3. Остальное разложим по ходу написания кода +2. Слой Pages — для каждой страницы отдельный слайс + 1. `ui` будет содержать саму страницу и составляющие её блоки + 2. `api` будет содержать более специализированные функции получения данных, использующие `shared/api` + 3. `model` может содержать клиентское хранилище данных, которые мы будем отображать + +Давайте создадим это приложение! + +## Часть 2. В коде + +Теперь, когда у нас есть план, давайте воплотим его в жизнь. Мы будем использовать React и [Remix](https://remix.run/). + +Для этого проекта уже есть готовый шаблон, cклонируйте его с GitHub, чтобы начать работу: [https://github.com/feature-sliced/tutorial-conduit/tree/clean](https://github.com/feature-sliced/tutorial-conduit/tree/clean) + +Установите зависимости с помощью `npm install` и запустите сервер с помощью `npm run dev`. Откройте [http://localhost:3000](http://localhost:3000/), и вы увидите пустое приложение. + +### Разложим по страницам + +Давайте начнем с создания пустых компонентов для всех наших страниц. Выполните следующую команду в своем проекте: + +```bash +npx fsd pages feed sign-in article-read article-edit profile settings --segments ui +``` + +Это создаст папки наподобие `pages/feed/ui/` и индексный файл `pages/feed/index.ts` для каждой страницы. + +### Подключим страницу фида + +Давайте подключим корневой маршрут (`/`) нашего приложения к странице фида. Создайте компонент `FeedPage.tsx` в `pages/feed/ui` и поместите в него следующее: + +```tsx title="pages/feed/ui/FeedPage.tsx" +export function FeedPage() { + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+
+ ); +} +``` + +Затем ре-экспортируйте этот компонент в публичном API страницы фида, файл `pages/feed/index.ts`: + +```tsx title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +``` + +Теперь подключите его к корневому маршруту. В Remix маршрутизация работает на файлах, и файлы маршрутов находятся в папке `app/routes`, что хорошо сочетается с Feature-Sliced Design. + +Используйте компонент `FeedPage` в `app/routes/_index.tsx`: + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +Затем, если вы запустите dev-сервер и откроете приложение, вы должны увидеть баннер Conduit! + +![Баннер Conduit](../../../../../../static/img/tutorial/conduit-banner.jpg) + +### API-клиент + +Чтобы общаться с бэкендом RealWorld, давайте создадим удобный API-клиент в Shared. Создайте два сегмента, `api` для клиента и `config` для таких переменных как базовый URL бэкенда: + +```bash +npx fsd shared --segments api config +``` + +Затем создайте `shared/config/backend.ts`: + +```tsx title="shared/config/backend.ts" +export { mockBackendUrl as backendBaseUrl } from "mocks/handlers"; +``` + +```tsx title="shared/config/index.ts" +export { backendBaseUrl } from "./backend"; +``` + +Поскольку проект RealWorld предоставляет [спецификацию OpenAPI](https://github.com/gothinkster/realworld/blob/main/api/openapi.yml), мы можем автоматически сгенерировать типы для нашего API-клиента. Мы будем использовать [пакет `openapi-fetch`](https://openapi-ts.pages.dev/openapi-fetch/), в котором дополнительно есть генератор типов. + +Выполните следующую команду, чтобы сгенерировать актуальные типы для API: + +```bash +npm run generate-api-types +``` + +В результате будет создан файл `shared/api/v1.d.ts`. Мы воспользуемся этим файлом в `shared/api/client.ts` для создания типизированного клиента API: + +```tsx title="shared/api/client.ts" +import createClient from "openapi-fetch"; + +import { backendBaseUrl } from "shared/config"; +import type { paths } from "./v1"; + +export const { GET, POST, PUT, DELETE } = createClient({ baseUrl: backendBaseUrl }); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; +``` + +### Реальные данные в ленте + +Теперь мы можем перейти к получению статей из бэкенда и добавлению их в ленту. Начнем с реализации компонента предпросмотра статьи. + +Создайте `pages/feed/ui/ArticlePreview.tsx` со следующим содержимым: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +export function ArticlePreview({ article }) { /* TODO */ } +``` + +Поскольку мы пишем на TypeScript, было бы неплохо иметь типизированный объект статьи Article. Если мы изучим сгенерированный `v1.d.ts`, то увидим, что объект Article доступен через `components["schemas"]["Article"]`. Поэтому давайте создадим файл с нашими моделями данных в Shared и экспортируем модели: + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; +``` + +Теперь мы можем вернуться к компоненту предпросмотра статьи и заполнить разметку данными. Обновите компонент, добавив в него следующее содержимое: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+ +
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +Кнопка "Мне нравится" пока ничего не делает, мы исправим это, когда перейдем на страницу чтения статей и реализуем функцию "Мне нравится". + +Теперь мы можем получить статьи и отобразить кучу этих карточек предпросмотра. Получение данных в Remix осуществляется с помощью *загрузчиков* — серверных функций, которые собирают те данные, которые нужны странице. Загрузчики взаимодействуют с API от имени страницы, поэтому мы поместим их в сегмент `api` страницы: + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; + +import { GET } from "shared/api"; + +export const loader = async () => { + const { data: articles, error, response } = await GET("/articles"); + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return json({ articles }); +}; +``` + +Чтобы подключить его к странице, нам нужно экспортировать его с именем `loader` из файла маршрута: + +```tsx title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +export { loader } from "./api/loader"; +``` + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export { loader } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +И последний шаг — отображение этих карточек в ленте. Обновите `FeedPage` следующим кодом: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+
+
+
+ ); +} +``` + +### Фильтрация по тегам + +Что касается тегов, то наша задача — получить их из бэкенда и запомнить выбранный пользователем тег. Мы уже знаем, как загружать из бэкенда — это еще один запрос от функции-загрузчика. Мы будем использовать удобную функцию `promiseHash` из пакета `remix-utils`, который уже установлен. + +Обновите файл загрузчика, `pages/feed/api/loader.ts`, следующим кодом: + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async () => { + return json( + await promiseHash({ + articles: throwAnyErrors(GET("/articles")), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +Вы можете заметить, что мы вынесли обработку ошибок в общую функцию `throwAnyErrors`. Она выглядит довольно полезной, так что, возможно, мы захотим переиспользовать её позже, а пока давайте просто заметим этот факт. + +Теперь перейдем к списку тегов. Он должен быть интерактивным - щелчок по тегу должен выбрать этот тег. По традиции Remix, мы будем использовать параметры запроса в URL в качестве хранилища для выбранного тега. Пусть браузер позаботится о хранилище, а мы сосредоточимся на более важных вещах. + +Обновите `pages/feed/ui/FeedPage.tsx` следующим кодом: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles, tags } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +Затем нам нужно использовать параметр поиска тегов в нашем загрузчике. Измените функцию `loader` в `pages/feed/api/loader.ts` на следующую: + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { params: { query: { tag: selectedTag } } }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +И всё, сегмент `model` нам не понадобился. Remix — клёвая штука. + +### Пагинация + +Аналогичным образом мы можем реализовать пагинацию. Не стесняйтесь попробовать реализовать её сами или же просто скопируйте код ниже. В любом случае, осуждать вас некому. + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const [searchParams] = useSearchParams(); + const { articles, tags } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} + +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +Ну вот, это тоже сделали. Есть еще список вкладок, который можно реализовать аналогичным образом, но давайте повременим с этим, пока не реализуем аутентификацию. Кстати, о ней! + +### Аутентификация \{#authentication} + +Аутентификация включает в себя две страницы — одну для входа в систему и другую для регистрации. Они, в основном, очень схожие, поэтому имеет смысл держать их в одном слайсе, `sign-in`, чтобы при необходимости можно было переиспользовать код. + +Создайте `RegisterPage.tsx` в сегменте `ui` в `pages/sign-in` со следующим содержимым: + +```tsx title="pages/sign-in/ui/RegisterPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { register } from "../api/register"; + +export function RegisterPage() { + const registerData = useActionData(); + + return ( +
+
+
+
+

Sign up

+

+ Have an account? +

+ + {registerData?.error && ( +
    + {registerData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +Сейчас нам нужно исправить сломанный импорт. Он обращается к новому сегменту, поэтому создайте его: + +```bash +npx fsd pages sign-in -s api +``` + +Однако прежде чем мы сможем реализовать бэкенд-часть регистрации, нам нужен некоторый инфраструктурный код для Remix для обработки сессий. Отправим его в Shared, на случай, если он понадобится какой-либо другой странице. + +Поместите следующий код в `shared/api/auth.server.ts`. Этот код очень специфичен для Remix, так что не беспокойтесь, если там не все понятно, просто скопируйте и вставьте: + +```tsx title="shared/api/auth.server.ts" +import { createCookieSessionStorage, redirect } from "@remix-run/node"; +import invariant from "tiny-invariant"; + +import type { User } from "./models"; + +invariant( + process.env.SESSION_SECRET, + "SESSION_SECRET must be set for authentication to work", +); + +const sessionStorage = createCookieSessionStorage<{ + user: User; +}>({ + cookie: { + name: "__session", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [process.env.SESSION_SECRET], + secure: process.env.NODE_ENV === "production", + }, +}); + +export async function createUserSession({ + request, + user, + redirectTo, +}: { + request: Request; + user: User; + redirectTo: string; +}) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.set("user", user); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 7, // 7 days + }), + }, + }); +} + +export async function getUserFromSession(request: Request) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + return session.get("user") ?? null; +} + +export async function requireUser(request: Request) { + const user = await getUserFromSession(request); + + if (user === null) { + throw redirect("/login"); + } + + return user; +} +``` + +А также экспортируйте модель `User` из файла `models.ts`, расположенного рядом с ним: + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +export type User = components["schemas"]["User"]; +``` + +Прежде чем этот код заработает, необходимо установить переменную окружения `SESSION_SECRET`. Создайте файл `.env` в корне проекта, пропишите в нем `SESSION_SECRET=`, а затем пробегитесь по клавиатуре, чтобы создать длинную случайную строку. У вас должно получиться что-то вроде этого: + +```bash title=".env" +SESSION_SECRET=несмейтеэтокопировать +``` + +Наконец, добавьте несколько экспортов в публичный API, чтобы использовать этот код: + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +``` + +Теперь мы можем написать код, который будет общаться с бэкендом RealWorld для регистрации. Мы сохраним его в `pages/sign-in/api`. Создайте файл `register.ts` и поместите в него следующий код: + +```tsx title="pages/sign-in/api/register.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const register = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const username = formData.get("username")?.toString() ?? ""; + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users", { + body: { user: { email, password, username } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +``` + +Почти готово! Осталось подключить страницу и действие регистрации к маршруту `/register`. Создайте `register.tsx` в `app/routes`: + +```tsx title="app/routes/register.tsx" +import { RegisterPage, register } from "pages/sign-in"; + +export { register as action }; + +export default RegisterPage; +``` + +Теперь, если вы перейдете на [http://localhost:3000/register,](http://localhost:3000/register) вы сможете создать пользователя! Остальная часть приложения пока что на это не отреагирует, мы займемся этим в ближайшее время. + +Аналогичным образом мы можем реализовать страницу входа в систему. Попробуйте сами или просто возьмите код и двигайтесь дальше: + +```tsx title="pages/sign-in/api/sign-in.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const signIn = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users/login", { + body: { user: { email, password } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/ui/SignInPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { signIn } from "../api/sign-in"; + +export function SignInPage() { + const signInData = useActionData(); + + return ( +
+
+
+
+

Sign in

+

+ Need an account? +

+ + {signInData?.error && ( +
    + {signInData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +export { SignInPage } from './ui/SignInPage'; +export { signIn } from './api/sign-in'; +``` + +```tsx title="app/routes/login.tsx" +import { SignInPage, signIn } from "pages/sign-in"; + +export { signIn as action }; + +export default SignInPage; +``` + +Теперь давайте дадим пользователям возможность попасть на эти страницы. + +### Хэдер + +Как мы уже говорили в первой части, хэдер приложения обычно размещается либо в Widgets, либо в Shared. Мы поместим его в Shared, потому что он очень прост, и вся бизнес-логика может быть сохранена за его пределами. Давайте создадим для него место: + +```bash +npx fsd shared ui +``` + +Теперь создайте `shared/ui/Header.tsx` со следующим содержимым: + +```tsx title="shared/ui/Header.tsx" +import { useContext } from "react"; +import { Link, useLocation } from "@remix-run/react"; + +import { CurrentUser } from "../api/currentUser"; + +export function Header() { + const currentUser = useContext(CurrentUser); + const { pathname } = useLocation(); + + return ( + + ); +} +``` + +Экспортируйте этот компонент из `shared/ui`: + +```tsx title="shared/ui/index.ts" +export { Header } from "./Header"; +``` + +В хэдере мы полагаемся на контекст, расположенный в `shared/api`. Создайте ещё его: + +```tsx title="shared/api/currentUser.ts" +import { createContext } from "react"; + +import type { User } from "./models"; + +export const CurrentUser = createContext(null); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +export { CurrentUser } from "./currentUser"; +``` + +Теперь давайте добавим хэдер на страницу. Мы хотим, чтобы он был на каждой странице, поэтому имеет смысл просто добавить его в корневой маршрут и обернуть аутлет (место, в которое будет отрендерена страница) провайдером контекста `CurrentUser`. Таким образом, все наше приложение, включая хэдер, получит доступ к объекту текущего пользователя. Мы также добавим загрузчик для получения объекта текущего пользователя из cookies. Добавьте следующее в `app/root.tsx`: + +```tsx title="app/root.tsx" +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from "@remix-run/react"; + +import { Header } from "shared/ui"; +import { getUserFromSession, CurrentUser } from "shared/api"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export const loader = ({ request }: LoaderFunctionArgs) => + getUserFromSession(request); + +export default function App() { + const user = useLoaderData(); + + return ( + + + + + + + + + + + + + +
+ + + + + + + + ); +} +``` + +В итоге на главной странице должно получиться следующее: + +
+ ![Страница фида Conduit, на которой есть хэдер, фид и теги. Вкладки по-прежнему отсутствуют.](../../../../../../static/img/tutorial/realworld-feed-without-tabs.jpg) + +
Страница фида Conduit, на которой есть хэдер, фид и теги. Вкладки по-прежнему отсутствуют.
+
+ +### Вкладки + +Теперь, когда мы можем определить состояние аутентификации, давайте также быстренько реализуем вкладки и лайки, чтоб закончить со страницей ленты. Нам нужна еще одна форма, но этот файл страницы становится слишком большим, поэтому давайте перенесем эти формы в соседние файлы. Мы создадим `Tabs.tsx`, `PopularTags.tsx` и `Pagination.tsx` со следующим содержимым: + +```tsx title="pages/feed/ui/Tabs.tsx" +import { useContext } from "react"; +import { Form, useSearchParams } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; + +export function Tabs() { + const [searchParams] = useSearchParams(); + const currentUser = useContext(CurrentUser); + + return ( +
+
+
    + {currentUser !== null && ( +
  • + +
  • + )} +
  • + +
  • + {searchParams.has("tag") && ( +
  • + + {searchParams.get("tag")} + +
  • + )} +
+
+
+ ); +} +``` + +```tsx title="pages/feed/ui/PopularTags.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; + +export function PopularTags() { + const { tags } = useLoaderData(); + + return ( +
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+ ); +} +``` + +```tsx title="pages/feed/ui/Pagination.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; + +export function Pagination() { + const [searchParams] = useSearchParams(); + const { articles } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ + ); +} +``` + +И теперь мы можем значительно упростить саму страницу с фидом: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; +import { Tabs } from "./Tabs"; +import { PopularTags } from "./PopularTags"; +import { Pagination } from "./Pagination"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ + + {articles.articles.map((article) => ( + + ))} + + +
+ +
+ +
+
+
+
+ ); +} +``` + +Нам также нужно учесть новую вкладку в функции-загрузчике: + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + /* unchanged */ +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + if (url.searchParams.get("source") === "my-feed") { + const userSession = await requireUser(request); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles/feed", { + params: { + query: { + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + headers: { Authorization: `Token ${userSession.token}` }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); + } + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +Прежде чем мы отложим страницу ленты, давайте добавим код, который будет обрабатывать лайки к постам. Измените ваш `ArticlePreview.tsx` на следующий: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Form, Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+
+ +
+
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +Этот код отправит POST-запрос на `/article/:slug` с `_action=favorite`, чтобы отметить статью как любимую. Пока это не работает, но как только мы начнем работать над читалкой статей, мы реализуем и это. + +И на этом мы официально закончили работу над фидом! Ура! + +### Читалка статей + +Во-первых, нам нужны данные. Давайте создадим загрузчик: + +```bash +npx fsd pages article-read -s api +``` + +```tsx title="pages/article-read/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import invariant from "tiny-invariant"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, getUserFromSession } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + invariant(params.slug, "Expected a slug parameter"); + const currentUser = await getUserFromSession(request); + const authorization = currentUser + ? { Authorization: `Token ${currentUser.token}` } + : undefined; + + return json( + await promiseHash({ + article: throwAnyErrors( + GET("/articles/{slug}", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + comments: throwAnyErrors( + GET("/articles/{slug}/comments", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + }), + ); +}; +``` + +```tsx title="pages/article-read/index.ts" +export { loader } from "./api/loader"; +``` + +Теперь мы можем подключить его к маршруту `/article/:slug`, создав файл маршрута `article.$slug.tsx`: + +```tsx title="app/routes/article.$slug.tsx" +export { loader } from "pages/article-read"; +``` + +Сама страница состоит из трех основных блоков — заголовка статьи с действиями (повторяется дважды), тела статьи и раздела комментариев. Это разметка страницы, она не особенно интересна: + +```tsx title="pages/article-read/ui/ArticleReadPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticleMeta } from "./ArticleMeta"; +import { Comments } from "./Comments"; + +export function ArticleReadPage() { + const { article } = useLoaderData(); + + return ( +
+
+
+

{article.article.title}

+ + +
+
+ +
+
+
+

{article.article.body}

+
    + {article.article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} +``` + +Более интересными являются `ArticleMeta` и `Comments`. Они содержат операции записи, такие как лайкнуть статью, оставить комментарий и т. д. Чтобы они заработали, нам сначала нужно реализовать бэкенд-часть. Создайте файл `action.ts` в сегменте `api` этой страницы: + +```tsx title="pages/article-read/api/action.ts" +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { namedAction } from "remix-utils/named-action"; +import { redirectBack } from "remix-utils/redirect-back"; +import invariant from "tiny-invariant"; + +import { DELETE, POST, requireUser } from "shared/api"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const currentUser = await requireUser(request); + + const authorization = { Authorization: `Token ${currentUser.token}` }; + + const formData = await request.formData(); + + return namedAction(formData, { + async delete() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirect("/"); + }, + async favorite() { + invariant(params.slug, "Expected a slug parameter"); + await POST("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfavorite() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async createComment() { + invariant(params.slug, "Expected a slug parameter"); + const comment = formData.get("comment"); + invariant(typeof comment === "string", "Expected a comment parameter"); + await POST("/articles/{slug}/comments", { + params: { path: { slug: params.slug } }, + headers: { ...authorization, "Content-Type": "application/json" }, + body: { comment: { body: comment } }, + }); + return redirectBack(request, { fallback: "/" }); + }, + async deleteComment() { + invariant(params.slug, "Expected a slug parameter"); + const commentId = formData.get("id"); + invariant(typeof commentId === "string", "Expected an id parameter"); + const commentIdNumeric = parseInt(commentId, 10); + invariant( + !Number.isNaN(commentIdNumeric), + "Expected a numeric id parameter", + ); + await DELETE("/articles/{slug}/comments/{id}", { + params: { path: { slug: params.slug, id: commentIdNumeric } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async followAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await POST("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfollowAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await DELETE("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + }); +}; +``` + +Реэкспортируйте её из слайса, а затем из маршрута. Пока мы здесь, давайте также подключим саму страницу: + +```tsx title="pages/article-read/index.ts" +export { ArticleReadPage } from "./ui/ArticleReadPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/article.$slug.tsx" +import { ArticleReadPage } from "pages/article-read"; + +export { loader, action } from "pages/article-read"; + +export default ArticleReadPage; +``` + +Теперь, несмотря на то, что мы еще не реализовали кнопку лайка в читалке, кнопка лайка в ленте начнет работать! Это потому, что она тоже отправляет запросы на этот маршрут. Попробуйте лайкнуть что-нибудь. + +`ArticleMeta` и `Comments` — это, опять же, просто формы. Мы уже делали это раньше, давайте возьмем их код и пойдем дальше: + +```tsx title="pages/article-read/ui/ArticleMeta.tsx" +import { Form, Link, useLoaderData } from "@remix-run/react"; +import { useContext } from "react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function ArticleMeta() { + const currentUser = useContext(CurrentUser); + const { article } = useLoaderData(); + + return ( +
+
+ + + + +
+ + {article.article.author.username} + + {article.article.createdAt} +
+ + {article.article.author.username == currentUser?.username ? ( + <> + + Edit Article + +    + + + ) : ( + <> + + +    + + + )} +
+
+ ); +} +``` + +```tsx title="pages/article-read/ui/Comments.tsx" +import { useContext } from "react"; +import { Form, Link, useLoaderData } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function Comments() { + const { comments } = useLoaderData(); + const currentUser = useContext(CurrentUser); + + return ( +
+ {currentUser !== null ? ( +
+
+ +
+
+ + +
+
+ ) : ( +
+
+

+ Sign in +   or   + Sign up +   to add comments on this article. +

+
+
+ )} + + {comments.comments.map((comment) => ( +
+
+

{comment.body}

+
+ +
+ + + +   + + {comment.author.username} + + {comment.createdAt} + {comment.author.username === currentUser?.username && ( + +
+ + +
+
+ )} +
+
+ ))} +
+ ); +} +``` + +А вместе с этим и наша читалка статей! Кнопки "Подписаться на автора", "Мне нравится" и "Оставить комментарий" теперь должны работать как положено. + +
+ ![Читалка статей с рабочими кнопками подписки и лайка](../../../../../../static/img/tutorial/realworld-article-reader.jpg) + +
Читалка статей с рабочими кнопками подписки и лайка
+
+ +### Редактор статей + +Это последняя страница, которую мы рассмотрим в этом руководстве, и самая интересная часть здесь — это то, как мы будем проверять данные формы. + +Сама страница, `article-edit/ui/ArticleEditPage.tsx`, будет довольно простой, дополнительная логика будет скрыта в двух других компонентах: + +```tsx title="pages/article-edit/ui/ArticleEditPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { TagsInput } from "./TagsInput"; +import { FormErrors } from "./FormErrors"; + +export function ArticleEditPage() { + const article = useLoaderData(); + + return ( +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+
+
+
+
+ ); +} +``` + +Эта страница получает текущую статью (если мы пишем статью не с нуля) и заполняет соответствующие поля формы. Мы уже видели это. Интересной частью является `FormErrors`, потому что он будет получать результат проверки и отображать его пользователю. Давайте посмотрим: + +```tsx title="pages/article-edit/ui/FormErrors.tsx" +import { useActionData } from "@remix-run/react"; +import type { action } from "../api/action"; + +export function FormErrors() { + const actionData = useActionData(); + + return actionData?.errors != null ? ( +
    + {actionData.errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null; +} +``` + +Здесь мы предполагаем, что наш экшн будет возвращать поле `errors`, массив понятных человеку сообщений об ошибках. К экшну мы перейдем чуть позже. + +Еще один компонент — это поле ввода тегов. Это обычное поле ввода с дополнительным предпросмотром выбранных тегов. Здесь особо не на что смотреть: + +```tsx title="pages/article-edit/ui/TagsInput.tsx" +import { useEffect, useRef, useState } from "react"; + +export function TagsInput({ + name, + defaultValue, +}: { + name: string; + defaultValue?: Array; +}) { + const [tagListState, setTagListState] = useState(defaultValue ?? []); + + function removeTag(tag: string): void { + const newTagList = tagListState.filter((t) => t !== tag); + setTagListState(newTagList); + } + + const tagsInput = useRef(null); + useEffect(() => { + tagsInput.current && (tagsInput.current.value = tagListState.join(",")); + }, [tagListState]); + + return ( + <> + + setTagListState(e.target.value.split(",").filter(Boolean)) + } + /> +
+ {tagListState.map((tag) => ( + + + [" ", "Enter"].includes(e.key) && removeTag(tag) + } + onClick={() => removeTag(tag)} + >{" "} + {tag} + + ))} +
+ + ); +} +``` + +Теперь перейдем к API-части. Загрузчик должен посмотреть на URL, и если в нем есть ссылка на статью, это означает, что мы редактируем существующую статью, и ее данные должны быть загружены. В противном случае ничего не возвращается. Давайте создадим этот загрузчик: + +```tsx title="pages/article-edit/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const currentUser = await requireUser(request); + + if (!params.slug) { + return { article: null }; + } + + return throwAnyErrors( + GET("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: { Authorization: `Token ${currentUser.token}` }, + }), + ); +}; +``` + +Экшн примет новые значения полей, прогонит их через нашу схему данных и, если все правильно, зафиксирует изменения в бэкенде, либо обновив существующую статью, либо создав новую: + +```tsx title="pages/article-edit/api/action.ts" +import { json, redirect, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, PUT, requireUser } from "shared/api"; +import { parseAsArticle } from "../model/parseAsArticle"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + try { + const { body, description, title, tags } = parseAsArticle( + await request.formData(), + ); + const tagList = tags?.split(",") ?? []; + + const currentUser = await requireUser(request); + const payload = { + body: { + article: { + title, + description, + body, + tagList, + }, + }, + headers: { Authorization: `Token ${currentUser.token}` }, + }; + + const { data, error } = await (params.slug + ? PUT("/articles/{slug}", { + params: { path: { slug: params.slug } }, + ...payload, + }) + : POST("/articles", payload)); + + if (error) { + return json({ errors: error }, { status: 422 }); + } + + return redirect(`/article/${data.article.slug ?? ""}`); + } catch (errors) { + return json({ errors }, { status: 400 }); + } +}; +``` + +Наша схема данных будет ещё и парсить `FormData`, что позволяет нам удобно получать чистые поля или просто бросать ошибки для обработки в конце. Вот как может выглядеть эта функция парсинга: + +```tsx title="pages/article-edit/model/parseAsArticle.ts" +export function parseAsArticle(data: FormData) { + const errors = []; + + const title = data.get("title"); + if (typeof title !== "string" || title === "") { + errors.push("Give this article a title"); + } + + const description = data.get("description"); + if (typeof description !== "string" || description === "") { + errors.push("Describe what this article is about"); + } + + const body = data.get("body"); + if (typeof body !== "string" || body === "") { + errors.push("Write the article itself"); + } + + const tags = data.get("tags"); + if (typeof tags !== "string") { + errors.push("The tags must be a string"); + } + + if (errors.length > 0) { + throw errors; + } + + return { title, description, body, tags: data.get("tags") ?? "" } as { + title: string; + description: string; + body: string; + tags: string; + }; +} +``` + +Возможно, она покажется немного длинной и повторяющейся, но такова цена, которую мы платим за читаемые сообщения об ошибках. Это может быть и схема Zod, например, но тогда нам придется выводить сообщения об ошибках на фронтенде, а эта форма не стоит таких сложностей. + +Последний шаг — подключение страницы, загрузчика и действия к маршрутам. Поскольку мы аккуратно поддерживаем и создание, и редактирование, мы можем экспортировать одно и то же действие как из `editor._index.tsx`, так и из `editor.$slug.tsx`: + +```tsx title="pages/article-edit/index.ts" +export { ArticleEditPage } from "./ui/ArticleEditPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/editor._index.tsx, app/routes/editor.$slug.tsx (одинаковое содержимое)" +import { ArticleEditPage } from "pages/article-edit"; + +export { loader, action } from "pages/article-edit"; + +export default ArticleEditPage; +``` + +Мы закончили! Войдите в систему и попробуйте создать новую статью. Или “забудьте” написать статью и посмотрите, как сработает валидация. + +
+ ![Редактор статей Conduit, в поле заголовка которого написано “New article”, а остальные поля пусты. Над формой есть две ошибки: “**Describe what this article is about**” и “**Write the article itself**”.](../../../../../../static/img/tutorial/realworld-article-editor.jpg) + +
Редактор статей Conduit, в поле заголовка которого написано “New article”, а остальные поля пусты. Над формой есть две ошибки: **“Describe what this article is about”** и **“Write the article itself**”.
+
+ +Страницы профиля и настроек очень похожи на страницы чтения и редактирования статей, они оставлены в качестве упражнения для читателя, то есть для вас :) diff --git a/src/content/docs/ru/docs/guides/examples/api-requests.mdx b/src/content/docs/ru/docs/guides/examples/api-requests.mdx new file mode 100644 index 0000000000..96fe238d71 --- /dev/null +++ b/src/content/docs/ru/docs/guides/examples/api-requests.mdx @@ -0,0 +1,148 @@ +--- +title: Обработка API-запросов +sidebar: + order: 4 +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; +import { FileTree } from '@astrojs/starlight/components'; +import { Aside } from '@astrojs/starlight/components'; + +## API-запросы в `shared` \{#shared-api-requests\} + +Начните с размещения общей логики API-запросов в каталоге `shared/api`. Это упрощает повторное использование запросов во всем приложении, что ускоряет разработку. Для многих проектов этого будет достаточно. + +Типичная структура файлов будет такой: + + +- shared + - api + - client.ts + - index.ts + - endpoints + - login.ts + + +Файл `client.ts` централизует настройку HTTP-запросов. Он оборачивает выбранный вами подход (например, `fetch()` или экземпляр `axios`) и обрабатывает общие конфигурации, такие как: + +- Базовый URL бэкенда. +- Заголовки по умолчанию (например, для аутентификации). +- Сериализация данных. + +Вот примеры для `axios` и `fetch`: + + + + +```ts title="shared/api/client.ts" +// Example using axios +import axios from 'axios'; + +export const client = axios.create({ + baseURL: 'https://your-api-domain.com/api/', + timeout: 5000, + headers: { 'X-Custom-Header': 'my-custom-value' } +}); +``` + + + +```ts title="shared/api/client.ts" +export const client = { + async post(endpoint: string, body: any, options?: RequestInit) { + const response = await fetch(`https://your-api-domain.com/api${endpoint}`, { + method: 'POST', + body: JSON.stringify(body), + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'my-custom-value', + ...options?.headers, + }, + }); + return response.json(); + } + // ... другие методы put, delete, и т.д. +}; +``` + + + + +Организуйте свои отдельные функции API-запросов в shared/api/endpoints, группируя их по API эндпоинтам. + + + +```ts title="shared/api/endpoints/login.ts" +import { client } from '../client'; + +export interface LoginCredentials { + email: string; + password: string; +} + +export function login(credentials: LoginCredentials) { + return client.post('/login', credentials); +} +``` + +Используйте файл `index.ts` в `shared/api` для экспорта ваших функций запросов. + +```ts title="shared/api/index.ts" +export { client } from './client'; // Если нужно экспортировать клиент +export { login } from './endpoints/login'; +export type { LoginCredentials } from './endpoints/login'; +``` + +## API-запросы, специфичные для слайса \{#slice-specific-api-requests\} + +Если API-запрос используется только определенным слайсом (например, одной страницей или фичей) и не будет использоваться повторно, поместите его в сегмент `api` этого слайса. Это позволит аккуратно отделить логику, специфичную для слайса, от всего остального приложения. + + +- pages + - login + - index.ts + - api + - login.ts + - ui + - LoginPage.tsx + + +```ts title="pages/login/api/login.ts" +import { client } from 'shared/api'; + +interface LoginCredentials { + email: string; + password: string; +} + +export function login(credentials: LoginCredentials) { + return client.post('/login', credentials); +} +``` + +Вам не нужно экспортировать функцию `login()` через публичный API страницы, потому что маловероятно, что какое-либо другое место в приложении будет нуждаться в этом запросе. + + + +## Использование генераторов клиентов \{#client-generators\} + +Если ваш бэкенд предоставляет OpenAPI спецификацию, инструменты как [orval](https://orval.dev/) или [openapi-typescript](https://openapi-ts.dev/), могут генерировать типы API и функции запросов. Разместите сгенерированный код, например, в `shared/api/openapi`. Обязательно включите `README.md` для документирования того, что это за типы и как их генерировать. + +## Интеграция с библиотеками состояния сервера \{#server-state-libraries\} + +При использовании библиотек состояния сервера, таких как [TanStack Query (React Query)](https://tanstack.com/query/latest) или [Pinia Colada](https://pinia-colada.esm.dev/) вам может потребоваться совместное использование типов или ключей кеша между срезами. Используйте общий слой `shared` для таких вещей, как: + +- Типы данных API +- Ключи кеша +- Общие параметры запросов и мутаций + +Подробнее о том, как работать с server state библиотеками, читайте в статье [React Query](/docs/guides/tech/with-react-query) \ No newline at end of file diff --git a/src/content/docs/ru/docs/guides/examples/auth.mdx b/src/content/docs/ru/docs/guides/examples/auth.mdx new file mode 100644 index 0000000000..4f44565448 --- /dev/null +++ b/src/content/docs/ru/docs/guides/examples/auth.mdx @@ -0,0 +1,235 @@ +--- +title: Авторизация +sidebar: + order: 1 +--- + +import { FileTree } from '@astrojs/starlight/components'; +import { Aside } from '@astrojs/starlight/components'; + +В общих чертах авторизация состоит из следующих этапов: + +1. Получить учетные данные от пользователя +1. Отправить их на бэкенд +1. Сохранить токен для отправки авторизованных запросов. + +## Как получить учетные данные пользователя + +Мы предполагаем, что ваше приложение само собирает эти данные. Если у вас авторизация через OAuth, вы можете просто создать страницу логина со ссылкой на страницу провайдера OAuth и перейти к [шагу 3](#how-to-store-the-token-for-authenticated-requests). + +### Отдельная страница для логина + +Обычно на сайтах есть отдельные страницы для логина, где вы вводите свое имя пользователя и пароль. Эти страницы довольно просты, поэтому не требуют декомпозиции. Более того, формы логина и регистрации внешне очень похожи, поэтому их можно даже сгруппировать на одной странице. Создайте слайс для вашей страницы логина/регистрации на слое Pages: + + +- pages + - login + - ui + - LoginPage.tsx (или аналог в вашем фреймворке) + - RegisterPage.tsx + - index.ts + - ... + + +Здесь мы создали два компонента и экспортировали их обоих в индексе слайса. Эти компоненты будут содержать формы, которые содержат понятные пользователю элементы для введения их учетных данных. + +### Диалог для логина + +Если в вашем приложении есть диалоговое окно для входа в систему, которое можно использовать на любой странице, вы можете создать для этого диалогового окна виджет. Таким образом, вы все равно сможете не сильно декомпозировать саму форму, но при этом переиспользовать этот диалог на любой странице. + + +- widgets + - login-dialog + - ui + - LoginDialog.tsx + - index.ts + - ... + + +Остальная часть этого руководства написана для первого подхода, где логин делается на отдельной странице, но те же принципы применимы и к виджету диалога. + +### Клиентская валидация + +Иногда, особенно при регистрации, имеет смысл выполнить проверку на стороне клиента, чтобы быстро сообщить пользователю, что они допустили ошибку. Проверка может происходить в сегменте `model` на странице логина. Используйте библиотеку проверки по схемам, например, [Zod][ext-zod] для JS/TS, и предоставьте эту схему сегменту `ui`: + +```ts title="pages/login/model/registration-schema.ts" +import { z } from "zod"; + +export const registrationData = z.object({ + email: z.string().email(), + password: z.string().min(6), + confirmPassword: z.string(), +}).refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], +}); +``` + +Затем в сегменте `ui` вы можете использовать эту схему для проверки ввода пользователя: + +```tsx title="pages/login/ui/RegisterPage.tsx" +import { registrationData } from "../model/registration-schema"; + +function validate(formData: FormData) { + const data = Object.fromEntries(formData.entries()); + try { + registrationData.parse(data); + } catch (error) { + // TODO: Показать пользователю сообщение об ошибке + } +} + +export function RegisterPage() { + return ( +
validate(new FormData(e.target))}> + + + + + + + + +
+ ) +} +``` + +## Как отправить учетные данные на бэкенд + +Создайте функцию, которая отправляет запрос к эндпоинту логина на бэкенде. Эту функцию можно вызвать либо непосредственно в коде компонента через библиотеку мутаций (например, TanStack Query), либо как побочный эффект в стейт-менеджере. + +### Где хранить функцию запроса + +Есть два места, куда можно положить эту функцию: в `shared/api` или в сегмент `api` на странице. + +#### В `shared/api` + +Этот подход хорошо сочетается с тем, чтобы размещать в `shared/api` все функции запросов, и группировать их по эндпоинту, например. Структура файлов в таком случае может выглядеть так: + + +- shared + - api + - endpoints + - login.ts + - ... + - client.ts + - index.ts + + +Файл `📄 client.ts` содержит обёртку над примитивом, выполняющим запросы (например, `fetch()`). Эта обёртка знает про base URL вашего бэкенда, проставляет необходимые заголовки, сериализует данные, и т.д. + +```ts title="shared/api/endpoints/login.ts" +import { POST } from "../client"; + +export function login({ email, password }: { email: string, password: string }) { + return POST("/login", { email, password }); +} +``` + +```ts title="shared/api/index.ts" +export { login } from "./endpoints/login"; +``` + +#### В сегменте `api` страницы + +Если вы не храните все свои запросы в одном месте, возможно, вам подойдет разместить эту функцию запроса в сегменте `api` на странице логина. + + +- pages + - login + - api + - login.ts + - ui + - LoginPage.tsx + - index.ts + - ... + + +```ts title="pages/login/api/login.ts" +import { POST } from "shared/api"; + +export function login({ email, password }: { email: string, password: string }) { + return POST("/login", { email, password }); +} +``` + +Эту функцию даже необязательно реэкспортировать из индекса страницы, потому что, скорее всего, она будет использоваться только внутри этой страницы. + +### Двухфакторная аутентификация + +Если ваше приложение поддерживает двухфакторную аутентификацию (2FA), возможно, вам придется перенаправить пользователя на другую страницу, где они смогут ввести одноразовый пароль. Обычно, ваш запрос `POST /login` возвращает объект пользователя с флагом, указывающим, что у пользователя включен 2FA. Если этот флаг установлен, перенаправьте пользователя на страницу 2FA. + +Поскольку эта страница очень связана с логином, вы также можете положить её в тот же слайс, `login`, на слое Pages. + +Вам также понадобится еще одна функция запроса, похожая на `login()`, которую мы создали выше. Поместите их вместе либо в Shared, либо в сегмент `api` на странице `login`. + +## Как хранить токен для авторизованных запросов \{#how-to-store-the-token-for-authenticated-requests\} + +Независимо от используемой вами схемы авторизации, будь то простой логин и пароль, OAuth или двухфакторная аутентификация, в конце вы получите токен. Этот токен следует хранить, чтобы последующие запросы могли идентифицировать себя. + +Идеальным хранилищем токенов для веб-приложения являются **cookies** — они не требуют ручного сохранения или обработки токенов. Таким образом, хранение cookies практически не требует усилий со стороны архитектуры фронтенда. Если ваш фронтенд-фреймворк имеет серверную часть (например, [Remix][ext-remix]), то серверную инфраструктуру cookies следует хранить в `shared/api`. В [разделе туториала «Аутентификация»][tutorial-authentication] есть пример того, как это сделать в Remix. + +Однако, иногда хранить токен в cookies — не вариант. В этом случае вам придется хранить токен самим. Помимо этого, вам также может потребоваться написать логику для обновления этого токена по истечении срока его действия. В рамках FSD есть несколько мест, где вы можете хранить токен, а также несколько способов сделать его доступным для остальной части приложения. + +### В Shared + +Этот подход хорошо работает, когда API-клиент определен в `shared/api`, поскольку токен свободно доступен ему для других функций-запросов, которые требуют авторизацию. Вы можете сделать так, чтобы клиент имел свой стейт, либо с помощью реактивного хранилища, либо просто с помощью переменной на уровне модуля. Затем вы можете обновлять этот стейт в ваших функциях `login()`/`logout()`. + +Автоматическое обновление токена может быть реализовано как middleware в API-клиенте — то, что выполняется каждый раз, когда вы делаете какой-либо запрос. Например, можно сделать так: + +- Авторизоваться и сохранить токен доступа, а также токен обновления. +- Сделать любой запрос, требующий авторизации +- Если запрос падает с кодом состояния, указывающим на истечение срока действия токена, а в хранилище есть токен, сделать запрос на обновление, сохранить новые токены и повторить исходный запрос. + +Одним из недостатков этого подхода является то, что логика хранения и обновления токена не имеет выделенного места. Это может подойти каким-то приложениям или командам, но если логика управления токенами более сложна, может захотеться разделить обязанности по отправке запросов и управлению токенами. В этом случае можно положить запросы и API-клиент в `shared/api`, а хранилище токенов и логику обновления — в `shared/auth`. + +Еще одним недостатком этого подхода является то, что если ваш сервер возвращает объект c информацией о вашем текущем пользователе вместе с токеном, вам будет некуда её положить, и придется запросить её снова из специального эндпоинта, например `/me` или `/users/current`. + +### В Entities + +У проектов на FSD часто есть сущность пользователя и/или сущность текущего пользователя. Это даже может быть одна сущность. + + + +Чтобы хранить токен в сущности User, создайте реактивное хранилище в сегменте `model`. Это хранилище может содержать одновременно и токен, и объект с информацией о пользователе. + +Поскольку API-клиент обычно размещается в `shared/api` или распределяется между сущностями, главной проблемой этого подхода является обеспечение доступа к токену для других запросов, без нарушения [правил импортов для слоёв][import-rule-on-layers]: + +> Модуль (файл) в слайсе может импортировать другие слайсы только в том случае, если они расположены на слоях строго ниже. + +Есть несколько решений этой проблемы: + +1. **Передавать токен вручную каждый раз, когда делаете запрос** + Это самое простое решение, но оно быстро становится неудобным, и если у вас нет строгой типизации, об этом легко забыть. Это решение также несовместимо с паттерном middleware для API-клиента в Shared. +1. **Открыть доступ к токену для всего приложения через контекст или глобальное хранилище вроде `localStorage`** + Ключ, по которому можно будет получить токен, будет храниться в `shared/api`, чтобы API-клиент мог его использовать. Реактивное хранилище токена будет экспортировано из сущности User, а провайдер контекста (если требуется) будет настроен на слое App. Это дает больше свободы для дизайна API-клиента, но такой подход создаёт неявную зависимость +1. **Вставлять токен в API-клиент каждый раз, когда токен меняется** + Если ваше хранилище реактивное, то можно подписаться на изменения и обновлять токен в API-клиенте каждый раз, когда хранилище в сущности User меняется. Это похоже на прошлое решение тем, что они оба создают неявную зависимость, но это решение более императивное ("push"), тогда как предыдущее — более декларативное ("pull"). + +Решив проблему доступности токена, хранящегося в модели сущности User, вы сможете описать дополнительную бизнес-логику, связанную с управлением токенами. Например, сегмент `model` может содержать логику, которая делает токен недействительным через определенный период времени или обновляет токен по истечении срока его действия. Чтобы совершать запросы на бэкенд для выполнения этих задач, используйте сегмент `api` сущности User или `shared/api`. + +### В Pages/Widgets (не рекомендуется) + +Не рекомендуется хранить состояние, актуальное для всего приложения, как например токен доступа, в страницах или виджетах. Не стоит размещать хранилище токенов в сегменте `model` на странице логина. Вместо этого выберите одно из первых двух решений: Shared или Entities. + +## Логаут и аннулирование токена + +Обычно в приложениях не делают целую отдельную страницу для логаута, но функционал логаута, тем не менее, очень важен. В этот функционал входит авторизованный запрос на бэкенд и обновление хранилища токенов. + +Если вы храните все ваши запросы в `shared/api`, оставьте там функцию для запроса на логаут, рядом с функцией для логина. Если нет, разместите функцию-запрос на логаут рядом с кнопкой, которая её вызывает. Например, если у вас есть виджет хэдера, который есть на каждой странице и содержит ссылку для логаута, поместите этот запрос в сегмент `api` этого виджета. + +Обновление хранилища токенов также должно будет запускаться с места кнопки логаута, как, например, виджет заголовка. Вы можете объединить запрос и обновление хранилища в сегменте `model` этого виджета. + +### Автоматический логаут + +Не забудьте предусмотреть ситуации сбоя запроса на логаут или сбоя запроса на обновление токена. В обоих случаях вам следует очистить хранилище токенов. Если вы храните свой токен в Entities, этот код можно поместить в сегмент `model`, поскольку это чистая бизнес-логика. Если вы храните токен в Shared, размещение этой логики в `shared/api` может раздуть сегмент и размыть его предназначение. Если вы замечаете, что ваш сегмент `api` содержит две несвязанные вещи, рассмотрите возможность выделения логики управления токенами в другой сегмент, например, `shared/auth`. + +[tutorial-authentication]: /docs/get-started/tutorial#authentication +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-remix]: https://remix.run +[ext-zod]: https://zod.dev diff --git a/src/content/docs/ru/docs/guides/examples/page-layout.mdx b/src/content/docs/ru/docs/guides/examples/page-layout.mdx new file mode 100644 index 0000000000..a0d825c636 --- /dev/null +++ b/src/content/docs/ru/docs/guides/examples/page-layout.mdx @@ -0,0 +1,105 @@ +--- +title: Лейауты страниц +sidebar: + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + +Это руководство рассматривает абстракцию _лейаута страницы_ — когда несколько страниц имеют одинаковую структуру, отличаясь только основным содержимым. + + + +## Простой лейаут + +Самый простой лейаут можно увидеть прямо на этой странице. Он имеет хэдер с навигацией по сайту, два сайдбара и футер с внешними ссылками. Здесь нет сложной бизнес-логики, и единственные динамические части — это сайдбары и переключатели справа в хэдере. Такой лейаут можно разместить целиком в `shared/ui` или в `app/layouts`, с заполнением контента сайдбаров через пропы: + +```tsx title="shared/ui/layout/Layout.tsx" +import { Link, Outlet } from "react-router-dom"; +import { useThemeSwitcher } from "./useThemeSwitcher"; + +export function Layout({ siblingPages, headings }) { + const [theme, toggleTheme] = useThemeSwitcher(); + + return ( +
+
+ + +
+
+ + {/* Здесь будет основное содержимое страницы */} + +
+
+
    +
  • GitHub
  • +
  • Twitter
  • +
+
+
+ ); +} +``` + +```ts title="shared/ui/layout/useThemeSwitcher.ts" +export function useThemeSwitcher() { + const [theme, setTheme] = useState("light"); + + function toggleTheme() { + setTheme(theme === "light" ? "dark" : "light"); + } + + useEffect(() => { + document.body.classList.remove("light", "dark"); + document.body.classList.add(theme); + }, [theme]); + + return [theme, toggleTheme] as const; +} +``` + +Код сайдбаров оставлен читателю в качестве упражнения 😉. + +## Использование виджетов в лейауте + +Иногда есть необходимость включить в лейаут определенную бизнес-логику, особенно если вы используете глубоко вложенные маршруты с роутером типа [React Router][ext-react-router]. Тогда вы не можете хранить лейаут в Shared или в Widgets из-за [правила импорта для слоёв][import-rule-on-layers]: + +> Модуль в слайсе может импортировать другие слайсы только в том случае, если они расположены на слоях строго ниже. + +Прежде чем обсуждать решения, нам нужно обсудить, действительно ли это проблема. Вам _действительно нужен_ этот лейаут, и если да, _действительно ли_ он должен быть виджетом? Если блок бизнес-логики, про который идёт речь, используется на 2-3 страницах, и лейаут просто является небольшой обёрткой для этого виджета, рассмотрите один из этих двух вариантов: + +1. **Напишите ваш лейаут прямо в коде роутера на уровне App** + Это отлично подходит для роутеров, поддерживающих вложенность, потому что вы можете группировать определенные маршруты и применять нужный лейаут только к ним. + +2. **Просто скопируйте его** + Желание абстрагировать код часто переоценено. Это особенно верно для лейаутов, потому что они редко меняются. В какой-то момент, если одна из этих страниц потребует изменений, вы можете просто внести изменения, не затрагивая другие страницы. Если вы беспокоитесь, что кто-то может забыть обновить другие страницы, всегда можно оставить комментарий, описывающий отношения между страницами. + +Если ни один из вышеперечисленных вариантов не подходит, есть два решения для включения виджета в лейаут: + +1. **Используйте render props или слоты** + Большинство фреймворков позволяют передавать часть UI внешне. В React это называется [render props][ext-render-props], в Vue — [слоты][ext-vue-slots]. + +2. **Переместите лейаут на уровень App** + Вы также можете хранить свой лейаут на уровне App, например, в `app/layouts`, и комбинировать любые виджеты, которые вам нужны. + +## Дополнительные материалы + +- Пример создания лейаута с аутентификацией с помощью React и Remix (аналогичен React Router) можно найти в [туториале][tutorial]. + +[tutorial]: /docs/get-started/tutorial +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-react-router]: https://reactrouter.com/ +[ext-render-props]: https://www.patterns.dev/react/render-props-pattern/ +[ext-vue-slots]: https://ru.vuejs.org/guide/components/slots diff --git a/src/content/docs/ru/docs/guides/examples/types.mdx b/src/content/docs/ru/docs/guides/examples/types.mdx new file mode 100644 index 0000000000..43e5a88c0c --- /dev/null +++ b/src/content/docs/ru/docs/guides/examples/types.mdx @@ -0,0 +1,441 @@ +--- +title: Типы +sidebar: + order: 2 +--- + +import { FileTree } from '@astrojs/starlight/components'; +import { Aside } from '@astrojs/starlight/components'; + +В этом руководстве рассматриваются типы данных из типизированных языков, таких как TypeScript, и где они вписываются в FSD. + + + +## Типы-утилиты + +Типы-утилиты — это типы, которые сами по себе не имеют особого смысла и обычно используются с другими типами. Например: + +
+ +```ts +type ArrayValues = T[number]; +``` + +
+ Источник: https://github.com/sindresorhus/type-fest/blob/main/source/array-values.d.ts +
+ +
+ +Чтобы добавить типы-утилиты в ваш проект, установите библиотеку, например [`type-fest`][ext-type-fest], или создайте свою собственную библиотеку в `shared/lib`. Обязательно четко укажите, какие новые типы _можно_ добавлять в эту библиотеку, а какие — _нельзя_. Например, назовите ее `shared/lib/utility-types` и добавьте внутрь файл README, описывающий, что такое типы-утилиты в понимании вашей команды. + +Не переоценивайте потенциал переиспользования типов-утилит. То, что их _можно_ использовать повторно, не означает, что так и будет, и поэтому не каждый тип-утилита должен быть в Shared. Некоторые типы-утилиты должны лежать прямо там, где они нужны: + + +- pages + - home + - api + - ArrayValues.ts (тип-утилита) + - getMemoryUsageMetrics.ts (код, который будет использовать эту утилиту) + + + + +## Бизнес-сущности и их ссылки друг на друга + +Одними из наиболее важных типов в приложении являются типы бизнес-сущностей, т. е. реальных вещей, с которыми работает ваше приложение. Например, в приложении сервиса онлайн-музыки у вас могут быть бизнес-сущности _Песня_ (song), _Альбом_ (album) и т. д. + +Бизнес-сущности часто приходят с бэкенда, поэтому первым шагом является типизация ответов бэкенда. Удобно иметь функцию запроса к каждому эндпоинту и типизировать результат вызова этой функции. Для дополнительной безопасности типов вы можете пропустить результат через библиотеку проверки по схемам, например [Zod][ext-zod]. + +Например, если вы храните все свои запросы в Shared, вы можете сделать так: + +```ts title="shared/api/songs.ts" +import type { Artist } from "./artists"; + +interface Song { + id: number; + title: string; + artists: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +Вы могли заметить, что тип `Song` ссылается на другую сущность, `Artist`. Это преимущество хранения ваших запросов в Shared — реальные типы часто ссылаются друг на друга. Если бы мы положили эту функцию в `entities/song/api`, мы бы не смогли просто импортировать `Artist` из `entities/artist`, потому что FSD ограничивает кросс-импорт между слайсами через [правило импорта для слоёв][import-rule-on-layers]: + +> Модуль в слайсе может импортировать другие слайсы только в том случае, если они расположены на слоях строго ниже. + +Есть два способа решения этой проблемы: + +1. **Параметризируйте типы** + Вы можете сделать так, чтоб ваши типы принимали типовые аргументы в качестве слотов для соединения с другими сущностями, и даже накладывать ограничения на эти слоты. Например: + + ```ts title="entities/song/model/song.ts" + interface Song { + id: number; + title: string; + artists: Array; + } + ``` + + Это хорошо работает для некоторых типов, и иногда хуже работает для других. Простой тип, такой как `Cart = { items: Array }`, можно легко заставить работать с любым типом продукта. Более связанные типы, такие как `Country` и `City`, может быть не так легко разделить. + +2. **Кросс-импортируйте (но только правильно)** + Чтоб сделать кросс-импорт между сущностями в FSD, вы можете использовать отдельный публичный API специально для каждого слайса, который будет кросс-импортировать. Например, если у нас есть сущности `song` (песня), `artist` (исполнитель), и `playlist` (плейлист), и последние две должны ссылаться на `song`, мы можем создать два специальных публичных API для них обоих в сущности `song` через `@x`-нотацию: + + +- entities + - song + - @x + - artist.ts (публичный API, из которого будет импортировать сущность `artist`) + - playlist.ts (публичный API, из которого будет импортировать сущность `playlist`) + - index.ts (обыкновенный публичный API) + + + Содержимое файла `📄 entities/song/@x/artist.ts` похоже на `📄 entities/song/index.ts`: + + + ```ts title="entities/song/@x/artist.ts" + export type { Song } from "../model/song.ts"; + ``` + + Затем `📄 entities/artist/model/artist.ts` может импортировать `Song` следующим образом: + + ```ts title="entities/artist/model/artist.ts" + import type { Song } from "entities/song/@x/artist"; + + export interface Artist { + name: string; + songs: Array; + } + ``` + + С помощью явных связей между сущностями мы получаем точный контроль взаимозависимостей и при этом поддерживаем достаточный уровень разделения доменов. + +## Объекты передачи данных (DTO) и мапперы \{#data-transfer-objects-and-mappers\} + +Объекты передачи данных, или DTO (от англ. _data transfer object_), — это термин, описывающий форму данных, которые поступают из бэкенда. Иногда DTO можно использовать как есть, но иногда их формат неудобен для фронтенда. Тут приходят на помощь мапперы — это функции, которые преобразуют DTO в более удобную форму. + +### Куда положить DTO + +Если ваши типы бэкенда находятся в отдельном пакете (например, если вы делите код между фронтендом и бэкендом), просто импортируйте ваши DTO оттуда, и готово! Если вы не делите код между бэкендом и фронтендом, вам нужно хранить DTO где-то в вашем фронтенд-коде, и мы рассмотрим этот случай ниже. + +Если вы храните функции запросов в `shared/api`, то именно там должны быть DTO, прямо рядом с функцией, которая их использует: + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + artist_ids: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +Как упоминалось в предыдущем разделе, хранение ваших запросов и DTO в Shared имеет преимущество того, что вы можете ссылаться на другие DTO. + +### Куда положить мапперы + +Мапперы — это функции, которые принимают DTO для преобразования, и, следовательно, они должны находиться рядом с определением DTO. На практике это означает, что если ваши запросы и DTO определены в `shared/api`, то и мапперы должны быть там же: + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} + +interface Song { + id: string; + title: string; + /** The full title of the song, including the disc number. */ + fullTitle: string; + artistIds: Array; +} + +function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +Если ваши запросы и хранилища определены в слайсах сущностей, то весь этот код должен быть там, с учётом ограничения кросс-импортов между сущностями: + +```ts title="entities/song/api/dto.ts" +import type { ArtistDTO } from "entities/artist/@x/song"; + +export interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} +``` + +```ts title="entities/song/api/mapper.ts" +import type { SongDTO } from "./dto"; + +export interface Song { + id: string; + title: string; + /** Полное название песни, включая номер диска. */ + fullTitle: string; + artistIds: Array; +} + +export function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} +``` + +```ts title="entities/song/api/listSongs.ts" +import { adaptSongDTO } from "./mapper"; + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +```ts title="entities/song/model/songs.ts" +import { createSlice, createEntityAdapter } from "@reduxjs/toolkit"; + +import { listSongs } from "../api/listSongs"; + +export const fetchSongs = createAsyncThunk('songs/fetchSongs', listSongs); + +const songAdapter = createEntityAdapter(); +const songsSlice = createSlice({ + name: "songs", + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSongs.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload); + }) + }, +}); +``` + +### Что делать с вложенными DTO + +Самый проблемный момент — это когда ответ от бэкенда содержит несколько сущностей. Например, если песня включает в себя не только ID авторов, но и сами объекты данных об авторах целиком. В этом случае сущности не могут не знать друг о друге (если только мы не хотим выбрасывать данные или проводить серьезную беседу с командой бэкенда). Вместо того, чтобы придумывать решения для неявных связей между срезами (например, общий middleware, который будет диспатчить действия другим слайсам), предпочитайте явный кросс-импорт через `@x`-нотацию. Вот как мы можем это реализовать с Redux Toolkit: + + +```ts title="entities/song/model/songs.ts" +import { + createSlice, + createEntityAdapter, + createAsyncThunk, + createSelector, +} from '@reduxjs/toolkit' +import { normalize, schema } from 'normalizr' + +import { getSong } from "../api/getSong"; + +// Объявляем схемы сущностей в normalizr +export const artistEntity = new schema.Entity('artists') +export const songEntity = new schema.Entity('songs', { + artists: [artistEntity], +}) + +const songAdapter = createEntityAdapter() + +export const fetchSong = createAsyncThunk( + 'songs/fetchSong', + async (id: string) => { + const data = await getSong(id) + // Нормализуем данные, чтобы редьюсеры могли загружать предсказуемый объект, например: + // `action.payload = { songs: {}, artists: {} }` + const normalized = normalize(data, songEntity) + return normalized.entities + } +) + +export const slice = createSlice({ + name: 'songs', + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload.songs) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +```ts title="entities/song/@x/artist.ts" +export { fetchSong } from "../model/songs"; +``` + +```ts title="entities/artist/model/artists.ts" +import { createSlice, createEntityAdapter } from '@reduxjs/toolkit' + +import { fetchSong } from 'entities/song/@x/artist' + +const artistAdapter = createEntityAdapter() + +export const slice = createSlice({ + name: 'users', + initialState: artistAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + // И здесь обрабатываем тот же ответ с бэкенда, добавляя исполнителей + artistAdapter.upsertMany(state, action.payload.artists) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +Это немного ограничивает преимущества изоляции слайсов, но чётко обозначает связь между этими двумя сущностями, которую мы не контролируем. Если эти сущности когда-либо будут рефакториться, их нужно будет рефакторить вместе. + +## Глобальные типы и Redux + +Глобальные типы — это типы, которые будут использоваться во всем приложении. Существует два вида глобальных типов, в зависимости от того, что им нужно знать: +1. Универсальные типы, которые не имеют никакой специфики приложения +2. Типы, которым нужно знать обо всем приложении + +Первый случай легко решить — поместите свои типы в Shared, в соответствующий сегмент. Например, если у вас есть интерфейс глобальной переменной для аналитики, вы можете поместить его в `shared/analytics`. + + + +Второй случай часто встречается в проектах с Redux без RTK. Ваш окончательный тип хранилища доступен только после того, как вы соедините все редьюсеры, но этот тип хранилища нужен селекторам, которые вы используете в приложении. Например, вот типичное определение хранилища в Redux: + +```ts title="app/store/index.ts" +import { combineReducers, rootReducer } from "redux"; + +import { songReducer } from "entities/song"; +import { artistReducer } from "entities/artist"; + +const rootReducer = combineReducers(songReducer, artistReducer); + +const store = createStore(rootReducer); + +type RootState = ReturnType; +type AppDispatch = typeof store.dispatch; +``` + +Было бы неплохо иметь типизированные хуки `useAppDispatch` и `useAppSelector` в `shared/store`, но они не могут импортировать `RootState` и `AppDispatch` из слоя App из-за [правила импорта для слоёв][import-rule-on-layers]: + +> Модуль в слайсе может импортировать другие слайсы только в том случае, если они расположены на слоях строго ниже. + +Рекомендуемое решение в этом случае — создать неявную зависимость между слоями Shared и App. Эти два типа, `RootState` и `AppDispatch`, вряд ли изменятся, и они будут знакомы разработчикам на Redux, поэтому неявная связь вряд ли станет проблемой. + +В TypeScript это можно сделать, объявив типы как глобальные, например так: + +```ts title="app/store/index.ts" +/* то же содержимое, что и в блоке кода до этого… */ + +declare type RootState = ReturnType; +declare type AppDispatch = typeof store.dispatch; +``` + +```ts title="shared/store/index.ts" +import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux"; + +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector: TypedUseSelectorHook = useSelector; +``` + +## Схемы валидации типов и Zod + +Если вы хотите проверить, что ваши данные соответствуют определенной форме или ограничениям, вы можете создать схему валидации. В TypeScript популярной библиотекой для этой задачи является [Zod][ext-zod]. Схемы валидации также должны быть размещены рядом с кодом, который их использует, насколько это возможно. + +Схемы валидации похожи на мапперы (как обсуждалось в разделе [Объекты передачи данных (DTO) и мапперы](#data-transfer-objects-and-mappers)) в том смысле, что они принимают объект передачи данных и парсят его, выдавая ошибку, если парсинг не удался. + +Один из наиболее распространенных случаев валидации — это данные, поступающие с бэкенда. Обычно вы хотите пометить запрос как неудавшийся, если данные не соответствуют схеме, поэтому имеет смысл поместить схему в том же месте, что и функция запроса, что обычно является сегментом `api`. + +Если ваши данные поступают через пользовательский ввод, например, через форму, валидация должна происходить во время ввода данных. Вы можете разместить свою схему в сегменте `ui`, рядом с компонентом формы, или в сегменте `model`, если сегмент `ui` слишком перегружен. + +## Типизация пропов компонентов и контекста + +В целом, лучше хранить интерфейс пропов или контекста в том же файле, что и компонент или контекст, который их использует. Если у вас фреймворк с однофайловыми компонентами, например, Vue или Svelte, и вы не можете определить интерфейс пропов в том же файле, или вы хотите переиспользовать этот интерфейс между несколькими компонентами, создайте отдельный файл в той же папке, обычно в сегменте `ui`. + +Вот пример с JSX (React или Solid): + +```ts title="pages/home/ui/RecentActions.tsx" +interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} + +export function RecentActions({ actions }: RecentActionsProps) { + /* … */ +} +``` + +И вот пример с интерфейсом, хранящимся в отдельном файле, для Vue: + +```ts title="pages/home/ui/RecentActionsProps.ts" +export interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} +``` + +```html title="pages/home/ui/RecentActions.vue" + +``` + +## Декларационные файлы окружения (`*.d.ts`) + +Некоторые пакеты, например, [Vite][ext-vite] или [ts-reset][ext-ts-reset], требуют декларационные файлы окружения для работы в вашем приложении. Обычно они небольшие и несложные, поэтому часто не требуют какой-либо архитектуры, их можно просто поместить в папку `src/`. Чтобы `src` был более организованным, вы можете хранить их на слое App, в `app/ambient/`. + +Другие пакеты просто не имеют типов, и вам может понадобиться объявить их как нетипизированные или даже написать собственные типы для них. Хорошим местом для этих типов будет `shared/lib`, в папке типа `shared/lib/untyped-packages`. Создайте там файл `%LIBRARY_NAME%.d.ts` и объявите типы, которые вам нужны: + +```ts title="shared/lib/untyped-packages/use-react-screenshot.d.ts" +// У этой библиотеки нет типов, и мы не хотели заморачиваться с написанием своих. +declare module "use-react-screenshot"; +``` + +## Автогенерация типов + +Часто бывает полезно генерировать типы из внешних источников, например, генерировать типы бэкенда из схемы OpenAPI. В этом случае создайте специальное место в вашем коде для этих типов, например, `shared/api/openapi`. Идеально, если вы также включите README в эту папку, который описывает, что это за файлы, как их перегенерировать и т. д. + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-type-fest]: https://github.com/sindresorhus/type-fest +[ext-zod]: https://zod.dev +[ext-vite]: https://vitejs.dev +[ext-ts-reset]: https://www.totaltypescript.com/ts-reset diff --git a/src/content/docs/ru/docs/guides/migration/from-custom.mdx b/src/content/docs/ru/docs/guides/migration/from-custom.mdx new file mode 100644 index 0000000000..f00c6bc643 --- /dev/null +++ b/src/content/docs/ru/docs/guides/migration/from-custom.mdx @@ -0,0 +1,236 @@ +--- +title: Миграция с кастомной архитектуры +sidebar: + order: 1 + label: С кастомной архитектуры +--- + +import { FileTree } from '@astrojs/starlight/components'; +import { Aside } from '@astrojs/starlight/components'; + +Это руководство описывает подход, который может быть полезен при миграции с кастомной самодельной архитектуры на Feature-Sliced Design. + +Вот структура папок типичной кастомной архитектуры. Мы будем использовать ее в качестве примера в этом руководстве. +Нажмите на синюю стрелку, чтобы открыть папку. + + +- src + - actions + - product/ + - order/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - routes/ + - products.jsx + - products.[id].jsx + - utils/ + - reducers/ + - selectors/ + - styles/ + - App.jsx + - index.js + + +## Перед началом \{#before-you-start\} + +Самый важный вопрос, который нужно задать своей команде при рассмотрении перехода на Feature-Sliced Design, — _действительно ли вам это нужно?_ Мы любим Feature-Sliced Design, но даже мы признаем, что некоторые проекты прекрасно обойдутся и без него. + +Вот несколько причин, по которым стоит рассмотреть переход: + +1. Новые члены команды жалуются, что сложно достичь продуктивного уровня +2. Внесение изменений в одну часть кода **часто** приводит к тому, что ломается другая несвязанная часть +3. Добавление новой функциональности затруднено из-за огромного количества вещей, о которых нужно думать + +**Избегайте перехода на FSD против воли ваших коллег**, даже если вы являетесь тимлидом. +Сначала убедите своих коллег в том, что преимущества перевешивают стоимость миграции и стоимость изучения новой архитектуры вместо установленной. + +Также имейте в виду, что любые изменения в архитектуре незаметны для руководства в моменте. Убедитесь, что они поддерживают переход, прежде чем начинать, и объясните им, как этот переход может быть полезен для проекта. + + + +--- + +Если вы всё-таки приняли решение начать миграцию, то первое, что вам следует сделать, — настроить алиас для `📁 src`. Это будет полезно позже, чтоб ссылаться на папки верхнего уровня. Далее в тексте мы будем считать `@` псевдонимом для `./src`. + +## Шаг 1. Разделите код по страницам \{#divide-code-by-pages\} + +Большинство кастомных архитектур уже имеют разделение по страницам, независимо от размера логики. Если у вас уже есть `📁 pages`, вы можете пропустить этот шаг. + +Если у вас есть только `📁 routes`, создайте `📁 pages` и попробуйте переместить как можно больше кода компонентов из `📁 routes`. Идеально, если у вас будет маленький файл роута и больший файл страницы. При перемещении кода создайте папку для каждой страницы и добавьте в нее индекс-файл: + + + +Файл роута: + +```js title="src/routes/products.[id].js" +export { ProductPage as default } from "@/pages/product" +``` + +Индекс-файл страницы: + +```js title="src/pages/product/index.js" +export { ProductPage } from "./ProductPage.jsx" +``` + +Файл с компонентом страницы: + +```jsx title="src/pages/product/ProductPage.jsx" +export function ProductPage(props) { + return
; +} +``` + +## Шаг 2. Отделите все остальное от страниц \{#separate-everything-else-from-pages\} + +Создайте папку `📁 src/shared` и переместите туда все, что не импортируется из `📁 pages` или `📁 routes`. Создайте папку `📁 src/app` и переместите туда все, что импортирует страницы или роуты, включая сами роуты. + +Помните, что у слоя Shared нет слайсов, поэтому сегменты могут импортировать друг из друга. + +В итоге у вас должна получиться структура файлов, похожая на эту: + + +- src + - app + - routes + - products.jsx + - products.[id].jsx + - App.jsx + - index.js + - pages + - product + - ui + - ProductPage.jsx + - index.js + - catalog + - shared + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## Шаг 3. Устраните кросс-импорты между страницами \{#tackle-cross-imports-between-pages\} + +Найдите все случаи, когда одна страница импортирует что-то из другой, и сделайте одно из двух: + +1. Скопируйте код, который импортируется, в зависимую страницу, чтобы убрать зависимость +2. Переместите код в соответствующий сегмент в Shared: + - если это часть UI-кита, переместите в `📁 shared/ui`; + - если это константа конфигурации, переместите в `📁 shared/config`; + - если это взаимодействие с бэкендом, переместите в `📁 shared/api`. + + + +## Шаг 4. Разберите слой Shared \{#unpack-shared-layer\} + +На данном этапе у вас может быть много всего в слое Shared, и в целом, следует избегать таких ситуаций. Причина этому в том, что слой Shared может быть зависимостью для любого другого слоя в вашем коде, поэтому внесение изменений в этот код автоматически более чревато непредвиденными последствиями. + +Найдите все объекты, которые используются только на одной странице, и переместите их в слайс этой страницы. И да, _это относится и к экшнам (actions), редьюсерам (reducers) и селекторам (selectors)_. Нет никакой пользы в группировке всех экшнов вместе, но есть польза в том, чтобы поместить актуальные экшны рядом с их местом использования. + +В итоге у вас должна получиться структура файлов, похожая на эту: + + +- src + - app/ unchanged + - pages/ + - product/ + - actions/ + - reducers/ + - selectors/ + - ui/ + - Component.jsx + - Container.jsx + - ProductPage.jsx + - index.js + - catalog/ + - shared only objects that are reused + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## Шаг 5. Распределите код по техническому назначению \{#organize-by-technical-purpose\} + +В FSD разделение по техническому назначению происходит с помощью _сегментов_. Существует несколько часто встречающихся сегментов: + +- `ui` — всё, что связано с отображением интерфейса: компоненты UI, форматирование дат, стили и т. д. +- `api` — взаимодействие с бэкендом: функции запросов, типы данных, мапперы и т. д. +- `model` — модель данных: схемы, интерфейсы, хранилища и бизнес-логика. +- `lib` — библиотечный код, который нужен другим модулям на этом слайсе. +- `config` — файлы конфигурации и фиче-флаги. + +Вы можете создавать свои собственные сегменты, если это необходимо. Убедитесь, что не создаете сегменты, которые группируют код по тому, чем он является, например, `components`, `actions`, `types`, `utils`. Вместо этого группируйте код по тому, для чего он предназначен. + +Перераспределите код ваших страниц по сегментам. У вас уже должен быть сегмент `ui`, теперь пришло время создать другие сегменты, например, `model` для ваших экшнов, редьюсеров и селекторов, или `api` для ваших thunk-ов и мутаций. + +Также перераспределите слой Shared, чтобы удалить следующие папки: +- `📁 components`, `📁 containers` — большинство из их содержимого должно стать `📁 shared/ui`; +- `📁 helpers`, `📁 utils` — если остались какие-то повторно используемые хелперы, сгруппируйте их по назначению, например, даты или преобразования типов, и переместите эти группы в `📁 shared/lib`; +- `📁 constants` — так же сгруппируйте по назначению и переместите в `📁 shared/config`. + +## Шаги по желанию \{#optional-steps\} + +### Шаг 6. Создайте сущности/фичи ёмкостью из Redux-слайсов, которые используются на нескольких страницах \{#form-entities-features-from-redux\} + +Обычно эти переиспользуемые Redux-слайсы будут описывать что-то, что имеет отношение к бизнесу, например, продукты или пользователи, поэтому их можно переместить в слой Entities, одна сущность на одну папку. Если Redux-слайс скорее связан с действием, которое ваши пользователи хотят совершить в вашем приложении, например, комментарии, то его можно переместить в слой Features. + +Сущности и фичи должны быть независимы друг от друга. Если ваша бизнес-область содержит встроенные связи между сущностями, обратитесь к [руководству по бизнес-сущностям][business-entities-cross-relations] за советом по организации этих связей. + +API-функции, связанные с этими слайсами, могут остаться в `📁 shared/api`. + +### Шаг 7. Проведите рефакторинг modules \{#refactor-your-modules\} + +Папка `📁 modules` обычно используется для бизнес-логики, поэтому она уже довольно похожа по своей природе на слой Features из FSD. Некоторые модули могут также описывать большие части пользовательского интерфейса, например, шапку приложения. В этом случае их можно переместить в слой Widgets. + +### Шаг 8. Сформируйте чистый фундамент UI в `shared/ui` \{#form-clean-ui-foundation\} + +`📁 shared/ui`, в идеале, должен содержать набор UI-элементов, в которых нет бизнес-логики. Они также должны быть очень переиспользуемыми. + +Проведите рефакторинг UI-компонентов, которые раньше находились в `📁 components` и `📁 containers`, чтобы отделить бизнес-логику. Переместите эту бизнес-логику в верхние слои. Если она не используется в слишком многих местах, вы даже можете рассмотреть копирование как вариант. + +## See also \{#see-also\} + +- [(Доклад) Ilya Klimov — Крысиные бега бесконечного рефакторинга: как не дать техническому долгу убить мотивацию и продукт](https://youtu.be/aOiJ3k2UvO4) + +[ext-steiger]: https://github.com/feature-sliced/steiger +[business-entities-cross-relations]: /docs/guides/examples/types#business-entities-and-their-cross-references diff --git a/src/content/docs/ru/docs/guides/migration/from-v1.mdx b/src/content/docs/ru/docs/guides/migration/from-v1.mdx new file mode 100644 index 0000000000..a6ba9e5c99 --- /dev/null +++ b/src/content/docs/ru/docs/guides/migration/from-v1.mdx @@ -0,0 +1,170 @@ +--- +title: Миграция с v1 +sidebar: + order: 2 +--- + +## Зачем v2? \{#why-v2\} + +Изначальная концепция **feature-slices** [была заявлена][ext-tg-spb] еще в 2018 году. + +С тех пор прошло много трансформаций методологии, но при этом **[сохранялись базовые принципы][ext-v1]**: + +- Использование *стандартизированной* структуры фронтенд-проектов +- Разбиение приложения в первую очередь - согласно *бизнес-логике* +- Использование *изолированных фичей*, для предотвращения неявных сайд-эффектов и циклических зависимостей +- Использование *Public API* с запретом лезть "во внутренности" модуля + +При этом, в прежней версии методологии все равно **оставались слабые места**, которые + +- Где-то приводили к бойлерплейту +- Где-то к чрезмерному усложнению кодовой базы и неочевидным правилам между абстракциями +- Где-то к неявным архитектурным решениям, что мешало поддержке проекта и онбордингу новых людей + +Новая версия методологии ([v2][ext-v2]) призвана **устранить эти недостатки, сохраняя при этом и имеющиеся достоинства** подхода. + +С 2018 года [развивалась][ext-fdd-issues] и другая подобная методология - [**feature-driven**][ext-fdd], о которой заявил впервые [Oleg Isonen][ext-kof]. + +В результате слияния двух подходов, **были улучшены и доработаны существующие практики** - в сторону большей гибкости, понятности и эффективности при применении. + +> По итогу это повлияло даже на наименование методологии - *"feature-slice**d**"* + +## Почему имеет смысл мигрировать проект на v2? \{#why-does-it-make-sense-to-migrate-the-project-to-v2\} + +> `WIP:` Текущая версия методологии находится на стадии разработки и некоторые детали *могут измениться* + +#### 🔍 Более прозрачная и простая архитектура \{#-more-transparent-and-simple-architecture\} + +Методология (v2) предлагает **более интуитивно понятные и более распространенные среди разработчиков абстракции и способы разделения логики.** + +Все это крайне положительно влияет на привлечение новых людей, а также изучение текущего состояния проекта, и распределение бизнес-логики приложения. + +#### 📦 Более гибкая и честная модульность \{#-more-flexible-and-honest-modularity\} + +Методология (v2) позволяет **распределять логику более гибким способом:** + +- С возможностью рефакторить с нуля изолированные части +- С возможностью опираться на одни и те же абстракции, но без лишних переплетений зависимостей +- С более простыми требованиями для расположения нового модуля *(layer => slice => segment)* + +#### 🚀 Больше спецификации, планов, комьюнити \{#-more-specifications-plans-community\} + +На данный момент `core-team` ведет активную работу именно над последней (v2) версией методологии + +А значит именно для нее: + +- будет больше описанных кейсов / проблем +- будет больше гайдов по применению +- будет больше реальных примеров +- будет в целом больше документации для онбординга новых людей и изучения концепций методологии +- будет развиваться в дальнейшем тулкит для соблюдения концепций и конвенций по архитектуре + +> Само собой, будет поддержка пользователей и по первой версии - но для нас первоочередная все же последняя версия +> +> В будущем же, при следующих мажорных обновлениях - у вас сохранится доступ и к текущей версии (v2) методологии, **без рисков для ваших команд и проектов** + +## Changelog + +### `BREAKING` Layers + +Теперь методология предполагает явное выделение слоев на верхнем уровне + +- `/app` > `/processes` > **`/pages`** > **`/features`** > `/entities` > `/shared` +- *Т.е. не все теперь трактуется как фичи/страницы* +- Такой подход позволяет [явно задать правила для слоев][ext-tg-v2-draft]: + - Чем **выше расположен слой** модуля - тем большим **контекстом** он располагает + + *(иными словами - каждый модуль слоя - может импортировать только модули нижележащих слоев, но не выше)* + - Чем **ниже расположен слой** модуля - тем больше **опасности и ответственности**, чтобы внести в него изменения + + *(потому что, как правило - более переиспользуемыми являются именно нижележащие слои)* + +### `BREAKING` Shared + +Инфраструктурные абстракции `/ui`, `/lib`, `/api`, которые раньше лежали в src-корне проекта, теперь обособлены отдельной директорией `/src/shared` + +- `shared/ui` - Все так же общий uikit приложения (опционален) + - *При этом никто не запрещает использовать здесь `Atomic Design` как раньше* +- `shared/lib` - Набор вспомогательных библиотек для реализации логики + - *По-прежнему - без свалки хелперов* +- `shared/api` - Общий энтрипоинт для обращения к API + - *Может прописываться и локально в каждой фиче/странице - но не рекомендуется* +- Как и раньше - в `shared` не должно быть явной привязки к бизнес-логике + - *При необходимости - нужно выносить эту связь на уровень `entities` или еще выше* + +### `NEW` Entities, Processes + +В v2 **добавлены и другие новые абстракции**, для устранения проблем усложнения логики и сильной связности. + +- `/entities` - слой **бизнес-сущностей**, содержащий в себе слайсы, относящиеся напрямую к бизнес-моделям или синтетическим сущностям, необходимым только на фронтенде + - *Примеры: `user`, `i18n`, `order`, `blog`* +- `/processes` - слой **бизнес-процессов**, пронизывающих приложение + - **Слой опционален**, обычно рекомендуется использовать, когда *логика разрастается и начинает размываться в нескольких страницах* + - *Примеры: `payment`, `auth`, `quick-tour`* + +### `BREAKING` Abstractions & Naming + +Теперь определены конкретные абстракции и [четкие рекомендации для их нейминга][refs-adaptability] + +[disc-process]: https://github.com/feature-sliced/documentation/discussions/20 +[disc-features]: https://github.com/feature-sliced/documentation/discussions/23 +[disc-entities]: https://github.com/feature-sliced/documentation/discussions/18#discussioncomment-422649 +[disc-shared]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453020 + +[disc-ui]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453132 +[disc-model]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-472645 +[disc-api]: https://github.com/feature-sliced/documentation/discussions/66 + +#### Layers + +- `/app` — **слой инициализации приложения** + - *Прежние варианты: `app`, `core`, `init`, `src/index` (и такое бывает)* +- `/processes` — [**слой бизнес-процессов**][disc-process] + - *Прежние варианты: `processes`, `flows`, `workflows`* +- `/pages` — **слой страниц приложения** + - *Прежние варианты: `pages`, `screens`, `views`, `layouts`, `components`, `containers`* +- `/features` — [**слой частей функциональности**][disc-features] + - *Прежние варианты: `features`, `components`, `containers`* +- `/entities` — [**слой бизнес-сущностей**][disc-entities] + - *Прежние варианты: `entities`, `models`, `shared`* +- `/shared` — [**слой переиспользуемого инфраструктурного кода**][disc-shared] 🔥 + - *Прежние варианты: `shared`, `common`, `lib`* + +#### Segments + +- `/ui` — [**UI-сегмент**][disc-ui] 🔥 + - *Прежние варианты: `ui`, `components`, `view`* +- `/model` — [**БЛ-сегмент**][disc-model] 🔥 + - *Прежние варианты: `model`, `store`, `state`, `services`, `controller`* +- `/lib` — сегмент **вспомогательного кода** + - *Прежние варианты: `lib`, `libs`, `utils`, `helpers`* +- `/api` — [**API-сегмент**][disc-api] + - *Прежние варианты: `api`, `service`, `requests`, `queries`* +- `/config` — **сегмент конфигурации приложения** + - *Прежние варианты: `config`, `env`, `get-env`* + +### `REFINED` Low coupling + +Теперь гораздо проще [соблюдать принцип низкой связности][refs-low-coupling] между модулями, благодаря новым слоям. + +*При этом по-прежнему рекомендуется максимально избегать случаев, где крайне трудно "расцепить" модули* + +## См. также \{#see-also\} + +- [Заметки с доклада "React SPB Meetup #1"][ext-tg-spb] +- [React Berlin Talk - Oleg Isonen "Feature Driven Architecture"][ext-kof-fdd] +- [Сравнение с v1 (community-chat)](https://t.me/feature_sliced/493) +- [Новые идеи v2 с пояснениями (atomicdesign-chat)][ext-tg-v2-draft] +- [Обсуждение абстракций и нейминга для новой версии методологии (v2)](https://github.com/feature-sliced/documentation/discussions/31) + +[refs-low-coupling]: /docs/reference/slices-segments#zero-coupling-high-cohesion +[refs-adaptability]: /docs/about/understanding/naming + +[ext-v1]: https://feature-sliced.github.io/featureslices.dev/v1.0.html +[ext-tg-spb]: https://t.me/feature_slices +[ext-fdd]: https://github.com/feature-sliced/documentation/tree/rc/feature-driven +[ext-fdd-issues]: https://github.com/kof/feature-driven-architecture/issues +[ext-v2]: https://github.com/feature-sliced/documentation +[ext-kof]: https://github.com/kof +[ext-kof-fdd]: https://www.youtube.com/watch?v=BWAeYuWFHhs +[ext-tg-v2-draft]: https://t.me/atomicdesign/18708 \ No newline at end of file diff --git a/src/content/docs/ru/docs/guides/migration/from-v2-0.mdx b/src/content/docs/ru/docs/guides/migration/from-v2-0.mdx new file mode 100644 index 0000000000..ebfd8538ff --- /dev/null +++ b/src/content/docs/ru/docs/guides/migration/from-v2-0.mdx @@ -0,0 +1,45 @@ +--- +title: Миграция с v2.0 на v2.1 +sidebar: + order: 3 +--- + +Основным изменением в v2.1 является новая ментальная модель разложения интерфейса — сначала страницы. + +В версии FSD 2.0 рекомендовалось найти сущности и фичи в вашем интерфейсе, рассматривая даже малейшие части представления сущностей и интерактивность как кандидаты на декомпозицию. Затем вы бы могли строить виджеты и страницы из сущностей и фич. В этой модели декомпозиции большая часть логики находилась в сущностях и фичах, а страницы были просто композиционными слоями, которые сами по себе не имели большого значения. + +В версии FSD 2.1 мы рекомендуем начинать со страниц, и возможно даже на них и остановиться. Большинство людей уже знают, как разделить приложение на страницы, и страницы также часто являются отправной точкой при попытке найти компонент в кодовой базе. В новой модели декомпозиции вы храните большую часть интерфейса и логики в каждой отдельной странице, а повторно используемый фундамент — в Shared. Если возникнет необходимость переиспользования бизнес-логики на нескольких страницах, вы можете переместить её на слой ниже. + +Другим нововведением в Feature-Sliced Design 2.1 является стандартизация кросс-импортов между сущностями с помощью `@x`-нотации. + +## Как мигрировать \{#how-to-migrate\} + +В версии 2.1 нет ломающих изменений, что означает, что проект, написанный с использованием FSD v2.0, также является валидным проектом в FSD v2.1. Однако мы считаем, что новая ментальная модель более полезна для команд и особенно для обучения новых разработчиков, поэтому рекомендуем внести небольшие изменения в вашу декомпозицию. + +### Соедините слайсы + +Простой способ начать — запустить на проекте наш линтер, [Steiger][steiger]. Steiger построен с новой ментальной моделью, и наиболее полезные правила будут: + +- [`insignificant-slice`][insignificant-slice] — если сущность или фича используется только на одной странице, это правило предложит целиком переместить код этой сущности или фичи прямо в эту страницу. +- [`excessive-slicing`][excessive-slicing] — если у слоя слишком много слайсов, это обычно означает, что декомпозиция слишком мелкая. Это правило предложит объединить или сгруппировать некоторые слайсы, чтобы помочь в навигации по проекту. + +```bash +npx steiger src +``` + +Это поможет вам определить, какие слайсы используются только один раз, чтобы вы могли ещё раз подумать, действительно ли они необходимы. Помните, что слой формирует своего рода глобальное пространство имен для всех слайсов внутри него. Точно так же, как вы не захотите загрязнять глобальное пространство имен переменными, которые используются только один раз, вы должны относиться к месту в пространстве имен слоя как к ценному месту, которое следует использовать сдержанно. + +### Стандартизируйте кросс-импорты + +Если у вас были кросс-импорты в вашем проекте до этого (мы не осуждаем!), вы теперь можете воспользоваться новой нотацией для кросс-импортов в Feature-Sliced Design — `@x`-нотацией. Она выглядит так: + +```ts title="entities/B/some/file.ts" +import type { EntityA } from "entities/A/@x/B"; +``` + +Чтоб узнать больше об этом, обратитесь к разделу [Публичный API для кросс-импортов][public-api-for-cross-imports] в разделе справочника. + +[insignificant-slice]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice +[steiger]: https://github.com/feature-sliced/steiger +[excessive-slicing]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports \ No newline at end of file diff --git a/src/content/docs/ru/docs/guides/tech/with-electron.mdx b/src/content/docs/ru/docs/guides/tech/with-electron.mdx new file mode 100644 index 0000000000..e49862327f --- /dev/null +++ b/src/content/docs/ru/docs/guides/tech/with-electron.mdx @@ -0,0 +1,137 @@ +--- +title: Использование с Electron +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +Electron-приложения имеют особую архитектуру, состоящую из нескольких процессов с разными ответственностями. Применение FSD в таком контексте требует адаптации структуры под специфику Electron. + + +- src + - app + - main + - index.ts + - preload + - index.ts + - renderer + - index.html + - main + - features + - user + - ipc + - get-user.ts + - send-user.ts + - entities/ + - shared/ + - renderer/ + - pages/ + - settings/ + - ipc/ + - get-user.ts + - save-user.ts + - ui + - user.tsx + - index.ts + - home + - ui + - home.tsx + - index.ts + - widgets/ + - features/ + - entities/ + - shared/ + - shared/ + - ipc/ + + +## Правила для публичного API +Каждый процесс должен иметь свой публичный API, как пример, нельзя импортировать модули из `main` в `renderer`. +Общедоступным между процессами кодом является только папка `src/shared`. +Она же необходима для описания контрактов по взаимодействию процессов. + +## Дополнительные изменения в стандартной структуре +Предлагается использовать новый сегмент `ipc`, в котором происходит взаимодействие между процессами. +Слои `pages` и `widgets`, исходя из названия, не должны присутствовать в `src/main`, вы можете использовать `features`, `entities` и `shared`. +Слой `app` в `src` содержит точки входа для `main` и `renderer`, а также IPC. +Сегментам в слое `app` нежелательно иметь точек пересечения + +## Пример взаимодействия + +```typescript title="src/shared/ipc/channels.ts" +export const CHANNELS = { + GET_USER_DATA: 'GET_USER_DATA', + SAVE_USER: 'SAVE_USER', +} as const; + +export type TChannelKeys = keyof typeof CHANNELS; +``` + +```typescript title="src/shared/ipc/events.ts" +import { CHANNELS } from './channels'; + +export interface IEvents { + [CHANNELS.GET_USER_DATA]: { + args: void, + response?: { name: string; email: string; }; + }; + [CHANNELS.SAVE_USER]: { + args: { name: string; }; + response: void; + }; +} +``` + +```typescript title="src/shared/ipc/preload.ts" +import { CHANNELS } from './channels'; +import type { IEvents } from './events'; + +type TOptionalArgs = T extends void ? [] : [args: T]; + +export type TElectronAPI = { + [K in keyof typeof CHANNELS]: (...args: TOptionalArgs) => IEvents[typeof CHANNELS[K]]['response']; +}; +``` + +```typescript title="src/app/preload/index.ts" +import { contextBridge, ipcRenderer } from 'electron'; +import { CHANNELS, type TElectronAPI } from 'shared/ipc'; + +const API: TElectronAPI = { + [CHANNELS.GET_USER_DATA]: () => ipcRenderer.sendSync(CHANNELS.GET_USER_DATA), + [CHANNELS.SAVE_USER]: args => ipcRenderer.invoke(CHANNELS.SAVE_USER, args), +} as const; + +contextBridge.exposeInMainWorld('electron', API); +``` + +```typescript title="src/main/features/user/ipc/send-user.ts" +import { ipcMain } from 'electron'; +import { CHANNELS } from 'shared/ipc'; + +export const sendUser = () => { + ipcMain.on(CHANNELS.GET_USER_DATA, ev => { + ev.returnValue = { + name: 'John Doe', + email: 'john.doe@example.com', + }; + }); +}; +``` + +```typescript title="src/renderer/pages/user-settings/ipc/get-user.ts" +import { CHANNELS } from 'shared/ipc'; + +export const getUser = () => { + const user = window.electron[CHANNELS.GET_USER_DATA](); + + return user ?? { name: 'John Donte', email: 'john.donte@example.com' }; +}; +``` + +## См. также +- [Документация по моделям процессов](https://www.electronjs.org/docs/latest/tutorial/process-model) +- [Документация по изоляции контекстов](https://www.electronjs.org/docs/latest/tutorial/context-isolation) +- [Документация по IPC](https://www.electronjs.org/docs/latest/tutorial/ipc) +- [Пример](https://github.com/feature-sliced/examples/tree/master/examples/electron) diff --git a/src/content/docs/ru/docs/guides/tech/with-nextjs.mdx b/src/content/docs/ru/docs/guides/tech/with-nextjs.mdx new file mode 100644 index 0000000000..3622360728 --- /dev/null +++ b/src/content/docs/ru/docs/guides/tech/with-nextjs.mdx @@ -0,0 +1,196 @@ +--- +title: Использование с Next.js +sidebar: + order: 1 +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; +import { FileTree } from '@astrojs/starlight/components'; + +FSD совместим с Next.js как в варианте App Router, так и в варианте Pages Router, если устранить главный конфликт — папки `app` и `pages`. + +## App Router \{#app-router\} + +### Конфликт между FSD и Next.js в слое `app` \{#conflict-between-fsd-and-nextjs-in-the-app-layer\} + +Next.js предлагает использовать папку `app` для определения маршрутов приложения. Он ожидает, что файлы в папке `app` будут соответствовать маршрутам. Этот механизм маршрутизации **не соответствует** концепции FSD, потому что невозможно сохранить плоскую структуру слайсов. + +Чтоб решить эту проблему, перенесите Next.js-овскую папку `app` в корень проекта, а затем импортируйте FSD-страницы из `src`, где располагаются слои FSD, в Next.js-овскую папку `app`. + +Вам также нужно будет добавить в корень проекта папку `pages`, иначе Next.js будет пытаться использовать `src/pages` в качестве Pages Router, даже если вы используете App Router, что приведёт к ошибкам при сборке проекта. Имеет смысл положить внутрь этой корневой папки `pages` файл `README.md` с описанием, почему эта папка нужна, даже когда она пустая. + + +- app + - api + - get-example + - route.ts + - example + - page.tsx +- pages + - README.md +- src/ + - app/ + - api-routes/ + - pages/ + - example/ + - index.ts + - ui/ + - example.tsx + - widgets/ + - features/ + - entities/ + - shared/ + + +Пример ре-экспорта страницы из `src/pages` в Next.js-овском `app`: + +```tsx title="app/example/page.tsx" +export { ExamplePage as default, metadata } from '@/pages/example'; +``` + +### Middleware \{#middleware\} + +Если вы используете middleware в проекте, оно обязательно должно располагаться в корне проекта рядом с Next.js-овскими папками `app` и `pages`. + +### Instrumentation \{#instrumentation\} + +Файл `instrumentation.js` позволяет отслеживать производительность и поведение вашего приложения. Если вы его используете, то он обязательно должен находиться в корне проекта по аналогии с `middleware.js` + +## Pages Router \{#pages-router\} + +### Конфликт между FSD и Next.js в слое `pages` \{#conflict-between-fsd-and-nextjs-in-the-pages-layer\} + +Роуты страниц должны помещаться в папку `pages` в корне проекта по аналогии с папкой `app` для App Router. Структура внутри `src`, где располагаются папки слоёв, остаётся без изменений. + + +- pages + - _app.tsx + - api + - example.ts + - example + - index.tsx +- src + - app + - custom-app + - custom-app.tsx + - api-routes + - get-example-data.ts + - pages + - example + - index.ts + - ui + - example.tsx + - widgets/ + - features/ + - entities/ + - shared/ + + +Пример ре-экспорта страницы из `src/pages` в Next.js-овском `pages`: + +```tsx title="pages/example/index.tsx" +export { Example as default } from '@/pages/example'; +``` + +### Кастомный компонент `_app` \{#custom-_app-component\} + +Вы можете поместить ваш кастомный компонент App либо в `src/app/_app` либо в `src/app/custom-app`: + +```tsx title="src/app/custom-app/custom-app.tsx" +import type { AppProps } from 'next/app'; + +export const MyApp = ({ Component, pageProps }: AppProps) => { + return ( + <> +

My Custom App component

+ + + ); +}; +``` + +```tsx title="pages/_app.tsx" +export { App as default } from '@/app/custom-app'; +``` + +## Route Handlers (API-маршруты) \{#route-handlers-api-routes\} + +Используйте сегмент `api-routes` в слое `app` для работы c Route Handlers. + +Будьте внимательны при написании бэкенд-кода в структуре FSD — FSD в первую очередь предназначен для фронтенда, и именно это люди будут ожидать в нём найти. Если вам нужно много эндпоинтов, попробуйте выделить их в отдельный пакет в монорепозитории. + + + + + +```tsx title="src/app/api-routes/get-example-data.ts" +import { getExamplesList } from '@/shared/db'; + +export const getExampleData = () => { + try { + const examplesList = getExamplesList(); + + return Response.json({ examplesList }); + } catch { + return Response.json(null, { + status: 500, + statusText: 'Ouch, something went wrong', + }); + } +}; +``` + +```tsx title="app/api/example/route.ts" +export { getExampleData as GET } from '@/app/api-routes'; +``` + + + + + +```tsx title="src/app/api-routes/get-example-data.ts" +import type { NextApiRequest, NextApiResponse } from 'next'; + +const config = { + api: { + bodyParser: { + sizeLimit: '1mb', + }, + }, + maxDuration: 5, +}; + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + res.status(200).json({ message: 'Hello from FSD' }); +}; + +export const getExampleData = { config, handler } as const; +``` + +```tsx title="src/app/api-routes/index.ts" +export { getExampleData } from './get-example-data'; +``` + +```tsx title="app/api/example.ts" +import { getExampleData } from '@/app/api-routes'; + +export const config = getExampleData.config; +export default getExampleData.handler; +``` + + + + + +## Дополнительные рекомендации \{#additional-recommendations\} + +- Используйте сегмент `db` в слое `shared` для описания запросов к БД и их дальнейшего использования в вышестоящих слоях. +- Логику кэширования и ревалидации запросов лучше держать там же, где и сами запросы. + +## См. также \{#see-also\} + +- [Структура проекта Next.js](https://nextjs.org/docs/app/getting-started/project-structure) +- [Компоновка страниц Next.js](https://nextjs.org/docs/app/getting-started/layouts-and-pages) + +[project-knowledge]: /docs/about/understanding/knowledge-types +[ext-app-router-stackblitz]: https://stackblitz.com/edit/stackblitz-starters-aiez55?file=README.md diff --git a/src/content/docs/ru/docs/guides/tech/with-nuxtjs.mdx b/src/content/docs/ru/docs/guides/tech/with-nuxtjs.mdx new file mode 100644 index 0000000000..0399acfa19 --- /dev/null +++ b/src/content/docs/ru/docs/guides/tech/with-nuxtjs.mdx @@ -0,0 +1,182 @@ +--- +title: Использование с NuxtJS +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +В NuxtJS проекте возможно реализовать FSD, однако возникают конфликты из-за различий между требованиями к структуре проекта NuxtJS и принципами FSD: + +- Изначально, NuxtJS предлагает файловую структуру проекта без папки `src`, то есть в корне проекта. +- Файловый роутинг находится в папке `pages`, а в FSD эта папка отведена под плоскую структуру слайсов. + + +## Добавление алиаса для `src` директории + +Добавьте обьект `alias` в ваш конфиг: +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Не относятся к FSD, включёны при старте проекта + alias: { + "@": '../src' + }, +}) +``` +## Выбор способа настройки роутера + +В NuxtJS есть два способа настройки роутинга - с помощью конфига и с помощью файловой структуры. +В случае с файловым роутингом вы будете создавать index.vue файлы в папках внутри директории app/routes, а в случае конфига - настраивать роуты в `router.options.ts` файле. + + +### Роутинг с помощью конфига + +В слое `app` создайте файл `router.options.ts`, и экспортируйте из него обьект конфига: +```ts title="app/router.options.ts" +import type { RouterConfig } from '@nuxt/schema'; + +export default { + routes: (_routes) => [], +}; + +``` + +Чтобы добавить страницу `Home` в проект, вам нужно сделать следующие шаги: +- Добавить слайс страницы внутри слоя `pages` +- Добавить соответствующий роут в конфиг `app/router.config.ts` + + +Для того чтобы создать слайс страницы, воспользуемся [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +Создайте файл `home-page.vue` внутри сегмента ui, откройте к нему доступ с помощью Public API + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +Таким образом, файловая структура будет выглядеть так: + + +- src + - app + - router.config.ts + - pages + - home + - ui + - home-page.vue + - index.ts + + +Наконец, добавим роут в конфиг: + +```ts title="app/router.config.ts" +import type { RouterConfig } from '@nuxt/schema' + +export default { + routes: (_routes) => [ + { + name: 'home', + path: '/', + component: () => import('@/pages/home.vue').then(r => r.default || r) + } + ], +} +``` + +### Файловый роутинг + +В первую очередь, создайте `src` директорию в корне проекта, а также создайте внутри этой директории слои app и pages и папку routes внутри слоя app. +Таким образом, ваша файловая структура должна выглядеть так: + + +- src + - app + - routes + - pages + + +Для того чтобы NuxtJS использовал папку routes внутри слоя `app` для файлового роутинга, вам нужно изменить `nuxt.config.ts` следующим образом: +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Не относятся к FSD, включёны при старте проекта + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes' + } +}) +``` + +Теперь, вы можете создавать роуты для страниц внутри `app` и подключать к ним страницы из `pages`. + +Например, чтобы добавить страницу `Home` в проект, вам нужно сделать следующие шаги: +- Добавить слайс страницы внутри слоя `pages` +- Добавить соответствующий роут внутрь слоя `app` +- Совместить страницу из слайса с роутом + +Для того чтобы создать слайс страницы, воспользуемся [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +Создайте файл `home-page.vue` внутри сегмента ui, откройте к нему доступ с помощью Public API + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +Создайте роут для этой страницы внутри слоя `app`: + + +- src + - app + - routes + - index.vue + - pages + - home + - ui + - home-page.vue + - index.ts + + +Добавьте внутрь `index.vue` файла компонент вашей страницы: + +```html title="src/app/routes/index.vue" + + + +``` + +## Что делать с `layouts`? + +Вы можете разместить layouts внутри слоя `app`, для этого нужно изменить конфиг следующим образом: + +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Не относятся к FSD, включёны при старте проекта + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes', + layouts: './src/app/layouts' + } +}) +``` + + +## См. также + +- [Документация по изменению конфига директорий в NuxtJS](https://nuxt.com/docs/api/nuxt-config#dir) +- [Документация по изменению конфига роутера в NuxtJS](https://nuxt.com/docs/guide/recipes/custom-routing#router-config) +- [Документация по изменению алиасов в NuxtJS](https://nuxt.com/docs/api/nuxt-config#alias) \ No newline at end of file diff --git a/src/content/docs/ru/docs/guides/tech/with-react-query.mdx b/src/content/docs/ru/docs/guides/tech/with-react-query.mdx new file mode 100644 index 0000000000..d17e91d080 --- /dev/null +++ b/src/content/docs/ru/docs/guides/tech/with-react-query.mdx @@ -0,0 +1,435 @@ +--- +title: Использование с React Query +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; +import { Aside } from '@astrojs/starlight/components'; + +## Проблема «куда положить ключи» + +### Решение - разбить по сущностям + +Если в проекте уже присутствует разделение на сущности, и каждый запрос соответствует одной сущности, +наиболее чистым будет разделение по сущностям. В таком случае, предлагаем использовать следующую структуру: + + +- src/ + - app/ + - pages/ + - entities/ + - \{entity\}/ + - api/ + - \{entity\}.query (Фабрика запросов, где определены ключи и функции) + - get-\{entity\} (Функция получения сущности) + - create-\{entity\} (Функция создания сущности) + - update-\{entity\} (Функция обновления объекта) + - delete-\{entity\} (Функция удаления объекта) + - features/ + - widgets/ + - shared/ + + +Если среди сущностей есть связи (например, у сущности Страна есть поле-список сущностей Город), то можно воспользоваться +[публичным API для кросс-импортов][public-api-for-cross-imports] или рассмотреть альтернативное решение ниже. + + +### Альтернативное решение — хранить запросы в общем доступе. + +В случаях, когда не подходит разделение по сущностям, можно рассмотреть следующую структуру: + + +- src/ + - shared/ + - api/ + - queries (Query-factories) + - document.ts + - background-jobs.ts + - index.ts + + +Затем в `@/shared/api/index.ts`: + +```ts title="@/shared/api/index.ts" +export { documentQueries } from "./queries/document"; +``` + +## Проблема «Куда мутации?» + +Мутации не рекомендуется смешивать с запросами. Возможны два варианта: + +### 1. Определить кастомный хук в сегменте api рядом с местом использования + +```tsx title="@/features/update-post/api/use-update-title.ts" +export const useUpdateTitle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, newTitle }) => + apiClient + .patch(`/posts/${id}`, { title: newTitle }) + .then((data) => console.log(data)), + + onSuccess: (newPost) => { + queryClient.setQueryData(postsQueries.ids(id), newPost); + }, + }); +}; +``` + +### 2. Определить функцию мутации в другом месте (Shared или Entities) и использовать `useMutation` напрямую в компоненте + +```tsx +const { mutateAsync, isPending } = useMutation({ + mutationFn: postApi.createPost, +}); +``` + +```tsx title="@/pages/post-create/ui/post-create-page.tsx" +export const CreatePost = () => { + const { classes } = useStyles(); + const [title, setTitle] = useState(""); + + const { mutate, isPending } = useMutation({ + mutationFn: postApi.createPost, + }); + + const handleChange = (e: ChangeEvent) => + setTitle(e.target.value); + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutate({ title, userId: DEFAULT_USER_ID }); + }; + + return ( +
+ + + Create + + + ); +}; +``` + +## Организация запросов + +### Фабрика запросов + +В этом гайде рассмотрим, как использовать фабрику запросов (объект, где значениями ключа - являются функции, возвращающие список ключей запроса) + +```ts +const keyFactory = { + all: () => ["entity"], + lists: () => [...postQueries.all(), "list"], +}; +``` + + + + + +### 1. Создание Фабрики запросов + +```tsx title="@/entities/post/api/post.queries.ts" +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; +import { getDetailPost } from "./get-detail-post"; +import { PostDetailQuery } from "./query/post.query"; + +export const postQueries = { + all: () => ["posts"], + + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), + + details: () => [...postQueries.all(), "detail"], + detail: (query?: PostDetailQuery) => + queryOptions({ + queryKey: [...postQueries.details(), query?.id], + queryFn: () => getDetailPost({ id: query?.id }), + staleTime: 5000, + }), +}; +``` + +### 2. Применение Фабрики запросов в коде приложения +```tsx +import { useParams } from "react-router-dom"; +import { postApi } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; + +type Params = { + postId: string; +}; + +export const PostPage = () => { + const { postId } = useParams(); + const id = parseInt(postId || ""); + const { + data: post, + error, + isLoading, + isError, + } = useQuery(postApi.postQueries.detail({ id })); + + if (isLoading) { + return
Loading...
; + } + + if (isError || !post) { + return <>{error?.message}; + } + + return ( +
+

Post id: {post.id}

+
+

{post.title}

+
+

{post.body}

+
+
+
Owner: {post.userId}
+
+ ); +}; +``` + +### Преимущества использования Фабрики запросов +- **Структурирование запросов:** Фабрика позволяет организовать все запросы к API в одном месте, что делает код более читаемым и поддерживаемым. +- **Удобный доступ к запросам и ключам:** Фабрика предоставляет удобные методы для доступа к различным типам запросов и их ключам. +- **Возможность рефетчинга запросов:** Фабрика обеспечивает возможность легкой рефетчинга без необходимости изменения ключей запросов в разных частях приложения. + +## Пагинация + +В этом разделе рассмотрим пример функции `getPosts`, которая выполняет запрос к API для получения сущностей постов с применением пагинации. + +### 1. Создание функции `getPosts` +Функция getPosts находится в файле `get-posts.ts`, который находится в сегменте API. + +```tsx title="@/pages/post-feed/api/get-posts.ts" +import { apiClient } from "@/shared/api/base"; + +import { PostWithPaginationDto } from "./dto/post-with-pagination.dto"; +import { PostQuery } from "./query/post.query"; +import { mapPost } from "./mapper/map-post"; +import { PostWithPagination } from "../model/post-with-pagination"; + +const calculatePostPage = (totalCount: number, limit: number) => + Math.floor(totalCount / limit); + +export const getPosts = async ( + page: number, + limit: number, +): Promise => { + const skip = page * limit; + const query: PostQuery = { skip, limit }; + const result = await apiClient.get("/posts", query); + + return { + posts: result.posts.map((post) => mapPost(post)), + limit: result.limit, + skip: result.skip, + total: result.total, + totalPages: calculatePostPage(result.total, limit), + }; +}; +``` + +### 2. Фабрика запросов для пагинации +Фабрика запросов `postQueries` определяет различные варианты запросов для работы с постами, +включая запрос списка постов с заранее определенной страницей и лимитом. + +```tsx +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; + +export const postQueries = { + all: () => ["posts"], + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), +}; +``` + + +### 3. Использование в коде приложения +```tsx title="@/pages/home/ui/index.tsx" +export const HomePage = () => { + const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN; + const [page, setPage] = usePageParam(DEFAULT_PAGE); + const { data, isFetching, isLoading } = useQuery( + postApi.postQueries.list(page, itemsOnScreen), + ); + return ( + <> + setPage(page)} + page={page} + count={data?.totalPages} + variant="outlined" + color="primary" + /> + + + ); +}; +``` + + +## `QueryProvider` для управления запросами +В этом гайде рассмотрим, как организовать `QueryProvider`. + +### 1. Создание `QueryProvider` +Файл `query-provider.tsx` расположен по пути `@/app/providers/query-provider.tsx`. + +```tsx title="@/app/providers/query-provider.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; + client: QueryClient; +}; + +export const QueryProvider = ({ client, children }: Props) => { + return ( + + {children} + + + ); +}; +``` + +### 2. Создание `QueryClient` +`QueryClient` представляет собой экземпляр, используемый для управления запросами к API. +Файл `query-client.ts` расположен по пути `@/shared/api/query-client.ts`. +`QueryClient` создается с определенными настройками для кэширования запросов. + +```tsx title="@/shared/api/query-client.ts" +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }, + }, +}); +``` + +## Кодогенерация + +Существуют инструменты для автоматической генерации кода, +которые менее гибкие, по сравнению с теми, что можно настроить, +как описано выше. Если ваш Swagger-файл хорошо структурирован, и вы используете одно из таких инструментов, +то возможно имеет смысл сгенерировать весь код в каталоге @/shared/api. + + +## Дополнительный совет по организации RQ +### API-Клиент + +Используя собственный класс клиента API в общем слое shared, +можно стандартизировать настройку и работу с API в проекте. +Это позволяет управлять логированием, заголовками и форматом обмена данными (например, JSON или XML) из одного места. +Такой подход облегчает поддержку и развитие проекта, поскольку упрощает изменения и обновления взаимодействия с API. + +```tsx title="@/shared/api/api-client.ts" +import { API_URL } from "@/shared/config"; + +export class ApiClient { + private baseUrl: string; + + constructor(url: string) { + this.baseUrl = url; + } + + async handleResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + try { + return await response.json(); + } catch (error) { + throw new Error("Error parsing JSON response"); + } + } + + public async get( + endpoint: string, + queryParams?: Record, + ): Promise { + const url = new URL(endpoint, this.baseUrl); + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.append(key, value.toString()); + }); + } + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + return this.handleResponse(response); + } + + public async post>( + endpoint: string, + body: TData, + ): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return this.handleResponse(response); + } +} + +export const apiClient = new ApiClient(API_URL); +``` + +## Полезные ссылки \{#see-also\} + +- [(GitHub) Пример проекта](https://github.com/ruslan4432013/fsd-react-query-example) +- [(CodeSandbox) Пример проекта](https://codesandbox.io/p/github/ruslan4432013/fsd-react-query-example/main) +- [О фабрике запросов](https://tkdodo.eu/blog/the-query-options-api) + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/ru/docs/guides/tech/with-sveltekit.mdx b/src/content/docs/ru/docs/guides/tech/with-sveltekit.mdx new file mode 100644 index 0000000000..72479d269e --- /dev/null +++ b/src/content/docs/ru/docs/guides/tech/with-sveltekit.mdx @@ -0,0 +1,100 @@ +--- +title: Использование с SvelteKit +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +В SvelteKit проекте возможно реализовать FSD, однако возникают конфликты из-за различий между требованиями к структуре проекта SvelteKit и принципами FSD: + +- Изначально, SvelteKit предлагает файловую структуру внутри папки `src/routes`, в то время как в FSD роутинг должен быть частью слоя `app`. +- SvelteKit предлагает складывать всё, что не относится к роутингу в папку `src/lib`. + + +## Настроим конфиг + +```ts title="svelte.config.ts" +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config}*/ +const config = { + preprocess: [vitePreprocess()], + kit: { + adapter: adapter(), + files: { + routes: 'src/app/routes', // перемещаем роутинг внутрь app слоя + lib: 'src', + appTemplate: 'src/app/index.html', // Перемещаем входную точку приложения внутрь слоя app + assets: 'public' + }, + alias: { + '@/*': 'src/*' // Создаём алиас для директории src + } + } +}; +export default config; +``` + +## Перемещение файлового роутинга в `src/app` + +Создадим слой app, переместим в него входную точку приложения `index.html` и создадим папку routes. +Таким образом, ваша файловая структура должна выглядеть так: + + +- src + - app + - index.html + - routes + - pages + + +Теперь, вы можете создавать роуты для страниц внутри `app` и подключать к ним страницы из `pages`. + +Например, чтобы добавить главную страницу в проект, вам нужно сделать следующие шаги: +- Добавить слайс страницы внутри слоя `pages` +- Добавить соответствующий роут в папку `routes` из слоя `app` +- Совместить страницу из слайса с роутом + +Для того чтобы создать слайс страницы, воспользуемся [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +Создайте файл `home-page.svelte` внутри сегмента ui, откройте к нему доступ с помощью Public API + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page.svelte'; +``` + +Создайте роут для этой страницы внутри слоя `app`: + + +- src + - app + - routes + - +page.svelte + - index.html + - pages + - home + - ui + - home-page.svelte + - index.ts + + +Добавьте внутрь `+page.svelte` файла компонент вашей страницы: + +```html title="src/app/routes/+page.svelte" + + + + +``` + +## См. также + +- [Документация по изменению конфига директорий в SvelteKit](https://kit.svelte.dev/docs/configuration#files) diff --git a/src/content/docs/ru/docs/reference/layers.mdx b/src/content/docs/ru/docs/reference/layers.mdx new file mode 100644 index 0000000000..29dd4fb175 --- /dev/null +++ b/src/content/docs/ru/docs/reference/layers.mdx @@ -0,0 +1,151 @@ +--- +title: Слои +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Слои - это первый уровень организационной иерархии в Feature-Sliced Design. Их цель - разделить код на основе того, сколько ответственности ему требуется и от скольких других модулей в приложении он зависит. Каждый слой несет особое семантическое значение, чтобы помочь вам определить, сколько ответственности следует выделить вашему коду. + +Всего существует **7 слоев**, расположенных от наибольшей ответственности и зависимости к наименьшей: + +Дерево файловой системы с одной корневой папкой под названием src и семью подпапками: app, processes, pages, widgets, features, entities, shared. Папка processes слегка выцвечена. +Дерево файловой системы с одной корневой папкой под названием src и семью подпапками: app, processes, pages, widgets, features, entities, shared. Папка processes слегка выцвечена. + +1. App (Эпп) +2. Processes (Процессы, устаревший слой) +2. Pages (Страницы) +3. Widgets (Виджеты) +4. Features (Фичи/функции) +5. Entities (Сущности) +6. Shared (Шэред) + +Вы не обязаны использовать все слои в своем проекте - добавляйте только те, что приносят пользу вашему проекту. Как правило, в большинстве фронтенд-проектов будут как минимум слои Shared, Pages и App. + +На практике слои представляют собой папки с названиями в нижнем регистре (например, `📁 shared`, `📁 pages`, `📁 app`). Добавление новых слоев _не рекомендуется_, так как их семантика стандартизирована. + +## Правило импорта слоев + +Слои состоят из _слайсов_ — высокосвязанных групп модулей. Зависимости между слайсами регулируются **правилом импорта слоев**: + +> _Модуль (файл) в слайсе может импортировать другие слайсы только если они находятся на слоях строго ниже._ + +Например, папка `📁 ~/features/aaa` является слайсом с именем "aaa". Файл внутри нее, `~/features/aaa/api/request.ts`, не может импортировать код из любого файла в `📁 ~/features/bbb`, но может импортировать код из `📁 ~/entities` и `📁 ~/shared`, а также любой код из `📁 ~/features/aaa`, например, `~/features/aaa/lib/cache.ts`. + +Слои App и Shared являются **исключениями** из этого правила — они одновременно являются и слоем, и слайсом. Слайсы разделяют код по бизнес-доменам, и эти два слоя являются исключениями, потому что в Shared нет бизнес-доменов, а App объединяет все бизнес-домены. + +На практике это означает, что слои App и Shared состоят из сегментов, и сегменты могут свободно импортировать друг друга. + +## Определения слоёв + +В этом разделе описывается семантическое значение каждого слоя, чтобы создать интуитивное представление о том, какой код в нём будет лежать. + +### Shared + +Этот слой формирует фундамент для остальной части приложения. Это место для создания связей с внешним миром, например, бэкенды, сторонние библиотеки, среда выполнения приложения (environment). Также это место для ваших собственных библиотек, сконцентрированных на конкретной задаче. + +Этот слой, как и слой App, _не содержит слайсов_. Слайсы предназначены для разделения слоя на предметные области, но предметные области не существуют в Shared. Это означает, что все файлы в Shared могут ссылаться и импортировать друг друга. + +Вот сегменты, которые вы обычно можете найти в этом слое: + +- `📁 api` — API-клиент и, возможно, функции для выполнения запросов к конкретным эндпоинтам бэкенда. +- `📁 ui` — UI-кит приложения. + Компоненты на этом слое не должны содержать бизнес-логику, но могут быть тематически связаны с бизнесом. Например, здесь можно разместить логотип компании и макет страницы. Компоненты с UI-логикой также допустимы (например, автозаполнение или строка поиска). +- `📁 lib` — коллекция внутренних библиотек. + Эта папка не должна рассматриваться как хелперы или утилиты ([прочитайте здесь, почему эти папки часто превращаются в свалку][ext-sova-utility-dump]). Вместо этого каждая библиотека в этой папке должна иметь одну область фокуса, например, даты, цвета, манипуляции с текстом и т.д. Эта область фокуса должна быть задокументирована в файле README. Разработчики в вашей команде должны знать, что можно и что нельзя добавлять в эти библиотеки. +- `📁 config` — переменные окружения, глобальные фиче-флаги и другая глобальная конфигурация для вашего приложения. +- `📁 routes` — константы маршрутов или шаблоны для сопоставления маршрутов. +- `📁 i18n` — код, настраивающий систему переводов, а также глобальные строки перевода. + +Вы можете добавлять ещё сегментов, но убедитесь, что название этих сегментов описывает цель содержимого, а не его суть. Например, `components`, `hooks` и `types` — плохие имена сегментов, поскольку они не очень полезны при поиске кода. + +### Entities + +Слайсы на этом слое представляют концепции из реального мира, с которыми работает проект. Обычно это термины, которые бизнес использует для описания продукта. Например, социальная сеть может работать с бизнес-сущностями, такими как Пользователь, Публикация и Группа. + +Слайс сущности может содержать хранилище данных (`📁 model`), схемы валидации данных (`📁 model`), функции запросов API, связанные с сущностями (`📁 api`), а также визуальное представление этой сущности в интерфейсе (`📁 ui`). Это визуальное представление не обязательно должно создавать полный блок пользовательского интерфейса — оно в первую очередь предназначено для переиспользования одного и того же внешнего вида на нескольких страницах приложения, и к нему может быть присоединена различная бизнес-логика через пропcы или слоты. + +#### Связи между сущностями + +Сущности в FSD являются слайсами, и по умолчанию слайсы не могут знать друг о друге. Однако в реальной жизни сущности часто взаимодействуют друг с другом, и иногда одна сущность владеет или содержит другие сущности. Из-за этого бизнес-логику этих взаимодействий лучше всего хранить на более высоких уровнях, таких как Features или Pages. + +Когда объект данных одной сущности содержит другие объекты данных, обычно хорошей идеей является сделать связь между сущностями явной и обойти изоляцию слайсов, создав API для кросс-ссылок через `@x`-нотацию. Причина в том, что связанные сущности должны рефакториться вместе, поэтому лучше сделать так, чтобы связь было невозможно не заметить. + +Например: + +```ts title="entities/artist/model/artist.ts" +import type { Song } from "entities/song/@x/artist"; + +export interface Artist { + name: string; + songs: Array; +} +``` + +```ts title="entities/song/@x/artist.ts" +export type { Song } from "../model/song.ts"; +``` + +Вы можете узнать больше о `@x`-нотации в разделе [Публичный API для кросс-импортов][public-api-for-cross-imports]. + +### Features + +Этот слой предназначен для основных взаимодействий в вашем приложении, действий, которые важны вашим пользователям. Эти взаимодействия часто затрагивают бизнес-сущности, поскольку сущности — это то, о чём ваше приложение. + +Важный принцип эффективного использования слоя Features: **не все должно быть фичей**. Хорошим показателем того, что что-то должно быть фичей, является тот факт, что оно переиспользуется. Например, если в приложении есть несколько редакторов, и у всех них есть комментарии, то комментарии являются переиспользуемой фичей. Помните, что слайсы — это механизм для быстрого поиска кода, и если фич слишком много, важные фичи теряются. + +В идеале, когда вы приходите в новый проект, вы узнаёте о его функциональности, просматривая страницы и фичи. Принимая решение о том, что должно быть функцией, оптимизируйте опыт новичка в проекте, который/ая хочет быстро обнаружить большие важные области кода. + +Слайс фичи может содержать UI для выполнения действия, например, форму (`📁 ui`), вызовы API, необходимые для выполнения действия (`📁 api`), валидацию и внутреннее состояние (`📁 model`), фиче-флаги (`📁 config`). + +### Widgets + +Слой Widgets предназначен для больших самодостаточных блоков интерфейса. Виджеты наиболее полезны, когда они используются на нескольких страницах или когда страница, к которой они принадлежат, имеет несколько больших независимых блоков, и это один из них. + +Если блок интерфейса составляет бо́льшую часть интересного контента на странице и никогда не используется повторно, он **не должен быть виджетом**, и вместо этого его следует разместить непосредственно на этой странице. + + + +### Pages + +Страницы — это то, из чего состоят веб-сайты и приложения (также называются "экраны" или "активности"). Одна страница обычно соответствует одному слайсу, однако, если есть несколько очень похожих страниц, их можно сгруппировать в один слайс, например, формы регистрации и входа. + +Нет никаких ограничений на количество кода, которое можно разместить в слайсе страницы, по крайней мере, до тех пор, пока вашей команде не станет сложно ориентироваться в ней. Если блок интерфейса со страницы не переиспользуется на других страницах, вполне допустимо оставить его внутри слайса страницы. + +В слайсе страницы вы обычно найдете интерфейс страницы, а также состояния загрузки и границы ошибок (`📁 ui`). Также вы можете найти там запросы на получение и изменение данных (`📁 api`). Обычно у страницы нет выделенной модели данных, и небольшие части состояния могут храниться в самих компонентах. + +### Processes + + + +Выход из ситуаций, когда требуется сложное многостраничное взаимодействие. + +Этот уровень намеренно оставлен не очень определенным. Большинству приложений этот слой не пригодится, логику на уровне роутера и сервера следует оставить на уровне App. Используйте этот слой только тогда, когда слой App вырастет настолько, что станет неподдерживаемым и потребует разгрузки. + +### App + +Всё, что касается приложения целиком, как в техническом смысле (например, провайдеры контекста), так и в бизнес-смысле (например, аналитика). + +Этот слой обычно не содержит слайсов, как и Shared, и вместо этого внутри него сразу находятся сегменты. + +Вот сегменты, которые вы обычно можете найти в этом слое: + +- `📁 routes` — конфигурация роутера +- `📁 store` — глобальная конфигурация хранилища +- `📁 styles` — глобальные стили +- `📁 entrypoint` — точка входа в код приложения, специфичная для вашего фреймворка + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports +[ext-remix]: https://remix.run +[ext-sova-utility-dump]: https://sergeysova.com/ru/why-utils-and-helpers-is-a-dump/ diff --git a/src/content/docs/ru/docs/reference/public-api.mdx b/src/content/docs/ru/docs/reference/public-api.mdx new file mode 100644 index 0000000000..825eb3b4b7 --- /dev/null +++ b/src/content/docs/ru/docs/reference/public-api.mdx @@ -0,0 +1,158 @@ +--- +title: Публичный API +sidebar: + order: 3 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Публичный API — это _контракт_ между группой модулей, например, слайсом, и кодом, который его использует. Он также действует как ворота, разрешая доступ только к определенным объектам и только через этот публичный API. + +На практике это обычно реализуется как индексный файл с реэкспортами: + +```js title="pages/auth/index.js" +export { LoginPage } from "./ui/LoginPage"; +export { RegisterPage } from "./ui/RegisterPage"; +``` + +## Что делает публичный API хорошим? + +Хороший публичный API делает использование и интеграцию слайса в другой код удобным и надежным. Этого можно достичь, поставив три цели: + +1. Остальная часть приложения должна быть защищена от структурных изменений в слайсе, таких как рефакторинг. +2. Значительные изменения в поведении слайса, которые нарушают предыдущие ожидания, должны вызывать изменения в публичном API. +3. Только необходимые части слайса должны быть доступны. + +Последняя цель имеет важные практические последствия. Может возникнуть соблазн создать слепые реэкспорты всего, особенно на ранних этапах разработки слайса, потому что любые новые объекты, которые вы экспортируете из своих файлов, также автоматически экспортируются из слайса: + +```js title="Плохая практика, features/comments/index.js" +// ❌ НИЖЕ ПЛОХОЙ КОД, НЕ ДЕЛАЙТЕ ТАК +export * from "./ui/Comment"; // 👎 не пытайтесь повторить дома +export * from "./model/comments"; // 💩 это плохая практика +``` + +Это приведет к тому, что по индексу слайса нельзя будет понять его интерфейс. Не зная интерфейс, вам придется глубоко погружаться в код слайса, чтобы понять, как с ним работать. Еще одна проблема заключается в том, что вы можете случайно раскрыть внутренние модули, что усложнит рефакторинг, если кто-то начнет от них зависеть. + +## Публичный API для кросс-импортов \{#public-api-for-cross-imports\} + +Кросс-импорты — это ситуация, когда один слайс импортирует из другого слайса на том же слое. Обычно это запрещено [правилом импорта для слоёв][import-rule-on-layers], но часто есть реальные причины, чтобы сделать кросс-импорт. Например, в реальном мире бизнес-сущности часто ссылаются друг на друга, и лучше отразить эти отношения в коде, а не пытаться избавиться от них. + +Для этой цели существует особый вид публичного API, также известный как `@x`-нотация. Если у вас есть сущности A и B, и сущность B должна импортировать из сущности A, то сущность A может объявить отдельный публичный API только для сущности B. + + +- entities + - A + - @x + - B.ts специальный публичный API только для кода внутри `entities/B/` + - index.ts обычный публичный API + + +Затем код внутри `entities/B/` может импортировать из `entities/A/@x/B`: + +```ts +import type { EntityA } from "entities/A/@x/B"; +``` + +Нотацию `A/@x/B` следует читать как "пересечение A и B". + + + +## Проблемы с индексными файлами + +Индексные файлы, такие как `index.js`, также известные как barrel-файлы (файлы-бочки), являются самым распространенным способом определения публичного API. Их легко создать, но они иногда вызывают проблемы с некоторыми сборщиками и фреймворками. + +### Циклические импорты + +Циклический импорт — это когда два или более файла импортируют друг друга по кругу. + +
+ Три файла, импортирующие друг друга по кругу + Три файла, импортирующие друг друга по кругу +
+ На изображении выше: три файла, `fileA.js`, `fileB.js` и `fileC.js`, импортирующие друг друга по кругу. +
+
+ +Эти ситуации часто трудно обрабатывать сборщикам, и в некоторых случаях они могут даже привести к ошибкам во время выполнения кода, которые может быть трудно отладить. + +Циклические импорты могут возникать и без индексных файлов, но наличие индексного файла создает явную возможность случайно создать циклический импорт. Это часто происходит, когда у вас есть два объекта, доступных в публичном API слайса, например, `HomePage` и `loadUserStatistics`, и `HomePage` нужно получить доступ к `loadUserStatistics`, но он делает это следующим образом: + +```jsx title="pages/home/ui/HomePage.jsx" +import { loadUserStatistics } from "../"; // импортируем из pages/home/index.js + +export function HomePage() { /* … */ } +``` + +```js title="pages/home/index.js" +export { HomePage } from "./ui/HomePage"; +export { loadUserStatistics } from "./api/loadUserStatistics"; +``` + +Эта ситуация создает циклический импорт, потому что `index.js` импортирует `ui/HomePage.jsx`, но `ui/HomePage.jsx` тоже импортирует `index.js`. + +Чтобы предотвратить эту проблему, воспользуйтесь этими принципами. Если у вас есть два файла, и один импортирует из другого: +- Если они находятся в одном слайсе, всегда используйте _относительные_ импорты и пишите полный путь импорта +- Если они находятся в разных слайсах, всегда используйте _абсолютные_ импорты, например, через алиас + +### Большие бандлы и неработающий tree-shaking в Shared \{#large-bundles\} + +Некоторым сборщикам может быть трудно выполнять tree-shaking (удаление неимпортированного кода), когда у вас есть индексный файл, который реэкспортирует все. + +Обычно это не проблема для публичных API, потому что содержимое модуля обычно довольно тесно связано, поэтому вам редко нужно импортировать одну вещь, но удалить другую. Однако есть два очень распространенных случая, когда обычные правила публичного API в FSD могут привести к проблемам — `shared/ui` и `shared/lib`. + +Эти две папки являются коллекциями несвязанных вещей, которые часто не нужны все в одном месте. Например, в `shared/ui` могут быть модули для каждого компонента в библиотеке UI: + + +- shared/ + - ui/ + - button/ + - text-field/ + - carousel/ + - accordion/ + + +Эта проблема усугубляется, когда один из этих модулей имеет тяжелую зависимость, такую как подсветка синтаксиса или библиотека drag'n'drop. Вы не хотите подтягивать их на каждую страницу, которая использует что-то из `shared/ui`, например, кнопку. + +Если ваши бандлы нежелательно растут из-за единого публичного API в `shared/ui` или `shared/lib`, рекомендуется вместо этого иметь отдельный индексный файл для каждого компонента или библиотеки: + + +- shared/ + - ui/ + - button + - index.js + - text-field + - index.js + + +Тогда потребители этих компонентов могут импортировать их напрямую, как показано ниже: + +```js title="pages/sign-in/ui/SignInPage.jsx" +import { Button } from '@/shared/ui/button'; +import { TextField } from '@/shared/ui/text-field'; +``` + +### Нет реальной защиты от обхода публичного API + +Когда вы создаете индексный файл для слайса, ничто не мешает другим не использовать его и импортировать напрямую. Это особенно заметно с автоимпортами, потому что существует несколько мест, откуда объект может быть импортирован, поэтому IDE должна решить за вас. Иногда она может выбрать прямой импорт, нарушая правило публичного API для слайсов. + +Чтобы автоматически выявлять эти проблемы, мы рекомендуем использовать [Steiger][ext-steiger], архитектурный линтер с набором правил для Feature-Sliced Design. + +### Худшая производительность сборщиков на больших проектах + +Наличие большого количества индексных файлов в проекте может замедлить работу сервера разработки, как отметил TkDodo в [своей статье "Please Stop Using Barrel Files"][ext-please-stop-using-barrel-files]. + +Есть несколько вещей, которые вы можете сделать, чтобы справиться с этой проблемой: + +1. То же самое, что и в разделе "Большие бандлы и неработающий tree-shaking в Shared" — создайте отдельные индексные файлы для каждого компонента/библиотеки в `shared/ui` и `shared/lib` вместо одного большого +2. Избегайте наличия индексных файлов в сегментах на слоях, которые имеют слайсы. + Например, если у вас есть индекс для фичи "comments", `📄 features/comments/index.js`, нет смысла иметь еще один индекс для `ui` сегмента этой фичи, `📄 features/comments/ui/index.js`. +3. Если у вас очень большой проект, есть большая вероятность, что ваше приложение можно разделить на несколько больших кусков. + Например, в Google Docs задачи, которые решают редактор документов и файловый браузер, сильно отличаются. Вы можете создать монорепозиторий, где каждый пакет является отдельным корнем FSD со своим набором слоев. Некоторые пакеты могут иметь только слои Shared и Entities, другие могут иметь только Pages и App, а некоторые могут включать свой небольшой Shared, но при этом использовать большой Shared из другого пакета. + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-please-stop-using-barrel-files]: https://tkdodo.eu/blog/please-stop-using-barrel-files diff --git a/src/content/docs/ru/docs/reference/slices-segments.mdx b/src/content/docs/ru/docs/reference/slices-segments.mdx new file mode 100644 index 0000000000..4c94b3227b --- /dev/null +++ b/src/content/docs/ru/docs/reference/slices-segments.mdx @@ -0,0 +1,71 @@ +--- +title: Слайсы и сегменты +sidebar: + order: 2 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## Слайсы + +Слайсы — это второй уровень в организационной иерархии Feature-Sliced Design. Их основное назначение — группировать код по его значению для продукта, бизнеса или просто приложения. + +Имена слайсов не стандартизированы, поскольку они напрямую определяются бизнес-областью вашего приложения. Например, фотогалерея может иметь слайсы `photo`, `effects`, `gallery-page`. Для социальной сети потребуются другие слайсы, например, `post`, `comments`, `news-feed`. + +Слои Shared и App не содержат слайсов. Это потому, что Shared не должен содержать никакой бизнес-логики, следовательно, не имеет продуктового значения, а App должен содержать только код, касающийся всего приложения, поэтому разделение не требуется. + +### Нулевая сцепленность и высокая связность \{#zero-coupling-high-cohesion\} + +Слайсы задуманы как независимые и сильно связанные группы файлов кода. Картинка ниже может помочь визуализировать такие сложные концепции как _связность_ (cohesion) и _сцепленность_ (coupling): + +
+ + +
+ Картинка вдохновлена https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/ +
+
+ +Идеальный слайс независим от других слайсов на своем уровне (нулевая сцепленность) и содержит бо́льшую часть кода, связанного с его основной целью (высокая связность). + +Независимость слайсов обеспечивается [правилом импорта для слоёв][layers--import-rule]: + +> _Модуль (файл) в слайсе может импортировать другие слайсы только если они находятся на слоях строго ниже._ + +### Правило публичного API для слайсов + +Внутри слайса код может быть организован как угодно, и это не создаст никаких проблем до тех пор, пока слайс имеет качественный публичный API. В этом суть правила **публичного API для слайсов**: + +> _Каждый слайс (и сегмент на слоях, не имеющих слайсов) должен содержать определение публичного API._ +> +> _Модули вне этого слайса/сегмента могут ссылаться только на публичный API, а не на внутреннюю файловую структуру этого слайса/сегмента._ + +Подробнее о причинах требования публичных API и лучших практиках их создания читайте в [справочнике о публичных API][ref-public-api]. + +### Группы слайсов + +Тесно связанные слайсы могут быть структурно сгруппированы в папку, но они должны соблюдать те же правила изоляции, что и другие слайсы — **никакого совместного использования кода** в этой папке быть не должно. + +![Функции "compose", "like" и "delete" сгруппированы в папку "post". В этой папке также есть файл "some-shared-code.ts", который зачеркнут, чтобы показать, что это запрещено.](/img/graphic-nested-slices.svg) + +## Сегменты + +Сегменты — это третий и последний уровень в организационной иерархии, их цель — группировать код по его техническому назначению. + +Существует несколько стандартизированных названий сегментов: + +- `ui` — все, что связано с отображением UI: UI-компоненты, форматтеры дат, стили и т.д. +- `api` — взаимодействие с бэкендом: функции запросов, типы данных, мапперы и т.д. +- `model` — модель данных: схемы, интерфейсы, хранилища и бизнес-логика. +- `lib` — библиотечный код, необходимый другим модулям в этом слайсе. +- `config` — конфигурационные файлы и фиче-флаги. + +На [странице про Слои][layers--layer-definitions] есть примеры того, как каждый из этих сегментов может использоваться на разных слоях. + +Вы также можете создавать свои собственные сегменты. Наиболее распространенные места для кастомных сегментов — это слои App и Shared, где слайсы не имеют смысла. + +Убедитесь, что название этих сегментов описывает, для чего нужно его содержимое, а не чем оно является. Например, `components`, `hooks` и `types` — плохие названия сегментов, потому что они не так полезны, когда вы ищете код. + +[layers--layer-definitions]: /docs/reference/layers#layer-definitions +[layers--import-rule]: /docs/reference/layers#import-rule-on-layers +[ref-public-api]: /docs/reference/public-api diff --git a/src/content/docs/ru/index.mdx b/src/content/docs/ru/index.mdx new file mode 100644 index 0000000000..51516104b1 --- /dev/null +++ b/src/content/docs/ru/index.mdx @@ -0,0 +1,12 @@ +--- +title: Feature-Sliced Design +template: splash +hero: + tagline: + Архитектурная методология для фронтенд проектов + actions: + - text: Ознакомиться + link: docs/get-started/overview + icon: right-arrow + variant: primary +--- diff --git a/src/content/docs/uz/docs/branding.mdx b/src/content/docs/uz/docs/branding.mdx new file mode 100644 index 0000000000..40babd28de --- /dev/null +++ b/src/content/docs/uz/docs/branding.mdx @@ -0,0 +1,82 @@ +--- +title: Brending bo'yicha tavsiyalar +--- + +import { Aside } from '@astrojs/starlight/components'; + +FSD ning vizual identifikatsiyasi uning asosiy tushunchalariga asoslanadi: `Layered`, `Sliced self-contained parts`, `Parts & Compose`, `Segmented`. + +Lekin biz FSD falsafasini aks ettiruvchi va osongina tanib olinadigan oddiy va chiroyli identifikatsiyaga intilamiz. + +**Iltimos, qulayligingiz uchun FSD identifikatoridan o'zgarmagan holda "as-is", aktivlarimizdan foydalaning.** Bu FSD ning brend-gayd identifikatoridan to'g'ri foydalanishga yordam beradi. + + + +## Nomlash \{#title} + +- ✅ **To'g'ri:** `Feature-Sliced Design`, `FSD` +- ❌ **Noto'g'ri:** `Feature-Sliced`, `Feature Sliced`, `FeatureSliced`, `feature-sliced`, `feature sliced`, `FS` + +## Emodzi \{#emojii} + +Tort tasviri 🍰 FSD-ning asosiy tushunchalarini yaxshi aks ettiradi, shuning uchun u bizning emojimiz sifatida tanlangan. + +> Namuna: *"🍰 Architectural design methodology for Frontend projects"* + +## Logo & Palitra \{#logo--palettte} + +FSD har xil konteksda bir nechta logotipga ega, lekin **primary** dan foydalanish tavsiya etiladi + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TemaLogo (Ctrl/Cmd + Click yuklab olish uchun)Foydalanish
primary
(#29BEDC, #517AED)
logo-primaryAksariyat hollarda afzal ko'riladi
flat
(#3193FF)
logo-flatBir rangli kontekst uchun
monochrome
(#FFF)
logo-monochromeOq qora kontekst uchun
square
(#3193FF)
logo-squareKvadrat o'lchamlar uchun
+ +## Bannerlar & Sxemalar \{#banners--schemes} + +banner-primary +banner-monochrome + +## Social Preview + +Ish davom etmoqda... + +## Prezintatsiya uchun shablon \{#presentation-template} + +Ish davom etmoqda... + +## Shuningdek qarang \{#see-also} + +- [Muhokama (github)](https://github.com/feature-sliced/documentation/discussions/399) +- [Ma'lumotnomalar bilan rebrending tarixi (figma)](https://www.figma.com/file/RPphccpoeasVB0lMpZwPVR/FSD-Brand?node-id=0%3A1) diff --git a/src/content/docs/uz/docs/get-started/overview.mdx b/src/content/docs/uz/docs/get-started/overview.mdx new file mode 100644 index 0000000000..18b3b72909 --- /dev/null +++ b/src/content/docs/uz/docs/get-started/overview.mdx @@ -0,0 +1,149 @@ +--- +title: Qisqacha +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +**Feature-Sliced Design** (FSD) bu front-end ilovalarining tuzilishini shakllantirish uchun arxitektura metodologiyasi. Oddiy qilib aytganda, bu kodni tartibga solish uchun qoidalar va konvensiyalar to'plami. Bu metodologiyaning asosiy maqsadi - loyihani yanada tushunarli va o'zgaruvchan biznes talablariga moslashuvchan qilishdir. + +FSD nafaqat konvensiyalar to'plami, balki vositalar to'plamini ham o'z ichiga oladi. Bizda [linter][ext-steiger] sizning loyihangizning arxitekturasini tekshirish uchun, [papka generatorlari][ext-tools] CLI yoki IDE lar orqali, shuningdek, ko'plab [misollar][examples] jamlanmasiga ega. + +## Menga to'g'ri keladimi? \{#is-it-right-for-me} + +FSD har qanday hajmdagi jamoalarda joriy qilinishi mumkin. Sizga to'g'ri keladi agar: + +- Siz **frontend** qilayotgan bo'lsangiz (veb, mobil, kompyuter dasturlari, h.k.) +- Siz **ilova** qurayotgan bo'lsangiz, kutubxona emas + +Shu bilan birga, bu yerda qaysi dasturlash tili, UI(foydalanuvchi interfeysi) freymvorki, yoki holat boshqaruvchisi(state manager)ni ishlatish bo'yicha hech qanday cheklov yo'q. Shuningdek, FSD ni bosqichma-bosqich joriy qilishingiz, monorepo(yagona repozitoriya)larda ishlatishingiz va ilovangizni modullarga bo'lib, FSD ni ularning har birida alohida amalga oshirishingiz mumkin. + +Agar sizda allaqachon arxitektura mavjud bo‘lsa va FSDga o‘tishni o‘ylayotgan bo‘lsangiz, avvalo joriy arxitektura jamoangizga muammo tug‘dirayotganiga ishonch hosil qiling. Masalan, agar loyihangiz juda katta va o‘zaro juda bog‘lanib ketgan bo‘lsa, bu yangi xususiyatlarni samarali joriy etishni qiyinlashtirishi mumkin. Yoki jamoangizga ko‘plab yangi a’zolar qo‘shilishi kutilayotgan bo‘lsa, FSDga o‘tish foydali bo‘lishi mumkin. Agar hozirgi arxitektura yaxshi ishlayotgan bo‘lsa, ehtimol uni o‘zgartirishga hojat yo‘q. Ammo agar migratsiya qilishga qaror qilsangiz, yo‘riqnoma uchun [Migratsiya][migration] sahifasini ko'rib chiqishingiz mumkin. +## Asosiy misollar \{#basic-example} + +Bu yerda FSD qo'llanilgan oddiy loyiha: + + +- app/ +- pages/ +- shared/ + + +Bu yuqori darajadagi papkalar ular _layerlar_ deyiladi. Keling chuqurroq qaraymiz: + + +- app/ + - routes/ + - analytics/ +- pages/ + - home/ + - article-reader/ + - ui/ + - api/ + - settings/ +- shared/ + - ui/ + - api/ + + +`📂 pages` ichidagi papkalar _slice(bo'lak)lar_ deyiladi. Ular layer ni domenlar bo'yicha bo'lishadi(bu holatda, sahifalar bo'yicha) + +`📂 app`, `📂 shared`, va `📂 pages/article-reader` ichidagilar _segment(bo'lim)lar_ deyiladi, va ular slicelar(yoki layerlar), ya'ni kodning vazifasiga qarab ajratiladi. + +## Tushunchalar \{#concepts} + +Layerlar, slicelar va segmentlar quyidagi iyerarxiyani hosil qiladi: + +
+ ![FSD konsepsiyalari iyearxiyasi, quyida tasvirlangan](/img/visual_schema.jpg) + +
+

Tepadagi rasmda: uchta ustunlar, chapdan o'ngga qarab mos ravishda "Layerlar", "Slicelar" va "Segmentlar" deb belgilangan.

+

"Layers" ustuni yuqoridan pastga qarab joylashtirilgan etti bo‘limni o‘z ichiga oladi: "app", "processes", "pages", "widgets", "features", "entities" va "shared". "Processes" bo‘limi ustidan chizib tashlangan. "Entities" bo‘limi ikkinchi ustun — "Slicelar" bilan bog‘langan bo‘lib, bu ikkinchi ustunning "entities" tarkibida ekanligini bildiradi.

+

"Slices" ustuni yuqoridan pastga qarab joylashtirilgan uch bo‘limni o‘z ichiga oladi: "user", "post" va "comment". "Post" bo‘limi uchinchi ustun — "Segmentlar" bilan bog‘langan bo‘lib, bu uchinchi ustunning "post" tarkibida ekanligini bildiradi.

+

"Segments" ustuni yuqoridan pastga qarab joylashtirilgan uchta bo'limni o'z ichiga oladi: "ui", "model" va "api".

+
+
+ +### Layerlar \{#layers} + +Layerlar barcha FSD loyihalarida standartlashtirilgan. Siz ularning barchasidan foydalanishingiz shart emas, lekin ularning nomlari muhim. Hozirda yettita layer mavjud(tepadan pastga qarab): + +1. **App** — Ilovaning ishlashi uchun zarur bo'lgan hamma narsa — marshrutlash(routing), dastur boshlanish nuqtasi(entrypoint), global stillar, provayderlar. +2. **Processes** (eskirgan) — Murakkab sahifalararo ssenariylar(ya'ni foydalanuvchi bir sahifada amal bajarsa, boshqa sahifada natija ko‘rinishi yoki turli sahifalar o‘rtasida state(holat) uzatilishi). +3. **Pages** — To‘liq sahifalar yoki ichma-ich marshrutlashdagi(routing) sahifaning katta qismlari. +4. **Widgets** — Katta, mustaqil funksionallik yoki UI bo‘laklari, odatda butun bir foydalanish holatini ta’minlaydi. +5. **Features** — _Qayta foydalaniladigan_ butun mahsulot xususiyatlarining implementatsiyalari, ya’ni foydalanuvchiga biznes qiymatini keltiradigan harakatlar. +6. **Entities** — Loyihada ishlaydigan biznes obyekti, masalan, `user` yoki `product`. +7. **Shared** — Loyihaning yoki biznesning aniq xususiyatlariga bog‘liq bo‘lmagan, lekin qayta ishlatiladigan funksionallik. + + +Layerlar bilan ishlashning asosiy tamoyili shundaki, bitta layerdagi modullar faqat o‘zidan quyi joylashgan layerlardagi modullarni bilishi va ulardan import qilishi mumkin. + +### Slicelar \{#slices} + +Keyingi tushuncha slicelar, ya’ni kodni biznes domenlari bo‘yicha bo‘lish usuli. Ularning nomlarini o‘zingiz tanlashingiz va xohlaganingizcha ko‘paytirishingiz mumkin. Slices kod bazangizni yanada tushunarli qiladi, chunki bir-biriga bog‘liq modullar bir joyda saqlanadi. + +Slicelar bir xil layer ichida boshqa slicelardan foydalana olmaydi. Bu esa yuqori cohesion([uyg'unlik][cohesion]) va low coupling([past bog‘liqlik][low-coupling]) tamoyillariga rioya qilishga yordam beradi. + +### Segmentlar \{#segments} + +Slicelar, shuningdek, App va Shared layerlari segmentlardan tashkil topadi. Segmentlar kodni uning maqsadiga qarab guruhlaydi. Segment nomlari standart bilan cheklanmagan, ammo eng keng tarqalgan maqsadlar uchun bir nechta an’anaviy nomlar mavjud: + +- `ui` — UI(foydalanuvchi interfeysi) ni aks ettiruvchi barcha narsalar: UI(foydalanuvchi interfeysi) komponentlar, sana formatlagichlar, stillar va boshqalar. +- `api` — backend bilan munosabat: so'rov funksiyalari, ma'lumot turlari, mapperlar va boshqalar. +- `model` — ma'lumot modeli: sxemalar, interfeyslar, holat saqlovchi obyektlar (stores) va biznes mantiq(business logic). +- `lib` — Ushbu sliceda joylashgan boshqa modullarga kerak kutubxona kodi. +- `config` — konfiguratsiya fayllari va feature flags(yoqib o'chiriladigan xususiyat bayroqlari) + +Odatda, ushbu segmentlar ko‘pchilik layerlar uchun yetarli bo‘ladi. Siz faqat Shared yoki App layerlarida o‘z segmentlaringizni yaratishingiz mumkin, lekin bu qat’iy qoida emas. + +## Afzalliklar \{#advantages} + +- **Bir xillik** + Tuzilma standartlashtirilganligi sababli loyihalar yanada bir xil ko'rinishga ega bo‘ladi, bu esa jamoaga yangi a’zolarni jalb qilish jarayonini osonlashtiradi. + +- **O'zgarishlar va refaktoring(kodni qayta tuzish) jarayonida barqarorlik** + Bitta layerdagi modul boshqa shu layerdagi yoki undan yuqoridagi modullardan foydalana olmaydi. + Bu esa ilovaning boshqa qismlariga kutilmagan ta’sir ko‘rsatmasdan izolyatsiyalangan o‘zgarishlar kiritish imkonini beradi. + +- **Qayta foydalaniladigan mantiq(logic)ni tartibli boshqarish** + Layerga qarab, kodni keng miqyosda qayta ishlatish yoki faqat ma'lum bir joyda ishlatish mumkin. + Bu esa **DRY** tamoyilini ushlab turish bilan birga, loyihaning qulayligini ham saqlab qolishga yordam beradi. + +- **Biznes va foydalanuvchi ehtiyojlariga yo‘naltirilganlik.** + Ilova biznes domenlarga bo‘lingan va nomlashda biznesga oid terminologiya(business language) dan foydalanish tavsiya etiladi, bu esa sizga loyiha tarkibidagi boshqa qismlarni to‘liq tushunmasdan ham mahsulot ustida ishlash imkonini beradi. + +## Bosqichma-bosqich joriy etish \{#incremental-adoption} + +Agar mavjud kod bazangizni FSD ga o'tkazmoqchi bo'lsangiz, quyidagi strategiyani tavsiya qilamiz. Biz buni bizning tajribamizda foydali deb topdik. + +1. Avval App va Shared layerlarini modul-ma-modul shakllantirib, mustahkam asos yarating. + +2. Mavjud UI komponentlarini Widgets va Pages ga umumiy tartibda joylashtiring, hatto ular FSD qoidalarini buzadigan bog‘liqliklarga ega bo‘lsa ham. + +3. Import qoidabuzarliklarini bosqichma-bosqich tuzatishni boshlang va shu bilan birga Entities, ehtimol Features ham ajratib chiqing. + +Refaktoring jarayonida yoki loyihaning faqat ayrim qismlarini o‘zgartirayotganda yangi yirik Entities qo‘shmaslik tavsiya etiladi. + +## Keyingi qadamlar \{#next-steps} + +- **FSD haqida chuqur tushuncha hosil qilmoqchimisiz?** [Bu yerda][tutorial]. +- **Misollar bilan o'rganishni afzal ko'rasizmi?** Bizda juda ko'plab [Misollar][examples] bo'limi mavjud. +- **Savolingiz bormi?** Bizning [Telegram chat][ext-telegram] ga qo'shiling va yordam oling. + +[tutorial]: /docs/get-started/tutorial +[examples]: /examples +[migration]: /docs/guides/migration/from-custom +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[ext-telegram]: https://t.me/feature_sliced +[low-coupling]: https://en.wikipedia.org/wiki/Loose_coupling +[cohesion]: https://en.wikipedia.org/wiki/Cohesion_(computer_science) diff --git a/src/content/docs/uz/docs/reference/layers.mdx b/src/content/docs/uz/docs/reference/layers.mdx new file mode 100644 index 0000000000..4741f27f65 --- /dev/null +++ b/src/content/docs/uz/docs/reference/layers.mdx @@ -0,0 +1,191 @@ +--- +title: Qatlamlar +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +Qatlamlar - Xususiyatlar bilan kesilgan dizayndagi tashkiliy ierarxiyaning birinchi darajasi. Ularning maqsadi kodni qanchalik mas'uliyatga muhtojligi va dasturda qancha boshqa modullarga bog'liqligi asosida ajratishdir. + + +Har bir qatlam o'ziga xos semantik ma'noga ega bo'lib, kodingizdagi modulga qanday mas'uliyat yuklanishi kerakligini aniqlashga yordam beradi. Qatlam nomlari va qiymatlari Feature-Sliced ​​Design yordamida qurilgan barcha loyihalar uchun standartlashtirilgan. +Hammasi bo'lib **7 ta qatlam** mavjud bo'lib, ular eng katta mas'uliyat va bog'liqlikdan tortib to eng kichikgacha tartiblangan: + +Дерево файловой системы с одной корневой папкой src и семью подпапками: app, processes, pages, widgets, features, entities, shared. Папка processes отображена немного тускло. +Дерево файловой системы с одной корневой папкой src и семью подпапками: app, processes, pages, widgets, features, entities, shared. Папка processes отображена немного тускло. + +1. App (Ilova) +2. Processes (Jarayonlar, eski qatlam) +2. Pages (Sahifalar) +3. Widgets (Vidjetlar) +4. Features (Ficha/funksiyalar) +5. Entities (Сущности) +6. Shared (Umumiy) + +Loyihangizdagi barcha qatlamlardan foydalanish shart emas - faqat loyihangizga foyda keltiradigan qatlamlarni qo'shing. + +## Qatlamlar uchun import qoidasi \{#import-rule-on-layers} + +Qatlamlar _slices_ - kuchli bog'langan modullar guruhidan iborat. Xususiyatlarga ega Dilimlangan dizayn past ulanishni saqlaydi, shuning uchun bo'limlar orasidagi bog'liqliklar **qatlamlar uchun import qoidasi** bilan boshqariladi: + +> _Slaysdagi modul faqat boshqa bo'limlarni quyidagi qatlamlarda joylashgan bo'lsagina import qilishi mumkin._ + +Masalan, `~/features/aaa` bo`limida bo`lak `aaa` bo`lgani uchun `~/features/aaa/api/request.ts` fayli `~/features/ papkasida biron bir moduldan kodni import qila olmaydi. bbb` , lekin `~/ob'ektlar` va `~/shared` dan, shuningdek, `~/features/aaa` qo`shni modullaridan kodni import qilishi mumkin. + +## Qatlam ta'riflari + +### Shared + +Izolyatsiya qilingan modullar, komponentlar va abstraktsiyalar loyiha yoki biznes xususiyatlaridan ajralib turadi. +Ogohlantirish: Bu qatlamni [utility dump][ext--sova-utility-dump] sifatida ishlatmang! + +Bu qatlam, boshqalardan farqli o'laroq, tilimlardan emas, balki to'g'ridan-to'g'ri segmentlardan iborat. + +**Tarkibga misollar**: + +* UI-biblioteka +* API-klient +* Brauzer API bilan ishlaydigan kod + +### Entities + +Loyihaning mohiyatini birgalikda tashkil etuvchi real dunyo tushunchalari. Odatda, bular korxona mahsulotni tavsiflash uchun foydalanadigan atamalardir. + +Ushbu qatlamning har bir qismi statik UI elementlarini, ma'lumotlarni saqlashni va CRUD (Yaratish-O'qish-O'zgartirish-O'chirish) operatsiyalarini o'z ichiga oladi. + +**Slayslar namunasi**: + + + + + + +
Ijtimoiy tarmoq uchun Git frontend uchun (masalan, GitHub)
    +
  • Foydalanuvchi
  • +
  • Nashr
  • +
  • Gruppa
  • +
    +
  • Repezitoriy
  • +
  • Fayl
  • +
  • Kommit
  • +
+ + + + +### Features + +Foydalanuvchi o'zi uchun qimmatli natijaga erishish uchun tadbirkorlik sub'ektlari bilan o'zaro aloqada bo'lish uchun ilovada amalga oshirishi mumkin bo'lgan harakatlar. Shuningdek, u foydalanuvchi uchun qiymat yaratish uchun foydalanuvchi nomidan dastur bajaradigan amallarni ham o'z ichiga oladi. + +Ushbu qatlamdagi bo'lak _interactive_ foydalanuvchi interfeysi elementlarini, ichki holatni va qiymat yaratuvchi amallarni bajarishga imkon beruvchi API so'rovlarini o'z ichiga olishi mumkin. + +**Slayslar namunasi**: + + + +
Ijtimoiy tarmoq uchun Git frontend uchun (masalan, GitHub) Foydalanuvchi nomidan harakatlar
    +
  • Tizimga kirish
  • +
  • Post yaratish
  • +
  • Guruhga qo'shilish
  • +
    +
  • Fayl o'zgartirish
  • +
  • Izoh qoldirish
  • +
  • Vetkalarni birlashtirish
  • +
    +
  • Tungi mavzuni avtomatik yoqish
  • +
  • Orqa fonda hisob-kitoblarni bajarish
  • +
  • User-Agent asosidagi harakatlar
  • +
+ +### Widgets + +O'z-o'zidan tuzilgan UI bloklari ob'ektlar va funktsiyalar kabi quyi darajadagi birliklar tarkibidan paydo bo'ldi. + +Ushbu qatlam ob'ektlar interfeysida qolgan bo'shliqlarni boshqa ob'ektlar va funktsiyalardan interaktiv elementlar bilan to'ldirish imkoniyatini beradi. Shuning uchun biznes mantig'i odatda ushbu qatlamga joylashtirilmaydi, aksincha u xususiyatlarda saqlanadi. Ushbu qatlamdagi har bir bo'lak foydalanishga tayyor UI komponentlarini va ba'zan ishoralar, klaviatura o'zaro ta'siri va boshqalar kabi biznes bo'lmagan mantiqni o'z ichiga oladi. + +Ba'zan ushbu qatlamga biznes mantiqini joylashtirish qulayroqdir. Ko'pincha, bu vidjet juda ko'p interaktivlikka ega (masalan, interaktiv jadvallar) va undagi biznes mantig'i qayta ishlatilmasa sodir bo'ladi. + +**Slayslar namunasi**: + + + + + + +
Ijtimoiy tarmoq uchun Git frontend uchun (masalan, GitHub)
    +
  • Nashr kartasi
  • +
  • Foydalanuvchi profili sarlavhasi (harakatlari bilan)
  • +
    +
  • Ombordagi fayllarni ro'yxatlash (amallar bilan)
  • +
  • Izohlar qatorida fikr bildiring
  • +
  • Repozitoriy kartasi
  • +
+ + + +### Pages + +Sahifa ilovalari uchun toʻliq sahifalar (masalan, veb-saytlar) yoki ekran ilovalari uchun ekranlar/faoliyatlar (masalan, mobil ilovalar). + +O'zining kompozitsion tabiatiga ko'ra, bu qatlam kattaroq miqyosda bo'lsa-da, Vidjetlarga o'xshaydi. Ushbu qatlamdagi har bir bo'lak marshrutizatorga ulanishga tayyor foydalanuvchi interfeysi komponentlarini o'z ichiga oladi, shuningdek, ma'lumotlarni qabul qilish va xatolarni qayta ishlash uchun mantiqni o'z ichiga olishi mumkin. + +**Slayslar namunasi**: + + + + + + +
Ijtimoiy tarmoq uchun Git frontend uchun (masalan, GitHub)
    +
  • Yangiliklar lentasi
  • +
  • Jamiyat sahifasi
  • +
  • Foydalanuvchi ommaviy profili
  • +
    +
  • Repezitoriy sahifasi
  • +
  • Foydalanuvchi repezitorisi
  • +
  • Repezitoriydagi brenchlar
  • +
+ +### Processes + + + +Murakkab ko'p sahifali shovqin talab qilinadigan vaziyatlardan chiqish yo'li. + +Bu daraja ataylab aniqlanmagan holda qoldiriladi. Aksariyat ilovalar ushbu qatlamga muhtoj bo'lmaydi, yo'riqnoma va server darajasidagi mantiq Ilova darajasida qoldirilishi kerak. Ilova qatlami qo‘llab-quvvatlanmaydigan darajada kattalashganda va uni olib tashlash kerak bo‘lgandagina ushbu qatlamdan foydalanishni o‘ylab ko‘ring. + +### App + +Texnik ma'noda (masalan, kontekst provayderlari) va biznes ma'nosida (masalan, tahlil) butun dasturga tegishli bo'lgan har qanday narsa. + +Bu qatlam odatda Shared kabi boʻlaklarni oʻz ichiga olmaydi, aksincha u toʻgʻridan-toʻgʻri segmentlarni oʻz ichiga oladi. + +**Tarkibga misollar**: + +* Stillar +* Router +* Ma'lumotlar do'konlari va boshqa kontekst provayderlari +* Analitika initsializatsiyasi + +[ext--remix]: https://remix.run +[ext--sova-utility-dump]: https://sergeysova.com/ru/why-utils-and-helpers-is-a-dump/ diff --git a/src/content/docs/vi/docs/about/alternatives.mdx b/src/content/docs/vi/docs/about/alternatives.mdx new file mode 100644 index 0000000000..9470624232 --- /dev/null +++ b/src/content/docs/vi/docs/about/alternatives.mdx @@ -0,0 +1,143 @@ +--- +sidebar_class_name: sidebar-item--wip +sidebar_position: 3 +title: Các phương án thay thế +sidebar: + order: 3 + badge: + text: WIP + variant: caution +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +# Các phương án thay thế + +Lịch sử các cách tiếp cận kiến trúc + +## Big Ball of Mud + +> Nó là gì; Tại sao nó phổ biến; Khi nào nó bắt đầu mang lại vấn đề; Phải làm gì và FSD giúp đỡ đây như thế nào + +- [(Article) Oleg Isonen - Last words on UI architecture before an AI takes over](https://oleg008.medium.com/last-words-on-ui-architecture-before-an-ai-takes-over-468c78f18f0d) +- [(Report) Julia Nikolaeva, iSpring - Big Ball of Mud and other problems of the monolith, we have handled](http://youtu.be/gna4Ynz1YNI) +- [(Article) DD - Big Ball of mud](https://thedomaindrivendesign.io/big-ball-of-mud/) + + +## Smart & Dumb components + +> Về cách tiếp cận; Về khả năng áp dụng trong frontend; Vị trí của methodology + +Về sự lỗi thời, về quan điểm mới từ methodology + +Tại sao cách tiếp cận component-container lại xấu? + +- [(Article) Den Abramov-Presentation and Container Components (TLDR: deprecated)](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) + + +## Design Principles + +> Chúng ta đang nói về điều gì; Vị trí của FSD + +SOLID, GRASP, KISS, YAGNI, ... - và tại sao chúng không hoạt động tốt cùng nhau trong thực tế + +Và nó tổng hợp các thực hành này như thế nào + +- [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Design Principles)](https://youtu.be/SnzPAr_FJ7w?t=380) + + +## DDD + +> Về cách tiếp cận; Tại sao nó hoạt động kém trong thực tế + +Sự khác biệt là gì, nó cải thiện khả năng áp dụng như thế nào, nó áp dụng các thực hành ở đâu + +- [(Article) DDD, Hexagonal, Onion, Clean, CQRS, ... How I put it all together](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) +- [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Clean Architecture, DDD)](https://youtu.be/SnzPAr_FJ7w?t=528) + + +## Clean Architecture + +> Về cách tiếp cận; Về khả năng áp dụng trong frontend; Vị trí của FSD + +Chúng giống nhau như thế nào (đối với nhiều người), chúng khác nhau như thế nào + +- [(Thread) About use-case/interactor in the methodology](https://t.me/feature_sliced/3897) +- [(Thread) About DI in the methodology](https://t.me/feature_sliced/4592) +- [(Article) Alex Bespoyasov - Clean Architecture on frontend](https://bespoyasov.me/blog/clean-architecture-on-frontend/) +- [(Article) DDD, Hexagonal, Onion, Clean, CQRS, ... How I put it all together](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) +- [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Clean Architecture, DDD)](https://youtu.be/SnzPAr_FJ7w?t=528) +- [(Article) Misconceptions of Clean Architecture](http://habr.com/ru/company/mobileup/blog/335382/) + + +## Frameworks + +> Về khả năng áp dụng trong frontend; Tại sao các framework không giải quyết vấn đề; tại sao không có cách tiếp cận duy nhất; vị trí của FSD + +Không phụ thuộc vào framework, cách tiếp cận thông thường + +- [(Article) About the reasons for creating the methodology (fragment about frameworks)](/docs/about/motivation) +- [(Thread) About the applicability of the methodology for different frameworks](https://t.me/feature_sliced/3867) + + +## Atomic Design + +### Nó là gì? +Trong Atomic Design, phạm vi trách nhiệm được chia thành các layer tiêu chuẩn. + +Atomic Design được chia thành **5 layer** (từ trên xuống dưới): + +1. `pages` - Chức năng tương tự với layer `pages` trong FSD. +2. `templates` - Các component xác định cấu trúc của trang mà không gắn với nội dung cụ thể. +3. `organisms` - Các module bao gồm các molecule và có logic kinh doanh. +4. `molecules` - Các component phức tạp hơn nói chung không chứa logic kinh doanh. +5. `atoms` - Các component UI không có logic kinh doanh. + +Các module ở một layer chỉ tương tác với các module ở các layer bên dưới, tương tự như FSD. +Tức là, các molecule được xây dựng từ các atom, các organism từ các molecule, các template từ các organism, và các page từ các template. +Atomic Design cũng bao hàm việc sử dụng Public API trong các module để cô lập. + +### Khả năng áp dụng cho frontend + +Atomic Design tương đối phổ biến trong các dự án. Atomic Design phổ biến hơn trong các nhà thiết kế web hơn là trong phát triển. +Các nhà thiết kế web thường sử dụng Atomic Design để tạo ra các thiết kế có thể mở rộng và dễ bảo trì. +Trong phát triển, Atomic Design thường được trộn lẫn với các methodology kiến trúc khác. + +Tuy nhiên, vì Atomic Design tập trung vào các component UI và sự kết hợp của chúng, một vấn đề phát sinh với việc triển khai +logic kinh doanh trong kiến trúc. + +Vấn đề là Atomic Design không cung cấp một mức trách nhiệm rõ ràng cho logic kinh doanh, +dẫn đến việc phân tán nó qua các component và mức khác nhau, làm phức tạp hóa bảo trì và testing. +Logic kinh doanh trở nên mờ nhạt, khiến việc phân tách rõ trách nhiệm trở nên khó khăn và làm +code ít modular và có thể tái sử dụng hơn. + +### Nó liên quan đến FSD như thế nào? + +Trong bối cảnh của FSD, một số yếu tố của Atomic Design có thể được áp dụng để tạo ra các component UI linh hoạt và có thể mở rộng. +Các layer `atoms` và `molecules` có thể được triển khai trong `shared/ui` trong FSD, đơn giản hóa việc tái sử dụng và +bảo trì các yếu tố UI cơ bản. + + +- shared/ + - ui/ + - atoms/ + - molecules/ + + +So sánh FSD và Atomic Design cho thấy rằng cả hai methodology đều hướng tới tính modular và tái sử dụng +nhưng tập trung vào các khía cạnh khác nhau. Atomic Design hướng tới các component trực quan và sự kết hợp của chúng. +FSD tập trung vào việc chia chức năng của ứng dụng thành các module độc lập và các kết nối giữa chúng. + +- [Atomic Design Methodology](https://atomicdesign.bradfrost.com/table-of-contents/) +- [(Thread) About applicability in shared / ui](https://t.me/feature_sliced/1653) +- [(Video) Briefly about Atomic Design](https://youtu.be/Yi-A20x2dcA) +- [(Talk) Ilya Azin - Feature-Sliced Design (fragment about Atomic Design)](https://youtu.be/SnzPAr_FJ7w?t=587) + +## Feature Driven + +> Về cách tiếp cận; Về khả năng áp dụng trong frontend; Vị trí của FSD + +Về tính tương thích, phát triển lịch sử và so sánh + +- [(Talk) Oleg Isonen - Feature Driven Architecture](https://youtu.be/BWAeYuWFHhs) +- [Feature Driven-Short specification (from the point of view of FSD)](https://github.com/feature-sliced/documentation/tree/rc/feature-driven) diff --git a/src/content/docs/vi/docs/about/mission.mdx b/src/content/docs/vi/docs/about/mission.mdx new file mode 100644 index 0000000000..8dec0555ca --- /dev/null +++ b/src/content/docs/vi/docs/about/mission.mdx @@ -0,0 +1,53 @@ +--- +title: Sứ mệnh +sidebar: + order: 1 +--- + +# Sứ mệnh + +Ở đây chúng tôi mô tả các mục tiêu và hạn chế của khả năng áp dụng của phương pháp - những gì chúng tôi được hướng dẫn khi phát triển phương pháp + +- Chúng tôi xem mục tiêu của mình là sự cân bằng giữa ideology và tính đơn giản +- Chúng tôi sẽ không thể tạo ra một giải pháp vạn năng phù hợp với mọi người + +**Tuy nhiên, phương pháp này nên gần gũi và dễ tiếp cận cho một nhóm rộng rãi các developer** + +## Mục tiêu + +### Tính rõ ràng trực quan cho nhiều developer + +Phương pháp này nên dễ tiếp cận - cho hầu hết thành viên trong nhóm dự án + +*Vì ngay cả với tất cả các công cụ tương lai, nó sẽ không đủ, nếu chỉ có những senior/lead có kinh nghiệm hiểu được phương pháp* + +### Giải quyết các vấn đề hàng ngày + +Phương pháp này nên đưa ra các lý do và giải pháp cho các vấn đề hàng ngày của chúng ta khi phát triển dự án + +**Và cũng - gắn kèm công cụ vào tất cả những điều này (cli, linter)** + +Để các developer có thể sử dụng một cách tiếp cận *đã được kiểm nghiệm trong thực chiến* cho phép họ bỏ qua các vấn đề lâu dài về kiến trúc và phát triển + +> *@sergeysova: Hãy tưởng tượng rằng, một developer viết code trong khuôn khổ của phương pháp và gặp vấn đề ít hơn 10 lần, đơn giản vì những người khác đã nghĩ ra giải pháp cho nhiều vấn đề.* + +## Hạn chế + +Chúng tôi không muốn *ép buộc quan điểm của mình*, và đồng thời chúng tôi hiểu rằng *nhiều thói quen của chúng ta, với tư cách là developer, cản trở từ ngày này qua ngày khác* + +Mọi người đều có mức độ kinh nghiệm riêng trong việc thiết kế và phát triển hệ thống, **do đó, đáng làm hiểu những điều sau:** + +- **Sẽ không hoạt động**: rất đơn giản, rất rõ ràng, cho mọi người + > *@sergeysova: Một số khái niệm không thể được hiểu một cách trực quan cho đến khi bạn gặp phải vấn đề và dành nhiều năm để giải quyết chúng.* + > + > - *Trong thế giới toán học: là graph theory.* + > - *Trong vật lý: cơ học lượng tử.* + > - *Trong lập trình: kiến trúc ứng dụng.* + +- **Có thể và mong muốn**: tính đơn giản, khả năng mở rộng + +## Xem thêm + +- [Các vấn đề kiến trúc][refs-architecture--problems] + +[refs-architecture--problems]: /docs/about/understanding/architecture#problems diff --git a/src/content/docs/vi/docs/about/motivation.mdx b/src/content/docs/vi/docs/about/motivation.mdx new file mode 100644 index 0000000000..d89d2315d9 --- /dev/null +++ b/src/content/docs/vi/docs/about/motivation.mdx @@ -0,0 +1,150 @@ +--- +title: Động lực +sidebar: + order: 2 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Ý tưởng chính của **Feature-Sliced Design** là tạo điều kiện thuận lợi và giảm chi phí phát triển các dự án phức tạp và được phát triển, dựa trên việc [kết hợp kết quả nghiên cứu, thảo luận kinh nghiệm của các loại developer khác nhau trong phạm vi rộng][ext-discussions]. + +Hiển nhiên, đây sẽ không phải là giải pháp vạn năng, và tất nhiên, phương pháp này sẽ có [giới hạn khả năng áp dụng][refs-mission] riêng của mình. + +Tuy nhiên, có những câu hỏi hợp lý về *tính khả thi của một phương pháp như vậy nói chung* + + + +## Tại sao các giải pháp hiện có không đủ? + +> Thường là những lập luận này: +> +> - *"Tại sao bạn cần một methodology mới, khi đã có những phương pháp và nguyên tắc thiết kế lâu đời như `SOLID`, `KISS`, `YAGNI`, `DDD`, `GRASP`, `DRY`, v.v."* +> - *"Tất cả các vấn đề được giải quyết bằng tài liệu dự án tốt, test và quy trình có cấu trúc"* +> - *"Các vấn đề sẽ không xảy ra nếu tất cả developer đều tuân theo những điều trên"* +> - *"Mọi thứ đã được phát minh trước bạn, bạn chỉ không biết sử dụng nó"* +> - *"Hãy lấy \{FRAMEWORK_NAME\} - mọi thứ đã được quyết định sẵn cho bạn ở đó"* + +### Chỉ có nguyên tắc là chưa đủ + +**Việc tồn tại các nguyên tắc là chưa đủ để thiết kế một kiến trúc tốt** + +Không phải ai cũng biết chúng một cách hoàn toàn, thậm chí ít người hơn hiểu và áp dụng chúng một cách chính xác + +*Các nguyên tắc thiết kế quá chung chung, và không đưa ra câu trả lời cụ thể cho câu hỏi: "Làm thế nào để thiết kế cấu trúc và kiến trúc của một ứng dụng có thể mở rộng và linh hoạt?"* + +### Quy trình không phải lúc nào cũng hiệu quả + +*Tài liệu/Test/Quy trình* tất nhiên là tốt, nhưng tiếc thay, ngay cả với chi phí cao cho chúng - **chúng không phải lúc nào cũng giải quyết được các vấn đề do kiến trúc đặt ra và việc đưa người mới vào dự án** + +- Thời gian để mỗi developer tham gia vào dự án không được giảm đáng kể, bởi vì tài liệu thường sẽ trở nên khổng lồ / lỗi thời +- Liên tục đảm bảo rằng mọi người hiểu kiến trúc theo cùng một cách - điều này cũng đòi hỏi một lượng tài nguyên khổng lồ +- Đừng quên về bus-factor + +### Các framework hiện có không thể áp dụng ở mọi nơi + +- Các giải pháp hiện có thường có ngưỡng vào cao, điều này khiến việc tìm kiếm developer mới trở nên khó khăn +- Ngoài ra, phần lớn thời gian, việc lựa chọn công nghệ đã được xác định trước khi các vấn đề nghiêm trọng trong dự án xuất hiện, và do đó bạn cần có khả năng "làm việc với những gì có sẵn" - **mà không bị ràng buộc vào công nghệ** + +> Q: *"Trong dự án của tôi `React/Vue/Redux/Effector/Mobx/{YOUR_TECH}` - làm thế nào tôi có thể xây dựng tốt hơn cấu trúc của các entity và mối quan hệ giữa chúng?"* + +### Kết quả + +Chúng ta nhận được các dự án *"độc đáo như bông tuyết"*, mỗi dự án đều đòi hỏi nhân viên phải học hỏi lâu dài, và kiến thức không chắc có thể áp dụng được cho dự án khác + +> @sergeysova: *"Đây chính xác là tình huống hiện tại tồn tại trong lĩnh vực phát triển frontend của chúng ta: mỗi lead sẽ phát minh ra các kiến trúc và cấu trúc dự án khác nhau, trong khi không chắc rằng những cấu trúc này sẽ vượt qua được thử thách thời gian, kết quả là tối đa hai người có thể phát triển dự án ngoài anh ta, và mỗi developer mới cần phải được hướng dẫn lại từ đầu."* + +## Tại sao các developer cần methodology? + +### Tập trung vào các tính năng kinh doanh, không phải vấn đề kiến trúc + +Methodology cho phép bạn tiết kiệm tài nguyên trong việc thiết kế một kiến trúc có thể mở rộng và linh hoạt, thay vào đó hướng sự chú ý của các developer vào việc phát triển chức năng chính. Đồng thời, các giải pháp kiến trúc được tiêu chuẩn hóa từ dự án này sang dự án khác. + +*Một câu hỏi riêng là methodology nên giành được sự tin tưởng của cộng đồng, để một developer khác có thể làm quen với nó và dựa vào nó trong việc giải quyết các vấn đề của dự án trong thời gian có sẵn* + +### Giải pháp đã được kiểm chứng bằng kinh nghiệm + +Methodology được thiết kế cho các developer hướng tới *một giải pháp đã được chứng minh cho việc thiết kế logic kinh doanh phức tạp* + +*Tuy nhiên, rõ ràng là methodology nói chung là về một tập hợp các best-practice, bài viết giải quyết các vấn đề và trường hợp nhất định trong quá trình phát triển. Do đó, methodology cũng sẽ hữu ích cho các developer khác - những người bằng cách nào đó gặp phải vấn đề trong quá trình phát triển và thiết kế* + +### Sức khỏe dự án + +Methodology sẽ cho phép *giải quyết và theo dõi các vấn đề của dự án trước, mà không đòi hỏi một lượng lớn tài nguyên* + +**Phần lớn thời gian, nợ kỹ thuật tích tụ và tích tụ theo thời gian, và trách nhiệm giải quyết nó nằm ở cả lead và team** + +Methodology sẽ cho phép bạn *cảnh báo* trước các vấn đề có thể xảy ra trong việc mở rộng và phát triển dự án + +## Tại sao doanh nghiệp cần một methodology? + +### Onboarding nhanh chóng + +Với methodology, bạn có thể thuê một người vào dự án **đã quen thuộc với cách tiếp cận này trước đó, và không cần đào tạo lại** + +*Mọi người bắt đầu hiểu và đóng góp cho dự án nhanh hơn, và có thêm bảo đảm để tìm người cho các lần lặp tiếp theo của dự án* + +### Giải pháp đã được kiểm chứng bằng kinh nghiệm + +Với methodology, doanh nghiệp sẽ có *giải pháp cho hầu hết các vấn đề phát sinh trong quá trình phát triển hệ thống* + +Vì phần lớn thời gian doanh nghiệp muốn có một framework / giải pháp có thể giải quyết phần lớn các vấn đề trong quá trình phát triển dự án + +### Khả năng áp dụng cho các giai đoạn khác nhau của dự án + +Methodology có thể mang lại lợi ích cho dự án *cả ở giai đoạn hỗ trợ và phát triển dự án, và ở giai đoạn MVP* + +Vâng, điều quan trọng nhất đối với MVP là *"các tính năng, không phải kiến trúc được đặt nền cho tương lai"*. Nhưng ngay cả trong điều kiện deadline hạn chế, việc biết các best-practice từ methodology, bạn có thể *"làm với ít máu"*, khi thiết kế phiên bản MVP của hệ thống, tìm ra một sự thỏa hiệp hợp lý +(thay vì mô hình hóa các tính năng "một cách ngẫu nhiên") + +*Điều tương tự có thể nói về testing* + +## Khi nào methodology của chúng tôi không cần thiết? + +- Nếu dự án sẽ tồn tại trong thời gian ngắn +- Nếu dự án không cần kiến trúc được hỗ trợ +- Nếu doanh nghiệp không nhận thấy mối liên kết giữa code base và tốc độ phát triển tính năng +- Nếu đối với doanh nghiệp việc đóng các đơn hàng càng sớm càng tốt, mà không cần hỗ trợ thêm + +### Qôy mô doanh nghiệp + +- **Doanh nghiệp nhỏ** - thường cần một giải pháp có sẵn và rất nhanh. Chỉ khi doanh nghiệp phát triển (ít nhất là gần trung bình), họ mới hiểu rằng để khách hàng tiếp tục sử dụng, cần thiết, trong số những điều khác, là dành thời gian cho chất lượng và tính ổn định của các giải pháp đang được phát triển +- **Doanh nghiệp vừa** - thường hiểu tất cả các vấn đề của phát triển, và ngay cả khi cần thiết *"đua tốc để có các tính năng"*, họ vẫn dành thời gian cho việc cải thiện chất lượng, refactoring và test (và tất nhiên - cho kiến trúc có thể mở rộng) +- **Doanh nghiệp lớn** - thường đã có đối tượng rộng rãi, nhân sự, và một tập hợp các thực hành rộng rãi hơn nhiều, và có thể thậm chí có cách tiếp cận kiến trúc riêng của họ, nên ý tưởng sử dụng của người khác không đến với họ thường xuyên + +## Kế hoạch + +Phần chính của các mục tiêu [được trình bày ở đây][refs-mission--goals], nhưng ngoài ra, đáng nói về kỳ vọng của chúng tôi từ methodology trong tương lai + +### Kết hợp kinh nghiệm + +Hiện tại chúng tôi đang cố gắng kết hợp tất cả kinh nghiệm đa dạng của `core-team`, và có được một methodology được rèn luyện bằng thực hành + +Tất nhiên, kết quả có thể là Angular 3.0, nhưng quan trọng hơn ở đây là **nghiên cứu chính vấn đề thiết kế kiến trúc của các hệ thống phức tạp** + +*Và vâng - chúng tôi có phàn nàn về phiên bản hiện tại của methodology, nhưng chúng tôi muốn làm việc cùng nhau để đi đến một giải pháp duy nhất và tối ưu (tính đến, trong số những điều khác, kinh nghiệm của cộng đồng)* + +### Cuộc sống ngoài specification + +Nếu mọi thứ điễn ra tốt, thì methodology sẽ không chỉ giới hạn trong specification và toolkit + +- Có thể sẽ có báo cáo, bài viết +- Có thể có `CODE_MOD` cho việc migration sang các công nghệ khác của các dự án được viết theo methodology +- Có thể kết quả là chúng ta sẽ có thể tiếp cận được các maintainer của các giải pháp công nghệ lớn + - *Đặc biệt cho React, so với các framework khác - đây là vấn đề chính, vì nó không nói cách giải quyết những vấn đề nhất định* + +## Xem thêm + +- [(Thảo luận) Không cần methodology?][disc-src] +- [Về sứ mệnh của methodology: mục tiêu và giới hạn][refs-mission] +- [Các loại kiến thức trong dự án][refs-knowledge] + +[refs-mission]: /docs/about/mission +[refs-mission--goals]: /docs/about/mission#goals +[refs-knowledge]: /docs/about/understanding/knowledge-types + +[disc-src]: https://github.com/feature-sliced/documentation/discussions/27 +[ext-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/vi/docs/about/promote/integration.mdx b/src/content/docs/vi/docs/about/promote/integration.mdx new file mode 100644 index 0000000000..277296f9eb --- /dev/null +++ b/src/content/docs/vi/docs/about/promote/integration.mdx @@ -0,0 +1,27 @@ +--- +title: Các khía cạnh tích hợp +sidebar: + order: 1 +--- + +# Các khía cạnh tích hợp + +## Tóm tắt + +5 phút đầu (RU): + + + +## Ngoài ra + +**Ưu điểm**: +- [Tổng quan](/docs/get-started/overview) +- CodeReview +- Onboarding + + +**Nhược điểm:** +- Độ phức tạp về mặt tư duy +- Ngưỡng vào cao +- "Layer hell" +- Các vấn đề điển hình của các cách tiếp cận dựa trên feature diff --git a/src/content/docs/vi/docs/about/understanding/architecture.mdx b/src/content/docs/vi/docs/about/understanding/architecture.mdx new file mode 100644 index 0000000000..d6e3ca6be9 --- /dev/null +++ b/src/content/docs/vi/docs/about/understanding/architecture.mdx @@ -0,0 +1,99 @@ +--- +title: Về kiến trúc +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## Các vấn đề + +Thường thì cuộc trò chuyện về kiến trúc được nêng lên khi việc phát triển dừng lại do một số vấn đề nhất định trong dự án. + +### Bus-factor & Onboarding + +Chỉ có một số lượng hạn chế người hiểu dự án và kiến trúc của nó + +**Ví dụ:** + +- *"Khó đưa người vào phát triển"* +- *"Đối với mọi vấn đề, mọi người đều có ý kiến riêng về cách giải quyết" (hãy ghen tị với angular)* +- *"Tôi không hiểu chuyện gì đang xảy ra trong khối monolith lớn này"* + +### Hậu quả ngầm định và không kiểm soát được + +Nhiều tác dụng phụ ngầm định trong quá trình phát triển/refactoring *("tất cả đều phụ thuộc vào nhau")* + +**Ví dụ:** + +- *"Feature import feature"* +- *"Tôi cập nhật store của một trang, và chức năng ở trang khác bị rơi"* +- *"Logic bị tráng đầy khắp ứng dụng, và không thể theo dõi được đâu là đầu, đâu là cuối"* + +### Tái sử dụng logic không kiểm soát được + +Khó khăn trong việc tái sử dụng/sửa đổi logic hiện có + +Đồng thời, thường có [hai thái cực](https://github.com/feature-sliced/documentation/discussions/14): + +- Hoặc logic được viết hoàn toàn từ đầu cho mỗi module *(với khả năng lặp lại trong codebase hiện có)* +- Hoặc có xu hướng chuyển tất-tất cả các module đã triển khai vào thư mục `shared`, từ đó tạo ra một kho chứa lớn các module *từ nó (trong đó hầu hết chỉ được sử dụng ở một nơi)* + +**Ví dụ:** + +- *"Tôi có **N** cách triển khai cùng một logic kinh doanh trong dự án của mình, mà tôi vẫn phải trả giá"* +- *"Có 6 component khác nhau của button/pop-up/... trong dự án"* +- *"Kho chứa các helper"* + +## Yêu cầu + +Do đó, có vẻ hợp lý khi trình bày các *yêu cầu mong muốn cho một kiến trúc lý tưởng:* + + + +### Tính rõ ràng + +- Nên **dễ dàng nắm vững và giải thích** dự án và kiến trúc của nó cho team +- Cấu trúc nên phản ánh **giá trị kinh doanh thực tế của dự án** +- Phải có **tác dụng phụ và kết nối** rõ ràng giữa các abstraction +- Nên **dễ phát hiện logic trùng lặp** mà không can thiệp vào các triển khai độc đáo +- Không nên có **sự phân tán logic** khắp dự án +- Không nên có **quá nhiều abstraction và quy tắc khác biệt** cho một kiến trúc tốt + +### Kiểm soát + +- Một kiến trúc tốt nên **tăng tốc giải quyết các tác vụ, việc đưa vào các tính năng** +- Nên có thể kiểm soát quá trình phát triển dự án +- Nên dễ dàng **mở rộng, sửa đổi, xóa code** +- Phải tuân thủ việc **phân tách và cô lập** chức năng +- Mỗi component của hệ thống phải **dễ dàng thay thế và loại bỏ** + - *[Không cần tối ưu hóa cho thay đổi][ext-kof-not-modification] - chúng ta không thể dự đoán tương lai* + - *[Tốt hơn là tối ưu hóa cho việc xóa][ext-kof-but-removing] - dựa trên bối cảnh đã tồn tại* + +### Khả năng thích ứng + +- Một kiến trúc tốt nên có thể áp dụng **cho hầu hết các dự án** + - *Với các giải pháp hạ tầng hiện có* + - *ở bất kỳ giai đoạn phát triển nào* +- Không nên phụ thuộc vào framework và nền tảng +- Nên có thể **dễ dàng mở rộng dự án và team**, với khả năng song song hóa phát triển +- Nên dễ dàng **thích ứng với các yêu cầu và hoàn cảnh thay đổi** + +## Xem thêm + +- [(React Berlin Talk) Oleg Isonen - Feature Driven Architecture][ext-kof] +- [(React SPB Meetup #1) Sergey Sova - Feature Slice][ext-slices-spb] +- [(Bài viết) Về việc modular hóa dự án][ext-medium] +- [(Bài viết) Về Separation of Concern và cấu trúc theo feature][ext-ryanlanciaux] + +[ext-kof-not-modification]: https://youtu.be/BWAeYuWFHhs?t=1631 +[ext-kof-but-removing]: https://youtu.be/BWAeYuWFHhs?t=1666 + +[ext-slices-spb]: https://t.me/feature_slices +[ext-kof]: https://youtu.be/BWAeYuWFHhs +[ext-medium]: https://alexmngn.medium.com/why-react-developers-should-modularize-their-applications-d26d381854c1 +[ext-ryanlanciaux]: https://ryanlanciaux.com/blog/2017/08/20/a-feature-based-approach-to-react-development/ diff --git a/src/content/docs/vi/docs/about/understanding/knowledge-types.mdx b/src/content/docs/vi/docs/about/understanding/knowledge-types.mdx new file mode 100644 index 0000000000..724f4906cd --- /dev/null +++ b/src/content/docs/vi/docs/about/understanding/knowledge-types.mdx @@ -0,0 +1,32 @@ +--- +sidebar_position: 3 +title: Các loại kiến thức trong dự án +sidebar: + label: Knowledge types + order: 3 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Có thể phân biệt các "loại kiến thức" sau trong bất kỳ dự án nào: + +* **Kiến thức cơ bản** + Kiến thức không thay đổi nhiều theo thời gian, chẳng hạn như thuật toán, khoa học máy tính, cơ chế ngôn ngữ lập trình và API của nó. + +* **Technology stack** + Kiến thức về tập hợp các giải pháp kỹ thuật được sử dụng trong dự án, bao gồm ngôn ngữ lập trình, framework và thư viện. + +* **Kiến thức dự án** + Kiến thức dành riêng cho dự án hiện tại và không có giá trị bên ngoài nó. Kiến thức này là cần thiết để các developer mới onboard có thể đóng góp hiệu quả. + + + +## Xem thêm \{#see-also} + +- [(Video 🇷🇺) Ilya Klimov - Về các loại kiến thức][ext-klimov] + +[ext-klimov]: https://youtu.be/4xyb_tA-uw0?t=249 diff --git a/src/content/docs/vi/docs/about/understanding/naming.mdx b/src/content/docs/vi/docs/about/understanding/naming.mdx new file mode 100644 index 0000000000..26d86472e2 --- /dev/null +++ b/src/content/docs/vi/docs/about/understanding/naming.mdx @@ -0,0 +1,48 @@ +--- +title: Đặt tên +sidebar: + order: 4 +--- + +Các developer khác nhau có kinh nghiệm và ngữ cảnh khác nhau, có thể dẫn đến hiểu lầm trong team khi cùng một entity được gọi khác nhau. Ví dụ: + +- Component để hiển thị có thể được gọi là "ui", "component", "ui-kit", "view", … +- Code được tái sử dụng trong toàn bộ ứng dụng có thể được gọi là "core", "shared", "app", … +- Code logic kinh doanh có thể được gọi là "store", "model", "state", … + +## Đặt tên trong Feature-Sliced Design \{#naming-in-fsd} + +Methodology sử dụng các thuật ngữ cụ thể như: + +- "app", "process", "page", "feature", "entity", "shared" như tên layer, +- "ui", "model", "lib", "api", "config" như tên segment. + +Rất quan trọng là phải tuân thủ những thuật ngữ này để tránh nhầm lẫn giữa các thành viên trong team và developer mới tham gia dự án. Việc sử dụng tên chuẩn cũng giúp khi xin trợ giúp từ cộng đồng. + +## Xung đột đặt tên \{#when-can-naming-interfere} + +Xung đột đặt tên có thể xảy ra khi các thuật ngữ được sử dụng trong methodology FSD trùng lặp với các thuật ngữ được sử dụng trong kinh doanh: + +- `FSD#process` vs quy trình mô phỏng trong ứng dụng, +- `FSD#page` vs trang log, +- `FSD#model` vs model xe hơi. + +Ví dụ, một developer nhìn thấy từ "process" trong code sẽ tốn thêm thời gian để tìm hiểu quy trình nào đang được đề cập. Những **xung đột như vậy có thể làm gián đoạn quá trình phát triển**. + +Khi từ vựng của dự án chứa thuật ngữ đặc thù của FSD, điều quan trọng là phải cẩn thận khi thảo luận các thuật ngữ này với team và các bên liên quan không quan tâm đến kỹ thuật. + +Để giao tiếp hiệu quả với team, được khuyến nghị sử dụng từ viết tắt "FSD" làm tiền tố cho các thuật ngữ methodology. Ví dụ, khi nói về một process, bạn có thể nói, "Chúng ta có thể đặt process này trên layer feature của FSD." + +Ngược lại, khi giao tiếp với các bên liên quan không thuộc kỹ thuật, tốt hơn là hạn chế sử dụng thuật ngữ FSD và tránh đề cập đến cấu trúc bên trong của codebase. + +## Xem thêm \{#see-also} + +- [(Thảo luận) Khả năng thích ứng của việc đặt tên][disc-src] +- [(Thảo luận) Khảo sát đặt tên Entity][disc-naming] +- [(Thảo luận) "process" vs "flow" vs ...][disc-processes] +- [(Thảo luận) "model" vs "store" vs ...][disc-model] + +[disc-model]: https://github.com/feature-sliced/documentation/discussions/68 +[disc-naming]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-464894 +[disc-processes]: https://github.com/feature-sliced/documentation/discussions/20 +[disc-src]: https://github.com/feature-sliced/documentation/discussions/16 diff --git a/src/content/docs/vi/docs/about/understanding/needs-driven.mdx b/src/content/docs/vi/docs/about/understanding/needs-driven.mdx new file mode 100644 index 0000000000..72f2f0ea5c --- /dev/null +++ b/src/content/docs/vi/docs/about/understanding/needs-driven.mdx @@ -0,0 +1,163 @@ +--- +title: Hướng nhu cầu +sidebar: + order: 2 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +# Hướng nhu cầu + + + +## Tại sao? + +Để chọn một tên rõ ràng cho một entity và hiểu các thành phần của nó, **bạn cần hiểu rõ task nào sẽ được giải quyết bằng tất cả code này.** + +> _@sergeysova: Trong quá trình phát triển, chúng tôi cố gắng đặt cho mỗi entity hoặc function một tên rõ ràng phản ánh ý định và ý nghĩa của code đang được thực thi._ + +_Sau cùng, không hiểu task thì không thể viết được test đúng bao phủ những trường hợp quan trọng nhất, đặt các lỗi giúp đỡ người dùng ở đúng chỗ, thậm chí là thiếu không làm gián đoạn luồng của người dùng vì các lỗi không quan trọng có thể sửa được._ + +## Chúng ta đang nói về task gì? + +Frontend phát triển ứng dụng và giao diện cho người dùng cuối, nên chúng ta giải quyết các task của những người tiêu dùng này. + +Khi một người đến với chúng ta, **anh ta muốn giải quyết một số đau khổ của mình hoặc đóng một nhu cầu.** + +_Nhiệm vụ của các manager và analyst là đưa ra nhu cầu này, và triển khai developer tính đến các tính năng của phát triển web (mất kết nối, lỗi backend, typo, nhấm con trỏ hoặc ngón tay)._ + +**Chính mục tiêu này, mà người dùng đến, là task của các developer.** + +> _Một vấn đề nhỏ được giải quyết là một feature trong methodology Feature-Sliced Design — bạn cần cắt toàn bộ phạm vi task của dự án thành các mục tiêu nhỏ._ + +## Điều này ảnh hưởng đến phát triển như thế nào? + +### Phân tách task + +Khi một developer bắt đầu triển khai một task, để đơn giản hóa việc hiểu và hỗ trợ code, anh ta trong đầu **cắt nó thành các giai đoạn**: + +* đầu tiên _chia thành các entity cấp cao nhất_ và _triển khai chúng_, +* sau đó những entity này _chia thành những cái nhỏ hơn_ +* và cứ tiếp tục như vậy + +_Trong quá trình chia thành các entity, developer bị buộc phải đặt tên cho chúng một cách rõ ràng phản ánh ý tưởng của mình và giúp hiểu task nào code giải quyết khi đọc listing_ +_Đồng thời, chúng ta không quên rằng chúng ta đang cố gắng giúp người dùng giảm bớt đau khổ hoặc thực hiện nhu cầu_ + +### Hiểu bản chất của task + +Nhưng để đặt tên rõ ràng cho một entity, **developer phải biết đủ về mục đích của nó** + +* anh ta sẽ sử dụng entity này như thế nào, +* nó triển khai phần nào của task của người dùng, entity này còn có thể được áp dụng ở đâu khác, +* nó có thể tham gia vào những task nào khác, +* và vân vân + +Không khó để rút ra kết luận: **trong khi developer sẽ suy ngẫm về tên của các entity trong khuôn khổ của methodology, anh ta sẽ có thể tìm ra các task được đưa ra kém thậm chí trước khi viết code.** + +> Làm thế nào đặt tên cho một entity nếu bạn không hiểu rõ những task nào nó có thể giải quyết, làm thế nào bạn có thể chia một task thành các entity nếu bạn không hiểu rõ nó? + +## Làm thế nào để đưa ra nó? + +**Để đưa ra một task được giải quyết bằng các feature, bạn cần hiểu bản thân task đó**, và đây đã là trách nhiệm của project manager và các analyst. + +_Methodology chỉ có thể nói cho developer những task nào mà product manager nên chú ý kỹ._ + +> _@sergeysova: Toàn bộ frontend chủ yếu là hiển thị thông tin, bất kỳ component nào ở lượt đầu tiên, hiển thị, và sau đó task "hiển thị cho người dùng một cái gì đó" không có giá trị thực tế._ +> +> _Ngay cả khi không tính đến đặc thù của frontend có thể hỏi, "tại sao tôi phải hiển thị cho bạn", và có thể tiếp tục hỏi cho đến khi không thoát được khỏi đau khổ hoặc nhu cầu của người tiêu dùng._ + +Người khi chúng ta có thể đến được những nhu cầu hoặc đau khổ cơ bản, chúng ta có thể quay lại và tìm hiểu **chính xác sản phẩm hoặc dịch vụ của bạn có thể giúp người dùng với mục tiêu của họ như thế nào** + +Bất kỳ task mới nào trong tracker của bạn đều hướng đến giải quyết các vấn đề kinh doanh, và doanh nghiệp cố gắng giải quyết các task của người dùng đồng thời kiếm tiền từ nó. Điều này có nghĩa là mỗi task đều có những mục tiêu nhất định, ngay cả khi chúng không được viết rõ trong văn bản mô tả. + +_**Developer phải hiểu rõ mục tiêu mà task này hay task khác đang theo đuổi**, nhưng không phải công ty nào cũng có thể đủ khả năng xây dựng quy trình hoàn hảo, mặc dù đây là một cuộc trò chuyện riêng, tuy nhiên, developer có thể "ping" các manager phù hợp để tìm hiểu điều này và thực hiện phần việc của mình một cách hiệu quả._ + +## Và lợi ích là gì? + +Bây giờ hãy nhìn toàn bộ quy trình từ đầu đến cuối. + +### 1. Hiểu task của người dùng + +Khi developer hiểu nỗi đau của họ và cách doanh nghiệp giải quyết chúng, anh ta có thể đưa ra các giải pháp mà doanh nghiệp không có do đặc thù của phát triển web. + +> Nhưng tất nhiên, tất cả điều này chỉ có thể hoạt động nếu developer không thờ ơ với những gì anh ta đang làm và vì mục đích gì, nếu không thì _tại sao lại cần methodology và các cách tiếp cận?_ + +### 2. Cấu trúc hóa và sắp xếp + +Với sự hiểu biết về task đến **một cấu trúc rõ ràng cả trong đầu và trong task cùng với code** + +### 3. Hiểu feature và các thành phần của nó + +**Một feature là một chức năng hữu ích cho người dùng** + +* Khi nhiều features được triển khai trong một feature, đây là **vi phạm ranh giới** +* Feature có thể không thể chia nhỏ và đang phát triển - **và điều này không tệ** +* **Tệ** - khi feature không trả lời câu hỏi _"Giá trị kinh doanh cho người dùng là gì?"_ +* Không thể có feature "map-office" + * Nhưng `booking-meeting-on-the-map`, `search-for-an-employee`, `change-of-workplace` - **có** + +> _@sergeysova: Điểm mấu chốt là feature chỉ chứa code triển khai chính chức năng_, không có chi tiết không cần thiết và giải pháp nội bộ (lý tưởng)* +> +> *Mở code feature **và chỉ thấy những gì liên quan đến task** - không hơn* + +### 4. Lợi ích + +Doanh nghiệp rất hiếm khi xoay chuyển hướng đi hoàn toàn sang hướng khác, có nghĩa là **phản ánh các task kinh doanh trong code ứng dụng frontend là lợi ích rất đáng kể.** + +_Sau đó bạn không phải giải thích cho mỗi thành viên mới trong team code này hay code kia làm gì, và nói chung tại sao nó được thêm vào - **mọi thứ sẽ được giải thích thông qua các task kinh doanh đã được phản ánh trong code.**_ + +> Cái được gọi là ["Ngôn ngữ kinh doanh" trong Domain Driven Development][ext-ubiq-lang] + +--- + +## Quay lại thực tế + +Nếu quy trình kinh doanh được hiểu và đặt tên tốt ở giai đoạn thiết kế - _thì không có vấn đề gì đặc biệt khi chuyển sự hiểu biết và logic này vào code._ + +**Tuy nhiên, trong thực tế**, task và chức năng thường được phát triển "quá" lặp đi lặp lại và (hoặc) không có thời gian để suy nghĩ kỹ về thiết kế. + +**Kết quả là, feature có ý nghĩa hôm nay, và nếu bạn mở rộng feature này trong một tháng, bạn có thể phải viết lại nửa dự án.** + +> *[[Từ cuộc thảo luận][disc-src]]: Developer cố gắng suy nghĩ trước 2-3 bước, tính đến những mong muốn trong tương lai, nhưng ở đây anh ta dựa vào kinh nghiệm của riêng mình* +> +> _Kỹ sư có kinh nghiệm thường ngay lập tức nhìn trước 10 bước, và hiểu nơi nào để chia một feature và kết hợp với feature khác_ +> +> _Nhưng đôi khi có những task mà chưa từng gặp trong kinh nghiệm, và không biết lấy đâu ra sự hiểu biết về cách phân tách hợp lý, với hậu quả không mong muốn ít nhất trong tương lai_ + +## Vai trò của methodology + +**Methodology giúp giải quyết vấn đề của developers, để dễ dàng hơn giải quyết vấn đề của người dùng.** + +Không có giải pháp cho vấn đề của developers chỉ vì lợi ích của developers + +Nhưng để developer giải quyết task của mình, **bạn cần hiểu task của người dùng** - ngược lại sẽ không hoạt động + +### Yêu cầu của methodology + +Rõ ràng là bạn cần xác định ít nhất hai yêu cầu cho **Feature-Sliced Design**: + +1. Methodology nên chỉ ra **cách tạo features, processes và entities** + + * Có nghĩa là nó nên giải thích rõ ràng _cách chia code giữa chúng_, có nghĩa là việc đặt tên các entities này cũng nên được quy định trong specification. + +2. Methodology nên giúp kiến trúc **[dễ dàng thích ứng với yêu cầu thay đổi của dự án][refs-arch--adaptability]** + +## Xem thêm + +* [(Bài viết) Kích thích cho việc xây dựng task rõ ràng (+ thảo luận)][disc-src] + > _**Bài viết hiện tại** là bản chuyển thể của cuộc thảo luận này, bạn có thể đọc phiên bản đầy đủ không cắt tại liên kết_ +* [(Thảo luận) Cách chia chức năng và nó là gì][tg-src] +* [(Bài viết) "Cách tổ chức ứng dụng của bạn tốt hơn"][ext-medium] + +[refs-arch--adaptability]: architecture#adaptability + +[ext-medium]: https://alexmngn.medium.com/how-to-better-organize-your-react-applications-2fd3ea1920f1 +[disc-src]: https://t.me/sergeysova/318 +[tg-src]: https://t.me/atomicdesign/18972 +[ext-ubiq-lang]: https://thedomaindrivendesign.io/developing-the-ubiquitous-language diff --git a/src/content/docs/vi/docs/branding.mdx b/src/content/docs/vi/docs/branding.mdx new file mode 100644 index 0000000000..4ea91e5def --- /dev/null +++ b/src/content/docs/vi/docs/branding.mdx @@ -0,0 +1,81 @@ +--- +title: Hướng dẫn Thương hiệu +--- + +import { Aside } from '@astrojs/starlight/components'; + +Bản sắc thị giác của FSD dựa trên các khái niệm cốt lõi: `Layered`, `Sliced self-contained parts`, `Parts & Compose`, `Segmented`. +Nhưng chúng tôi cũng có xu hướng thiết kế bản sắc đơn giản, đẹp mắt, thể hiện triết lý FSD và dễ nhận biết. + +**Vui lòng sử dụng bản sắc của FSD "nguyên trạng", không thay đổi nhưng với các tài sản của chúng tôi để thuận tiện cho bạn.** Hướng dẫn thương hiệu này sẽ giúp bạn sử dụng bản sắc FSD một cách chính xác. + + + +## Tiêu đề + +- ✅ **Đúng:** `Feature-Sliced Design`, `FSD` +- ❌ **Sai:** `Feature-Sliced`, `Feature Sliced`, `FeatureSliced`, `feature-sliced`, `feature sliced`, `FS` + +## Emoji + +Hình ảnh bánh 🍰 thể hiện khá tốt các khái niệm cốt lõi của FSD, nên nó đã được chọn làm emoji đặc trưng của chúng tôi + +> Ví dụ: *"🍰 Phương pháp luận thiết kế kiến trúc cho các dự án Frontend"* + +## Logo & Bảng màu + +FSD có một vài biến thể logo cho các ngữ cảnh khác nhau, nhưng được khuyến nghị ưu tiên **primary** + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ThemeLogo (Ctrl/Cmd + Click để tải xuống)Sử dụng
primary
(#29BEDC, #517AED)
logo-primaryƯu tiên trong hầu hết trường hợp
flat
(#3193FF)
logo-flatCho ngữ cảnh một màu
monochrome
(#FFF)
logo-monocrhomeCho ngữ cảnh thang màu xám
square
(#3193FF)
logo-squareCho biên hình vuông
+ +## Banner & Sơ đồ + +banner-primary +banner-monochrome + +## Social Preview + +Đang trong quá trình hoàn thiện... + +## Template thuyết trình + +Đang trong quá trình hoàn thiện... + +## Xem thêm + +- [Discussion (github)](https://github.com/feature-sliced/documentation/discussions/399) +- [Lịch sử phát triển với tham khảo (figma)](https://www.figma.com/file/RPphccpoeasVB0lMpZwPVR/FSD-Brand?node-id=0%3A1) diff --git a/src/content/docs/vi/docs/get-started/faq.mdx b/src/content/docs/vi/docs/get-started/faq.mdx new file mode 100644 index 0000000000..f8c36c89f5 --- /dev/null +++ b/src/content/docs/vi/docs/get-started/faq.mdx @@ -0,0 +1,67 @@ +--- +title: FAQ +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + + + +### Có toolkit hay linter nào không? + +Có! Chúng tôi có linter tên là [Steiger][ext-steiger] để kiểm tra kiến trúc project của bạn và có [folder generator][ext-tools] thông qua CLI hoặc IDE. + +### Đặt layout/template của trang ở đâu? + +Nếu bạn cần layout markup thuần túy, bạn có thể giữ chúng trong `shared/ui`. Nếu bạn cần sử dụng các layer cao hơn bên trong, có một vài lựa chọn: + +- Có lẽ bạn không cần layout chút nào? Nếu layout chỉ có vài dòng, có thể hợp lý hơn là duplicate code trong mỗi trang thay vì cố gắng trừu tượng hóa nó. +- Nếu bạn thực sự cần layout, bạn có thể có chúng như các widget hoặc trang riêng biệt, và compose chúng trong cấu hình router ở App. Nested routing là một lựa chọn khác. + +### Sự khác biệt giữa feature và entity là gì? + +_Entity_ là khái niệm thực tế mà app của bạn đang làm việc với. _Feature_ là tương tác cung cấp giá trị thực tế cho người dùng app của bạn, điều mà mọi người muốn làm với các entity của bạn. + +Để biết thêm thông tin cùng với ví dụ, xem trang Reference về [slice][reference-entities]. + +### Tôi có thể embed page/feature/entity vào nhau không? + +Có, nhưng việc embedding này nên xảy ra ở các layer cao hơn. Ví dụ, bên trong widget, bạn có thể import cả feature rồi insert feature này vào feature khác như props/children. + +Bạn không thể import feature này từ feature khác, điều này bị cấm bởi [**import rule on layers**][import-rule-layers]. + +### Còn Atomic Design thì sao? + +Phiên bản hiện tại của phương pháp luận không yêu cầu cũng không cấm việc sử dụng Atomic Design cùng với Feature-Sliced Design. + +Ví dụ, Atomic Design [có thể được áp dụng tốt](https://t.me/feature_sliced/1653) cho segment `ui` của các module. + +### Có tài nguyên/bài viết/v.v. hữu ích nào về FSD không? + +Có! https://github.com/feature-sliced/awesome + +### Tại sao tôi cần Feature-Sliced Design? + +Nó giúp bạn và team của bạn nhanh chóng tổng quan project theo các component mang lại giá trị chính. Kiến trúc được tiêu chuẩn hóa giúp tăng tốc onboarding và giải quyết các tranh luận về cấu trúc code. Xem trang [motivation][motivation] để tìm hiểu thêm về lý do FSD được tạo ra. + +### Developer mới có cần architecture/methodology không? + +Có thì tốt hơn là không + +*Thường thì khi bạn thiết kế và phát triển project một mình, mọi thứ diễn ra suôn sẻ. Nhưng nếu có tạm dừng trong quá trình phát triển, có thêm developer mới vào team - thì vấn đề sẽ xuất hiện* + +### Làm thế nào để làm việc với authorization context? + +Trả lời [ở đây](/docs/guides/examples/auth) + +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[import-rule-layers]: /docs/reference/layers#import-rule-on-layers +[reference-entities]: /docs/reference/layers#entities +[motivation]: /docs/about/motivation +[telegram]: https://t.me/feature_sliced +[discord]: https://discord.gg/S8MzWTUsmp +[github-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/vi/docs/get-started/overview.mdx b/src/content/docs/vi/docs/get-started/overview.mdx new file mode 100644 index 0000000000..c4e3a55b54 --- /dev/null +++ b/src/content/docs/vi/docs/get-started/overview.mdx @@ -0,0 +1,149 @@ +--- +title: Tổng quan +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +**Feature-Sliced Design** (FSD) là một phương pháp thiết kế kiến trúc để xây dựng các ứng dụng frontend. Nói đơn giản, đây là tập hợp các quy tắc và quy ước để tổ chức code. Mục đích chính của phương pháp này là làm cho dự án trở nên dễ hiểu và ổn định hơn khi đối mặt với những yêu cầu kinh doanh liên tục thay đổi. + +Ngoài tập hợp các quy ước, FSD còn là một bộ công cụ. Chúng tôi có [linter][ext-steiger] để kiểm tra kiến trúc dự án của bạn, [trình tạo thư mục][ext-tools] thông qua CLI hoặc IDE, cũng như thư viện phong phú các [ví dụ][examples]. + +## Có phù hợp với tôi không? \{#is-it-right-for-me} + +FSD có thể được triển khai trong các dự án và nhóm với bất kỳ quy mô nào. Nó phù hợp với dự án của bạn nếu: + +- Bạn đang làm **frontend** (UI trên web, mobile, desktop, v.v.) +- Bạn đang xây dựng một **ứng dụng**, không phải thư viện + +Vâng, chỉ đơn giản thế thôi! Không có ràng buộc nào về ngôn ngữ lập trình, UI framework, hoặc state manager bạn sử dụng. Bạn cũng có thể áp dụng FSD từng bước một, sử dụng nó trong monorepo, và mở rộng quy mô lớn bằng cách chia ứng dụng thành các package và triển khai FSD riêng lẻ trong từng package. + +Nếu bạn đã có một kiến trúc và đang cân nhắc chuyển sang FSD, hãy đảm bảo rằng kiến trúc hiện tại đang **gây ra vấn đề** trong nhóm của bạn. Ví dụ, nếu dự án của bạn đã trở nên quá lớn và liên kết chặt chẽ với nhau khiến việc triển khai tính năng mới trở nên kém hiệu quả, hoặc nếu bạn dự kiến có nhiều thành viên mới tham gia nhóm. Nếu kiến trúc hiện tại hoạt động tốt, có lẽ không đáng để thay đổi. Nhưng nếu bạn quyết định migrate, hãy xem phần [Migration][migration] để được hướng dẫn. + +## Ví dụ cơ bản \{#basic-example} + +Đây là một dự án đơn giản triển khai FSD: + + +- app/ +- pages/ +- shared/ + + +Những thư mục cấp cao này được gọi là _layer_. Hãy xem sâu hơn: + + +- app/ + - routes/ + - analytics/ +- pages/ + - home/ + - article-reader/ + - ui/ + - api/ + - settings/ +- shared/ + - ui/ + - api/ + + +Các thư mục bên trong `📂 pages` được gọi là _slice_. Chúng chia layer theo domain (trong trường hợp này, theo trang). + +Các thư mục bên trong `📂 app`, `📂 shared`, và `📂 pages/article-reader` được gọi là _segment_, và chúng chia các slice (hoặc layer) theo mục đích kỹ thuật, tức là code đó dùng để làm gì. + +## Các khái niệm \{#concepts} + +Layer, slice, và segment tạo thành một hệ thống phân cấp như sau: + +
+ ![Hierarchy of FSD concepts, described below](../../../../../../static/img/visual_schema.jpg) + +
+

Hình minh họa: ba cột, được gắn nhãn từ trái sang phải lần lượt là "Layers", "Slices", và "Segments".

+

Cột "Layers" chứa bảy phân chia được sắp xếp từ trên xuống dưới và được gắn nhãn "app", "processes", "pages", "widgets", "features", "entities", và "shared". Phân chia "processes" bị gạch ngang. Phân chia "entities" được kết nối với cột thứ hai "Slices" theo cách truyền đạt rằng cột thứ hai là nội dung của "entities".

+

Cột "Slices" chứa ba phân chia được sắp xếp từ trên xuống dưới và được gắn nhãn "user", "post", và "comment". Phân chia "post" được kết nối với cột thứ ba "Segments" theo cùng cách như vậy để nó là nội dung của "post".

+

Cột "Segments" chứa ba phân chia, được sắp xếp từ trên xuống dưới và được gắn nhãn "ui", "model", và "api".

+
+
+ +### Layer \{#layers} + +Các layer được tiêu chuẩn hóa trên tất cả các dự án FSD. Bạn không cần phải sử dụng tất cả các layer, nhưng tên của chúng rất quan trọng. Hiện tại có bảy layer (từ trên xuống dưới): + +1. **App** — mọi thứ khiến ứng dụng chạy được — routing, entrypoint, global style, provider. +2. **Processes** (deprecated) — các kịch bản phức tạp liên quan đến nhiều trang. +3. **Pages** — các trang đầy đủ hoặc các phần lớn của trang trong nested routing. +4. **Widget** — các khối chức năng hoặc UI lớn, tự chứa, thường cung cấp toàn bộ một use case. +5. **Feature** — các triển khai _tái sử dụng_ của toàn bộ tính năng sản phẩm, tức là các hành động mang lại giá trị kinh doanh cho người dùng. +6. **Entity** — các thực thể kinh doanh mà dự án làm việc với, như `user` hoặc `product`. +7. **Shared** — chức năng tái sử dụng, đặc biệt khi nó tách rời khỏi đặc điểm cụ thể của dự án/kinh doanh, mặc dù không nhất thiết. + + + +Điều thú vị với các layer là các module ở một layer chỉ có thể biết về và import từ các module từ các layer ở phía dưới một cách nghiêm ngặt. + +### Slice \{#slices} + +Tiếp theo là các slice, chúng phân chia code theo domain business. Bạn có thể tự do chọn bất kỳ tên nào cho chúng và tạo nhiều như bạn muốn. Các slice làm cho codebase của bạn dễ điều hướng hơn bằng cách giữ các module có liên quan logic gần nhau. + +Các slice không thể sử dụng slice khác trên cùng layer, và điều đó giúp với tính liên kết cao và khớp nối thấp. + +### Segment \{#segments} + +Các slice, cũng như các layer App và Shared, bao gồm các segment, và các segment nhóm code của bản theo mục đích của nó. Tên segment không bị ràng buộc bởi tiêu chuẩn, nhưng có một số tên quy ước cho các mục đích phổ biến nhất: + +- `ui` — mọi thứ liên quan đến hiển thị UI: UI component, date formatter, style, v.v. +- `api` — tương tác backend: request function, data type, mapper, v.v. +- `model` — model dữ liệu: schema, interface, store, và business logic. +- `lib` — library code mà các module khác trên slice này cần. +- `config` — file cấu hình và feature flag. + +Thường thì những segment này đủ cho hầu hết các layer, bạn chỉ tạo segment riêng của mình trong Shared hoặc App, nhưng đây không phải là quy tắc bắt buộc. + +## Ưu điểm \{#advantages} + +- **Tính thống nhất** + Vì cấu trúc được tiêu chuẩn hóa, các dự án trở nên thống nhất hơn, điều này làm cho việc onboard thành viên mới dễ dàng hơn cho nhóm. + +- **Ổn định trước các thay đổi và refactoring** + Một module trên một layer không thể sử dụng các module khác trên cùng layer, hoặc các layer ở trên. + Điều này cho phép bạn thực hiện các sửa đổi độc lập mà không có hậu quả không lường trước đối với phần còn lại của ứng dụng. + +- **Kiểm soát việc tái sử dụng logic** + Tùy thuộc vào layer, bạn có thể làm cho code rất có thể tái sử dụng hoặc rất cục bộ. + Điều này giữ sự cân bằng giữa việc tuân theo nguyên tắc **DRY** và tính thực tế. + +- **Định hướng vào nhu cầu kinh doanh và người dùng** + Ứng dụng được chia theo các domain kinh doanh và việc sử dụng ngôn ngữ kinh doanh được khuyến khích trong việc đặt tên, để bạn có thể thực hiện công việc sản phẩm hữu ích mà không cần hiểu đầy đủ tất cả các phần không liên quan khác của dự án. + +## Áp dụng từng bước \{#incremental-adoption} + +Nếu bạn có một codebase hiện có mà bạn muốn migrate sang FSD, chúng tôi đề xuất chiến lược sau. Chúng tôi thấy nó hữu ích trong kinh nghiệm migrate của chính mình. + +1. Bắt đầu bằng cách từ từ định hình các layer App và Shared từng module một để tạo nền tảng. + +2. Phân phối tất cả UI hiện có trên Widget và Page bằng cách sơ bộ, ngay cả khi chúng có dependency vi phạm các quy tắc của FSD. + +3. Bắt đầu từ từ giải quyết các vi phạm import và cũng trích xuất Entity và có thể cả Feature. + +Nên tránh thêm các entity lớn mới trong khi refactor hoặc chỉ refactor một số phần nhất định của dự án. + +## Bước tiếp theo \{#next-steps} + +- **Muốn nắm bắt tốt cách tư duy trong FSD?** Xem [Tutorial][tutorial]. +- **Bạn thích học từ ví dụ?** Chúng tôi có rất nhiều trong phần [Examples][examples]. +- **Bạn có câu hỏi?** Ghé thăm [Telegram chat][ext-telegram] của chúng tôi và nhận trợ giúp từ cộng đồng. + +[tutorial]: /docs/get-started/tutorial +[examples]: /examples +[migration]: /docs/guides/migration/from-custom +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[ext-telegram]: https://t.me/feature_sliced diff --git a/src/content/docs/vi/docs/get-started/tutorial.mdx b/src/content/docs/vi/docs/get-started/tutorial.mdx new file mode 100644 index 0000000000..84420c02f2 --- /dev/null +++ b/src/content/docs/vi/docs/get-started/tutorial.mdx @@ -0,0 +1,2266 @@ +--- +title: Tutorial +sidebar: + order: 2 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## Phần 1. Trên giấy + +Tutorial này sẽ xem xét một app thực tế, còn được biết đến với tên Conduit. Conduit là một bản clone cơ bản của [Medium](https://medium.com/) — nó cho phép bạn đọc và viết bài, cũng như bình luận trên các bài viết của người khác. + +![Conduit home page](/img/tutorial/realworld-feed-anonymous.jpg) + +Đây là một ứng dụng khá nhỏ, vì vậy chúng ta sẽ giữ nó đơn giản và tránh phân tách quá mức. Rất có thể toàn bộ ứng dụng sẽ chỉ cần ba layer: **App**, **Pages**, và **Shared**. Nếu không, chúng ta sẽ giới thiệu thêm các layer khi cần. Sẵn sàng chưa? + +### Bắt đầu bằng việc liệt kê các trang + +Nếu nhìn vào ảnh chụp màn hình ở trên, chúng ta có thể giả định ít nhất những trang sau: + +- Trang chủ (article feed) +- Đăng nhập và đăng ký +- Đọc article +- Chỉnh sửa article +- Xem profile người dùng +- Chỉnh sửa profile người dùng (user settings) + +Mỗi trang này sẽ trở thành một *slice* riêng trên *layer* Pages. Hãy nhớ lại từ phần tổng quan rằng slice chỉ đơn giản là các folder bên trong layer, và layer chỉ đơn giản là các folder có tên định sẵn như `pages`. + +Như vậy, folder Pages của chúng ta sẽ trông như thế này: + + +- pages/ + - feed/ + - sign-in/ + - article-read/ + - article-edit/ + - profile/ + - settings/ + +``` + +Sự khác biệt chính của Feature-Sliced Design so với cấu trúc code không được quy định là các pages không thể tham chiếu lẫn nhau. Nghĩa là, một page không thể import code từ trang khác. Điều này là do **quy tắc import trên các layer**: + +*Một module (file) trong một slice chỉ có thể import các slice khác khi chúng nằm trên các layer ở bên dưới.* + +Trong trường hợp này, một trang là một slice, vì vậy các module (file) bên trong trang này chỉ có thể tham chiếu code từ các layer bên dưới, không phải từ cùng layer Pages. + +### Nhìn kỹ hơn vào feed + +
+ ![Anonymous user's perspective](../../../../../../static/img/tutorial/realworld-feed-anonymous.jpg) +
+ _Từ góc nhìn của người dùng ẩn danh_ +
+
+ +
+ ![Authenticated user's perspective](../../../../../../static/img/tutorial/realworld-feed-authenticated.jpg) +
+ _Từ góc nhìn của người dùng đã xác thực_ +
+
+ +Có ba khu vực động trên trang feed: + +1. Các link đăng nhập với thông báo nếu bạn đã đăng nhập +2. Danh sách các tag kích hoạt việc lọc trong feed +3. Một/hai feed của các article, mỗi article có nút like + +Các link đăng nhập là một phần của header chung cho tất cả các trang, chúng ta sẽ xem xét nó riêng. + +#### Danh sách các tag + +Để xây dựng danh sách các tag, chúng ta cần lấy các tag có sẵn, render mỗi tag dưới dạng chip, và lưu trữ các tag đã chọn trong client-side storage. Những thao tác này thuộc các danh mục "tương tác API", "giao diện người dùng", và "lưu trữ". Trong Feature-Sliced Design, code được phân tách theo mục đích sử dụng *segment*. Segment là các folder trong slice, và chúng có thể có tên tùy ý để mô tả mục đích, nhưng một số mục đích rất phổ biến nên có quy ước cho một số tên segment nhất định: + +- 📂 `api/` cho các tương tác backend +- 📂 `ui/` cho code xử lý rendering và giao diện +- 📂 `model/` cho storage và business logic +- 📂 `config/` cho feature flag, biến môi trường và các hình thức cấu hình khác + +Chúng ta sẽ đặt code lấy tag vào `api`, component tag vào `ui`, và tương tác storage vào `model`. + +#### Các article + +Sử dụng cùng nguyên tắc nhóm, chúng ta có thể phân tách feed của các article thành ba segment tương tự: + +- 📂 `api/`: lấy các article được phân trang với số lượng like; thích một article +- 📂 `ui/`: + - danh sách tab có thể render thêm tab nếu có tag được chọn + - article riêng lẻ + - phân trang chức năng +- 📂 `model/`: client-side storage của các article hiện tại được tải và trang hiện tại (nếu cần) + +### Tái sử dụng code chung + +Hầu hết các trang có ý định rất khác nhau, nhưng một số thứ nhất định vẫn giữ nguyên trong toàn bộ app — ví dụ, UI kit tuân thủ design language, hoặc quy ước trên backend rằng mọi thứ được thực hiện bằng REST API với cùng phương thức xác thực. Vì các slice được thiết kế để tách biệt, việc tái sử dụng code được hỗ trợ bởi một layer thấp hơn, **Shared**. + +Shared khác với các layer khác ở chỗ nó chứa các segment, không phải slice. Theo cách này, layer Shared có thể được coi là sự kết hợp giữa một layer và một slice. + +Thông thường, code trong Shared không được lên kế hoạch trước, mà được trích xuất trong quá trình phát triển, vì chỉ trong quá trình phát triển mới rõ phần nào của code thực sự được chia sẻ. Tuy nhiên, vẫn hữu ích khi ghi nhớ loại code nào thuộc về Shared: + +- 📂 `ui/` — UI kit, giao diện thuần túy, không có business logic. Ví dụ, button, modal dialog, form input. +- 📂 `api/` — wrapper tiện lợi xung quanh các primitive tạo request (như `fetch()` trên Web) và, tùy chọn, các function để kích hoạt request cụ thể theo đặc tả backend. +- 📂 `config/` — phân tích biến môi trường +- 📂 `i18n/` — cấu hình hỗ trợ ngôn ngữ +- 📂 `router/` — routing primitive và route constant + +Đó chỉ là một vài ví dụ về tên segment trong Shared, nhưng bạn có thể bỏ qua bất kỳ segment nào hoặc tạo segment của riêng bạn. Điều quan trọng duy nhất cần nhớ khi tạo segment mới là tên segment nên mô tả **mục đích (tại sao), không phải bản chất (cái gì)**. Các tên như "components", "hooks", "modals" *không nên* được sử dụng vì chúng mô tả những file này là gì, nhưng không giúp điều hướng code bên trong. Điều này yêu cầu mọi người trong team phải đào sâu vào từng file trong những folder như vậy và cũng giữ code không liên quan gần nhau, dẫn đến việc refactoring ảnh hưởng đến các khu vực rộng lớn của code và do đó làm cho việc review code và testing khó khăn hơn. + +### Định nghĩa public API nghiêm ngặt + +Trong ngữ cảnh của Feature-Sliced Design, thuật ngữ *public API* đề cập đến một slice hoặc segment khai báo những gì có thể được import từ nó bởi các module khác trong dự án. Ví dụ, trong JavaScript đó có thể là file `index.js` re-export các object từ các file khác trong slice. Điều này cho phép tự do refactoring code bên trong slice miễn là hợp đồng với thế giới bên ngoài (tức là public API) vẫn giữ nguyên. + +Đối với layer Shared không có slice, thường thuận tiện hơn khi định nghĩa public API riêng cho mỗi segment thay vì định nghĩa một index duy nhất cho mọi thứ trong Shared. Điều này giữ các import từ Shared được tổ chức tự nhiên theo ý định. Đối với các layer khác có slice, ngược lại — thường thực tế hơn khi định nghĩa một index cho mỗi slice và để slice quyết định tập hợp segment riêng của nó mà thế giới bên ngoài không biết vì các layer khác thường có ít export hơn nhiều. + +Các slice/segment của chúng ta sẽ xuất hiện với nhau như sau: + + +- pages/ + - feed/ + - index + - sign-in/ + - index + - article-read/ + - index + - ... +- shared/ + - ui/ + - index + - api/ + - index + - ... + + +Bất cứ thứ gì bên trong các folder như `pages/feed` hoặc `shared/ui` chỉ được biết đến bởi những folder đó, và các file khác không nên dựa vào cấu trúc nội bộ của những folder này. + +### Khối UI lớn được tái sử dụng + +Trước đó chúng ta đã ghi chú để xem lại header xuất hiện trên mỗi trang. Xây dựng lại từ đầu trên mỗi trang sẽ không thực tế, vì vậy việc muốn tái sử dụng nó là điều tự nhiên. Chúng ta đã có Shared để hỗ trợ tái sử dụng code, tuy nhiên, có một lưu ý khi đặt các khối UI lớn trong Shared — layer Shared không được biết về bất kỳ layer nào ở trên. + +Giữa Shared và Pages có ba layer khác: Entities, Features, và Widgets. Một số dự án có thể có thứ gì đó trong những layer đó mà họ cần trong một khối có thể tái sử dụng lớn, và điều đó có nghĩa là chúng ta không thể đặt khối có thể tái sử dụng đó trong Shared, nếu không nó sẽ import từ các layer trên, điều này bị cấm. Đó là lúc layer Widgets xuất hiện. Nó được đặt phía trên Shared, Entities, và Features, vì vậy nó có thể sử dụng tất cả chúng. + +Trong trường hợp của chúng ta, header rất đơn giản — đó là logo tĩnh và điều hướng cấp cao nhất. Điều hướng cần thực hiện request đến API để xác định người dùng hiện tại có đăng nhập hay không, nhưng điều đó có thể được xử lý bằng một import đơn giản từ segment `api`. Do đó, chúng ta sẽ giữ header của mình trong Shared. + +### Nhìn kỹ vào trang có form + +Hãy cũng kiểm tra một trang được thiết kế để chỉnh sửa, không phải đọc. Ví dụ, trình soạn thảo article: + +![Conduit post editor](/img/tutorial/realworld-editor-authenticated.jpg) + +Nó trông đơn giản, nhưng chứa một số khía cạnh của phát triển ứng dụng mà chúng ta chưa khám phá — validation form, trạng thái lỗi, và data persistence. + +Nếu chúng ta xây dựng trang này, chúng ta sẽ lấy một số input và button từ Shared và ghép thành một form trong segment `ui` của trang này. Sau đó, trong segment `api`, chúng ta sẽ định nghĩa một mutation request để tạo article trên backend. + +Để validate request trước khi gửi, chúng ta cần một validation schema, và vị trí tốt cho nó là segment `model`, vì nó là data model. Ở đó chúng ta sẽ tạo ra các thông báo lỗi và hiển thị chúng bằng một component khác trong segment `ui`. + +Để cải thiện trải nghiệm người dùng, chúng ta cũng có thể persist các input để ngăn mất dữ liệu vô tình. Đây cũng là công việc của segment `model`. + +### Tóm tắt + +Chúng ta đã kiểm tra một số trang và phác thảo cấu trúc sơ bộ cho ứng dụng của mình: + +1. Layer Shared + 1. `ui` sẽ chứa UI kit có thể tái sử dụng của chúng ta + 2. `api` sẽ chứa các tương tác primitive với backend + 3. Phần còn lại sẽ được sắp xếp theo yêu cầu +2. Layer Pages — mỗi trang là một slice riêng biệt + 1. `ui` sẽ chứa chính trang đó và tất cả các phần của nó + 2. `api` sẽ chứa data fetching chuyên biệt hơn, sử dụng `shared/api` + 3. `model` có thể chứa client-side storage của dữ liệu mà chúng ta sẽ hiển thị + +Hãy bắt đầu xây dựng! + +## Phần 2. Trong code + +Bây giờ chúng ta đã có kế hoạch, hãy đưa nó vào thực hành. Chúng ta sẽ sử dụng React và [Remix](https://remix.run). + +Có một template sẵn sàng cho dự án này, clone nó từ GitHub để có được khởi đầu: [https://github.com/feature-sliced/tutorial-conduit/tree/clean](https://github.com/feature-sliced/tutorial-conduit/tree/clean). + +Cài đặt dependencies với `npm install` và khởi động development server với `npm run dev`. Mở [http://localhost:3000](http://localhost:3000) và bạn sẽ thấy một app trống. + +### Bố trí các trang + +Hãy bắt đầu bằng việc tạo các component trống cho tất cả các trang của chúng ta. Chạy lệnh sau trong dự án của bạn: + +```bash +npx fsd pages feed sign-in article-read article-edit profile settings --segments ui +``` + +Điều này sẽ tạo các folder như `pages/feed/ui/` và một file index, `pages/feed/index.ts`, cho mỗi trang. + +### Kết nối trang feed + +Hãy kết nối route gốc của ứng dụng với trang feed. Tạo một component, `FeedPage.tsx` trong `pages/feed/ui` và đặt nội dung sau vào đó: + + +```tsx title="pages/feed/ui/FeedPage.tsx" +export function FeedPage() { + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+
+ ); +} +``` + +Sau đó re-export component này trong public API của trang feed, file `pages/feed/index.ts`: + + + +```ts title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +``` + +Bây giờ kết nối nó với root route. Trong Remix, routing dựa trên file, và các file route được đặt trong folder `app/routes`, điều này phù hợp với Feature-Sliced Design. + +Sử dụng component `FeedPage` trong `app/routes/_index.tsx`: + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +Sau đó, nếu bạn chạy dev server và mở ứng dụng, bạn sẽ thấy banner của Conduit! + +![The banner of Conduit](/img/tutorial/conduit-banner.jpg) + +### API client + +Để giao tiếp với RealWorld backend, hãy tạo một API client tiện lợi trong Shared. Tạo hai segment, `api` cho client và `config` cho các biến như backend base URL: + +```bash +npx fsd shared --segments api config +``` + +Sau đó tạo `shared/config/backend.ts`: + +```tsx title="shared/config/backend.ts" +export { mockBackendUrl as backendBaseUrl } from "mocks/handlers"; +``` + +```tsx title="shared/config/index.ts" +export { backendBaseUrl } from "./backend"; +``` + +Vì dự án RealWorld tiện lợi cung cấp [đặc tả OpenAPI](https://github.com/gothinkster/realworld/blob/main/api/openapi.yml), chúng ta có thể tận dụng các type được tự động tạo cho client của mình. Chúng ta sẽ sử dụng [package `openapi-fetch`](https://openapi-ts.pages.dev/openapi-fetch/) đi kèm với type generator bổ sung. + +Chạy lệnh sau để tạo API typing cập nhật: + +```bash +npm run generate-api-types +``` + +Điều này sẽ tạo file `shared/api/v1.d.ts`. Chúng ta sẽ sử dụng file này để tạo typed API client trong `shared/api/client.ts`: + +```tsx title="shared/api/client.ts" +import createClient from "openapi-fetch"; + +import { backendBaseUrl } from "shared/config"; +import type { paths } from "./v1"; + +export const { GET, POST, PUT, DELETE } = createClient({ baseUrl: backendBaseUrl }); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; +``` + +### Dữ liệu thực trong feed + +Bây giờ chúng ta có thể tiến hành thêm các article vào feed, được lấy từ backend. Hãy bắt đầu bằng cách triển khai component preview article. + +Tạo `pages/feed/ui/ArticlePreview.tsx` với nội dung sau: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +export function ArticlePreview({ article }) { /* TODO */ } +``` + +Vì chúng ta viết bằng TypeScript, sẽ tốt nếu có một article object được type. Nếu chúng ta khám phá `v1.d.ts` được tạo, chúng ta có thể thấy rằng article object có sẵn thông qua `components["schemas"]["Article"]`. Vì vậy hãy tạo file với các data model của chúng ta trong Shared và export các model: + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; +``` + +Bây giờ chúng ta có thể quay lại component preview article và điền markup với dữ liệu. Cập nhật component với nội dung sau: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+ +
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +Button like hiện tại chưa làm gì cả, chúng ta sẽ sửa điều đó khi đến trang đọc article và triển khai tính năng thích. + +Bây giờ chúng ta có thể lấy các article và render ra một loạt các card này. Lấy dữ liệu trong Remix được thực hiện bằng *loader* — các function phía server lấy chính xác những gì trang cần. Loader tương tác với API thay mặt cho trang, vì vậy chúng ta sẽ đặt chúng trong segment `api` của trang: + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; + +import { GET } from "shared/api"; + +export const loader = async () => { + const { data: articles, error, response } = await GET("/articles"); + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return json({ articles }); +}; +``` + +Để kết nối nó với trang, chúng ta cần export nó với tên `loader` từ route file: + +```tsx title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +export { loader } from "./api/loader"; +``` + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export { loader } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +Và bước cuối cùng là render các card này trong feed. Cập nhật `FeedPage` của bạn với code sau: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+
+
+
+ ); +} +``` + +### Lọc theo tag + +Về các tag, công việc của chúng ta là lấy chúng từ backend và lưu trữ tag hiện tại được chọn. Chúng ta đã biết cách lấy — đó là một request khác từ loader. Chúng ta sẽ sử dụng function tiện lợi `promiseHash` từ package `remix-utils`, đã được cài đặt. + +Cập nhật file loader, `pages/feed/api/loader.ts`, với code sau: + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async () => { + return json( + await promiseHash({ + articles: throwAnyErrors(GET("/articles")), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +Bạn có thể nhận thấy rằng chúng ta đã trích xuất xử lý lỗi thành function generic `throwAnyErrors`. Nó trông khá hữu ích, vì vậy chúng ta có thể muốn tái sử dụng nó sau, nhưng hiện tại hãy chỉ để mắt đến nó. + +Bây giờ, đến danh sách các tag. Nó cần phải tương tác — nhấp vào tag sẽ làm cho tag đó được chọn. Theo quy ước Remix, chúng ta sẽ sử dụng URL search parameter làm storage cho tag đã chọn. Để trình duyệt lo về storage trong khi chúng ta tập trung vào những thứ quan trọng hơn. + +Cập nhật `pages/feed/ui/FeedPage.tsx` với code sau: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles, tags } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +Sau đó chúng ta cần sử dụng search parameter `tag` trong loader của chúng ta. Thay đổi function `loader` trong `pages/feed/api/loader.ts` thành như sau: + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { params: { query: { tag: selectedTag } } }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +Thế là xong, không cần segment `model`. Remix khá gọn gàng. + +### Phân trang + +Tương tự như vậy, chúng ta có thể triển khai phân trang. Hãy thoải mái thử tự làm hoặc chỉ copy code bên dưới. Dù sao cũng không ai phán xét bạn. + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const [searchParams] = useSearchParams(); + const { articles, tags } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} + +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +Vậy là cũng xong rồi. Còn có danh sách tab có thể được triển khai tương tự, nhưng hãy đợi cho đến khi chúng ta triển khai authentication. Nói về điều đó! + +### Authentication + +Authentication liên quan đến hai trang — một để đăng nhập và một để đăng ký. Chúng hầu như giống nhau, vì vậy hợp lý khi giữ chúng trong cùng một slice, `sign-in`, để chúng có thể tái sử dụng code nếu cần. + +Tạo `RegisterPage.tsx` trong segment `ui` của `pages/sign-in` với nội dung sau: + +```tsx title="pages/sign-in/ui/RegisterPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { register } from "../api/register"; + +export function RegisterPage() { + const registerData = useActionData(); + + return ( +
+
+
+
+

Sign up

+

+ Have an account? +

+ + {registerData?.error && ( +
    + {registerData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +Chúng ta có một import bị lỗi cần sửa bây giờ. Nó liên quan đến một segment mới, vì vậy hãy tạo nó: + +```bash +npx fsd pages sign-in -s api +``` + +Tuy nhiên, trước khi chúng ta có thể triển khai phần backend của đăng ký, chúng ta cần một số code infrastructure để Remix xử lý session. Điều đó thuộc về Shared, phòng khi trang nào khác cần nó. + +Đặt code sau vào `shared/api/auth.server.ts`. Đây là code rất cụ thể cho Remix, vì vậy đừng lo lắng quá nhiều về nó, chỉ cần copy-paste: + +```tsx title="shared/api/auth.server.ts" +import { createCookieSessionStorage, redirect } from "@remix-run/node"; +import invariant from "tiny-invariant"; + +import type { User } from "./models"; + +invariant( + process.env.SESSION_SECRET, + "SESSION_SECRET must be set for authentication to work", +); + +const sessionStorage = createCookieSessionStorage<{ + user: User; +}>({ + cookie: { + name: "__session", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [process.env.SESSION_SECRET], + secure: process.env.NODE_ENV === "production", + }, +}); + +export async function createUserSession({ + request, + user, + redirectTo, +}: { + request: Request; + user: User; + redirectTo: string; +}) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.set("user", user); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 7, // 7 days + }), + }, + }); +} + +export async function getUserFromSession(request: Request) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + return session.get("user") ?? null; +} + +export async function requireUser(request: Request) { + const user = await getUserFromSession(request); + + if (user === null) { + throw redirect("/login"); + } + + return user; +} +``` + +Và cũng export model `User` từ file `models.ts` ngay cạnh nó: + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +export type User = components["schemas"]["User"]; +``` + +Trước khi code này có thể hoạt động, biến môi trường `SESSION_SECRET` cần được đặt. Tạo file tên `.env` trong thư mục gốc của dự án, viết `SESSION_SECRET=` và sau đó bấm một số phím trên bàn phím để tạo chuỗi ngẫu nhiên dài. Bạn sẽ có thứ gì đó như thế này: + +```bash title=".env" +SESSION_SECRET=dontyoudarecopypastethis +``` + +Cuối cùng, thêm một số export vào public API để sử dụng code này: + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +``` + +Bây giờ chúng ta có thể viết code sẽ giao tiếp với RealWorld backend để thực sự thực hiện đăng ký. Chúng ta sẽ giữ điều đó trong `pages/sign-in/api`. Tạo file có tên `register.ts` và đặt code sau vào bên trong: + +```tsx title="pages/sign-in/api/register.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const register = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const username = formData.get("username")?.toString() ?? ""; + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users", { + body: { user: { email, password, username } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +``` + +Gần xong rồi! Chỉ cần kết nối trang và action với route `/register`. Tạo `register.tsx` trong `app/routes`: + +```tsx title="app/routes/register.tsx" +import { RegisterPage, register } from "pages/sign-in"; + +export { register as action }; + +export default RegisterPage; +``` + +Bây giờ nếu bạn đi đến [http://localhost:3000/register](http://localhost:3000/register), bạn sẽ có thể tạo người dùng! Phần còn lại của ứng dụng sẽ chưa phản ứng với điều này, chúng ta sẽ giải quyết điều đó ngay. + +Tương tự như vậy, chúng ta có thể triển khai trang đăng nhập. Hãy thử hoặc chỉ lấy code và tiếp tục: + +```tsx title="pages/sign-in/api/sign-in.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const signIn = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users/login", { + body: { user: { email, password } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/ui/SignInPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { signIn } from "../api/sign-in"; + +export function SignInPage() { + const signInData = useActionData(); + + return ( +
+
+
+
+

Sign in

+

+ Need an account? +

+ + {signInData?.error && ( +
    + {signInData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +export { SignInPage } from './ui/SignInPage'; +export { signIn } from './api/sign-in'; +``` + +```tsx title="app/routes/login.tsx" +import { SignInPage, signIn } from "pages/sign-in"; + +export { signIn as action }; + +export default SignInPage; +``` + +Bây giờ hãy cung cấp cho người dùng cách thực sự đến các trang này. + +### Header + +Như chúng ta đã thảo luận trong phần 1, header app thường được đặt trong Widgets hoặc trong Shared. Chúng ta sẽ đặt nó trong Shared vì nó rất đơn giản và tất cả business logic có thể được giữ bên ngoài nó. Hãy tạo chỗ cho nó: + +```bash +npx fsd shared ui +``` + +Bây giờ tạo `shared/ui/Header.tsx` với nội dung sau: + +```tsx title="shared/ui/Header.tsx" +import { useContext } from "react"; +import { Link, useLocation } from "@remix-run/react"; + +import { CurrentUser } from "../api/currentUser"; + +export function Header() { + const currentUser = useContext(CurrentUser); + const { pathname } = useLocation(); + + return ( + + ); +} +``` + +Export component này từ `shared/ui`: + +```tsx title="shared/ui/index.ts" +export { Header } from "./Header"; +``` + +Trong header, chúng ta dựa vào context được giữ trong `shared/api`. Cũng tạo điều đó: + +```tsx title="shared/api/currentUser.ts" +import { createContext } from "react"; + +import type { User } from "./models"; + +export const CurrentUser = createContext(null); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +export { CurrentUser } from "./currentUser"; +``` + +Bây giờ hãy thêm header vào trang. Chúng ta muốn nó có trên mọi trang, vì vậy hợp lý khi chỉ thêm nó vào root route và wrap outlet (nơi trang sẽ được render) với provider context `CurrentUser`. Theo cách này, toàn bộ app của chúng ta và cả header đều có quyền truy cập vào object người dùng hiện tại. Chúng ta cũng sẽ thêm loader để thực sự lấy object người dùng hiện tại từ cookie. Đặt nội dung sau vào `app/root.tsx`: + +```tsx title="app/root.tsx" +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from "@remix-run/react"; + +import { Header } from "shared/ui"; +import { getUserFromSession, CurrentUser } from "shared/api"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export const loader = ({ request }: LoaderFunctionArgs) => + getUserFromSession(request); + +export default function App() { + const user = useLoaderData(); + + return ( + + + + + + + + + + + + + +
+ + + + + + + + ); +} +``` + +Tại thời điểm này, bạn sẽ có kết quả sau trên trang chủ: + +
+ ![The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing.](../../../../../../static/img/tutorial/realworld-feed-without-tabs.jpg) + +
Trang feed của Conduit, bao gồm header, feed, và các tag. Các tab vẫn còn thiếu.
+
+ +### Tab + +Bây giờ chúng ta có thể phát hiện trạng thái authentication, hãy cũng nhanh chóng triển khai các tab và like bài viết để hoàn thành trang feed. Chúng ta cần một form khác, nhưng file trang này đang trở nên khá lớn, vì vậy hãy chuyển những form này vào các file liền kề. Chúng ta sẽ tạo `Tabs.tsx`, `PopularTags.tsx`, và `Pagination.tsx` với nội dung sau: + +```tsx title="pages/feed/ui/Tabs.tsx" +import { useContext } from "react"; +import { Form, useSearchParams } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; + +export function Tabs() { + const [searchParams] = useSearchParams(); + const currentUser = useContext(CurrentUser); + + return ( +
+
+
    + {currentUser !== null && ( +
  • + +
  • + )} +
  • + +
  • + {searchParams.has("tag") && ( +
  • + + {searchParams.get("tag")} + +
  • + )} +
+
+
+ ); +} +``` + +```tsx title="pages/feed/ui/PopularTags.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; + +export function PopularTags() { + const { tags } = useLoaderData(); + + return ( +
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+ ); +} +``` + +```tsx title="pages/feed/ui/Pagination.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; + +export function Pagination() { + const [searchParams] = useSearchParams(); + const { articles } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ + ); +} +``` + +Và bây giờ chúng ta có thể đơn giản hóa đáng kể chính trang feed: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; +import { Tabs } from "./Tabs"; +import { PopularTags } from "./PopularTags"; +import { Pagination } from "./Pagination"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ + + {articles.articles.map((article) => ( + + ))} + + +
+ +
+ +
+
+
+
+ ); +} +``` + +Chúng ta cũng cần tính đến tab mới trong function loader: + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + /* unchanged */ +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + if (url.searchParams.get("source") === "my-feed") { + const userSession = await requireUser(request); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles/feed", { + params: { + query: { + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + headers: { Authorization: `Token ${userSession.token}` }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); + } + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +Trước khi rời trang feed, hãy thêm một số code xử lý like cho bài viết. Thay đổi `ArticlePreview.tsx` của bạn thành như sau: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Form, Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+
+ +
+
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +Code này sẽ gửi POST request đến `/article/:slug` với `_action=favorite` để đánh dấu article là yêu thích. Nó chưa hoạt động, nhưng khi chúng ta bắt đầu làm việc trên trình đọc article, chúng ta cũng sẽ triển khai điều này. + +Và với điều đó, chúng ta đã chính thức hoàn thành feed! Yay! + +### Trình đọc article + +Trước tiên, chúng ta cần dữ liệu. Hãy tạo một loader: + +```bash +npx fsd pages article-read -s api +``` + +```tsx title="pages/article-read/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import invariant from "tiny-invariant"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, getUserFromSession } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + invariant(params.slug, "Expected a slug parameter"); + const currentUser = await getUserFromSession(request); + const authorization = currentUser + ? { Authorization: `Token ${currentUser.token}` } + : undefined; + + return json( + await promiseHash({ + article: throwAnyErrors( + GET("/articles/{slug}", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + comments: throwAnyErrors( + GET("/articles/{slug}/comments", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + }), + ); +}; +``` + +```tsx title="pages/article-read/index.ts" +export { loader } from "./api/loader"; +``` + +Bây giờ chúng ta có thể kết nối nó với route `/article/:slug` bằng cách tạo file route có tên `article.$slug.tsx`: + +```tsx title="app/routes/article.$slug.tsx" +export { loader } from "pages/article-read"; +``` + +Chính trang bao gồm ba khối chính — header article với các action (lặp lại hai lần), nội dung article, và phần comment. Đây là markup cho trang, nó không đặc biệt thú vị: + +```tsx title="pages/article-read/ui/ArticleReadPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticleMeta } from "./ArticleMeta"; +import { Comments } from "./Comments"; + +export function ArticleReadPage() { + const { article } = useLoaderData(); + + return ( +
+
+
+

{article.article.title}

+ + +
+
+ +
+
+
+

{article.article.body}

+
    + {article.article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} +``` + +Điều thú vị hơn là `ArticleMeta` và `Comments`. Chúng chứa các thao tác ghi như thích article, để lại comment, v.v. Để chúng hoạt động, trước tiên chúng ta cần triển khai phần backend. Tạo `action.ts` trong segment `api` của trang: + +```tsx title="pages/article-read/api/action.ts" +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { namedAction } from "remix-utils/named-action"; +import { redirectBack } from "remix-utils/redirect-back"; +import invariant from "tiny-invariant"; + +import { DELETE, POST, requireUser } from "shared/api"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const currentUser = await requireUser(request); + + const authorization = { Authorization: `Token ${currentUser.token}` }; + + const formData = await request.formData(); + + return namedAction(formData, { + async delete() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirect("/"); + }, + async favorite() { + invariant(params.slug, "Expected a slug parameter"); + await POST("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfavorite() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async createComment() { + invariant(params.slug, "Expected a slug parameter"); + const comment = formData.get("comment"); + invariant(typeof comment === "string", "Expected a comment parameter"); + await POST("/articles/{slug}/comments", { + params: { path: { slug: params.slug } }, + headers: { ...authorization, "Content-Type": "application/json" }, + body: { comment: { body: comment } }, + }); + return redirectBack(request, { fallback: "/" }); + }, + async deleteComment() { + invariant(params.slug, "Expected a slug parameter"); + const commentId = formData.get("id"); + invariant(typeof commentId === "string", "Expected an id parameter"); + const commentIdNumeric = parseInt(commentId, 10); + invariant( + !Number.isNaN(commentIdNumeric), + "Expected a numeric id parameter", + ); + await DELETE("/articles/{slug}/comments/{id}", { + params: { path: { slug: params.slug, id: commentIdNumeric } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async followAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await POST("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfollowAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await DELETE("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + }); +}; +``` + +Export điều đó từ slice và sau đó từ route. Trong khi làm điều đó, hãy cũng kết nối chính trang: + +```tsx title="pages/article-read/index.ts" +export { ArticleReadPage } from "./ui/ArticleReadPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/article.$slug.tsx" +import { ArticleReadPage } from "pages/article-read"; + +export { loader, action } from "pages/article-read"; + +export default ArticleReadPage; +``` + +Bây giờ, mặc dù chúng ta chưa triển khai button like trên trang đọc, button like trong feed sẽ bắt đầu hoạt động! Đó là vì nó đã gửi request "like" đến route này. Hãy thử điều đó. + +`ArticleMeta` và `Comments`, một lần nữa, là một loạt form. Chúng ta đã làm điều này trước đây, hãy lấy code của chúng và tiếp tục: + +```tsx title="pages/article-read/ui/ArticleMeta.tsx" +import { Form, Link, useLoaderData } from "@remix-run/react"; +import { useContext } from "react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function ArticleMeta() { + const currentUser = useContext(CurrentUser); + const { article } = useLoaderData(); + + return ( +
+
+ + + + +
+ + {article.article.author.username} + + {article.article.createdAt} +
+ + {article.article.author.username == currentUser?.username ? ( + <> + + Edit Article + +    + + + ) : ( + <> + + +    + + + )} +
+
+ ); +} +``` + +```tsx title="pages/article-read/ui/Comments.tsx" +import { useContext } from "react"; +import { Form, Link, useLoaderData } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function Comments() { + const { comments } = useLoaderData(); + const currentUser = useContext(CurrentUser); + + return ( +
+ {currentUser !== null ? ( +
+
+ +
+
+ + +
+
+ ) : ( +
+
+

+ Sign in +   or   + Sign up +   to add comments on this article. +

+
+
+ )} + + {comments.comments.map((comment) => ( +
+
+

{comment.body}

+
+ +
+ + + +   + + {comment.author.username} + + {comment.createdAt} + {comment.author.username === currentUser?.username && ( + +
+ + +
+
+ )} +
+
+ ))} +
+ ); +} +``` + +Và với điều đó, trình đọc article của chúng ta cũng hoàn thành! Các button theo dõi tác giả, thích bài viết, và để lại comment giờ đây sẽ hoạt động như mong đợi. + +
+ ![Article reader with functioning buttons to like and follow](../../../../../../static/img/tutorial/realworld-article-reader.jpg) + +
Trình đọc article với các button hoạt động để thích và theo dõi
+
+ +### Trình chỉnh sửa article + +Đây là trang cuối cùng mà chúng ta sẽ đề cập trong tutorial này, và phần thú vị nhất ở đây là cách chúng ta sẽ validate dữ liệu form. + +Chính trang, `article-edit/ui/ArticleEditPage.tsx`, sẽ khá đơn giản, độ phức tạp bổ sung được cất giấu trong hai component khác: + +```tsx title="pages/article-edit/ui/ArticleEditPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { TagsInput } from "./TagsInput"; +import { FormErrors } from "./FormErrors"; + +export function ArticleEditPage() { + const article = useLoaderData(); + + return ( +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+
+
+
+
+ ); +} +``` + +Trang này lấy article hiện tại (trừ khi chúng ta viết từ đầu) và điền vào các trường form tương ứng. Chúng ta đã thấy điều này trước đây. Phần thú vị là `FormErrors`, vì nó sẽ nhận kết quả validation và hiển thị cho người dùng. Hãy xem: + +```tsx title="pages/article-edit/ui/FormErrors.tsx" +import { useActionData } from "@remix-run/react"; +import type { action } from "../api/action"; + +export function FormErrors() { + const actionData = useActionData(); + + return actionData?.errors != null ? ( +
    + {actionData.errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null; +} +``` + +Ở đây chúng ta giả định rằng action của chúng ta sẽ trả về trường `errors`, một mảng các thông báo lỗi có thể đọc được. Chúng ta sẽ đến action ngay. + +Component khác là input tag. Nó chỉ là trường input đơn giản với preview bổ sung của các tag đã chọn. Không có gì nhiều để xem ở đây: + +```tsx title="pages/article-edit/ui/TagsInput.tsx" +import { useEffect, useRef, useState } from "react"; + +export function TagsInput({ + name, + defaultValue, +}: { + name: string; + defaultValue?: Array; +}) { + const [tagListState, setTagListState] = useState(defaultValue ?? []); + + function removeTag(tag: string): void { + const newTagList = tagListState.filter((t) => t !== tag); + setTagListState(newTagList); + } + + const tagsInput = useRef(null); + useEffect(() => { + tagsInput.current && (tagsInput.current.value = tagListState.join(",")); + }, [tagListState]); + + return ( + <> + + setTagListState(e.target.value.split(",").filter(Boolean)) + } + /> +
+ {tagListState.map((tag) => ( + + + [" ", "Enter"].includes(e.key) && removeTag(tag) + } + onClick={() => removeTag(tag)} + >{" "} + {tag} + + ))} +
+ + ); +} +``` + +Bây giờ, cho phần API. Loader nên nhìn vào URL, và nếu nó chứa article slug, có nghĩa là chúng ta đang chỉnh sửa article hiện có, và dữ liệu của nó nên được tải. Nếu không, trả về không có gì. Hãy tạo loader đó: + +```ts title="pages/article-edit/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const currentUser = await requireUser(request); + + if (!params.slug) { + return { article: null }; + } + + return throwAnyErrors( + GET("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: { Authorization: `Token ${currentUser.token}` }, + }), + ); +}; +``` + +Action sẽ lấy các giá trị trường mới, chạy chúng qua data schema của chúng ta, và nếu mọi thứ đều đúng, commit những thay đổi đó đến backend, hoặc bằng cách cập nhật article hiện có hoặc tạo một article mới: + +```tsx title="pages/article-edit/api/action.ts" +import { json, redirect, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, PUT, requireUser } from "shared/api"; +import { parseAsArticle } from "../model/parseAsArticle"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + try { + const { body, description, title, tags } = parseAsArticle( + await request.formData(), + ); + const tagList = tags?.split(",") ?? []; + + const currentUser = await requireUser(request); + const payload = { + body: { + article: { + title, + description, + body, + tagList, + }, + }, + headers: { Authorization: `Token ${currentUser.token}` }, + }; + + const { data, error } = await (params.slug + ? PUT("/articles/{slug}", { + params: { path: { slug: params.slug } }, + ...payload, + }) + : POST("/articles", payload)); + + if (error) { + return json({ errors: error }, { status: 422 }); + } + + return redirect(`/article/${data.article.slug ?? ""}`); + } catch (errors) { + return json({ errors }, { status: 400 }); + } +}; +``` + +Schema có vai trò kép như một function phân tích cho `FormData`, cho phép chúng ta thuận tiện lấy các trường sạch hoặc chỉ throw lỗi để xử lý ở cuối. Đây là cách function phân tích đó có thể trông như thế nào: + +```tsx title="pages/article-edit/model/parseAsArticle.ts" +export function parseAsArticle(data: FormData) { + const errors = []; + + const title = data.get("title"); + if (typeof title !== "string" || title === "") { + errors.push("Give this article a title"); + } + + const description = data.get("description"); + if (typeof description !== "string" || description === "") { + errors.push("Describe what this article is about"); + } + + const body = data.get("body"); + if (typeof body !== "string" || body === "") { + errors.push("Write the article itself"); + } + + const tags = data.get("tags"); + if (typeof tags !== "string") { + errors.push("The tags must be a string"); + } + + if (errors.length > 0) { + throw errors; + } + + return { title, description, body, tags: data.get("tags") ?? "" } as { + title: string; + description: string; + body: string; + tags: string; + }; +} +``` + +Có thể nói, nó hơi dài và lặp lại, nhưng đó là cái giá chúng ta phải trả cho các lỗi có thể đọc được. Điều này cũng có thể là Zod schema, ví dụ, nhưng sau đó chúng ta sẽ phải render thông báo lỗi trên frontend, và form này không đáng để phức tạp hóa. + +Một bước cuối cùng — kết nối trang, loader, và action với các route. Vì chúng ta hỗ trợ gọn gàng cả tạo và chỉnh sửa, chúng ta có thể export cùng một thứ từ cả `editor._index.tsx` và `editor.$slug.tsx`: + +```tsx title="pages/article-edit/index.ts" +export { ArticleEditPage } from "./ui/ArticleEditPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/editor._index.tsx, app/routes/editor.$slug.tsx (same content)" +import { ArticleEditPage } from "pages/article-edit"; + +export { loader, action } from "pages/article-edit"; + +export default ArticleEditPage; +``` + +Chúng ta hoàn thành rồi! Đăng nhập và thử tạo article mới. Hoặc "quên" viết article và xem validation hoạt động. + +
+ ![The Conduit article editor, with the title field saying "New article" and the rest of the fields empty. Above the form there are two errors: "**Describe what this article is about" and "Write the article itself".**](../../../../../../static/img/tutorial/realworld-article-editor.jpg) + +
Trình chỉnh sửa article Conduit, với trường tiêu đề nói "New article" và phần còn lại của các trường trống. Phía trên form có hai lỗi: **"Describe what this article is about"** và **"Write the article itself"**.
+
+ +Các trang profile và settings rất giống với trình đọc và chỉnh sửa article, chúng được để lại như bài tập cho người đọc, đó là bạn :) diff --git a/src/content/docs/vi/docs/guides/examples/api-requests.mdx b/src/content/docs/vi/docs/guides/examples/api-requests.mdx new file mode 100644 index 0000000000..457e4b5f18 --- /dev/null +++ b/src/content/docs/vi/docs/guides/examples/api-requests.mdx @@ -0,0 +1,144 @@ +--- +title: Xử lý API Requests +sidebar: + order: 4 +--- + +import { FileTree, Aside, Tabs, TabItem } from '@astrojs/starlight/components'; + +## Shared API Requests \{#shared-api-requests} + +Bắt đầu bằng cách đặt logic API request chung trong thư mục `shared/api`. Điều này giúp dễ dàng tái sử dụng các request trong toàn bộ ứng dụng và hỗ trợ prototyping nhanh hơn. Đối với nhiều dự án, đây là tất cả những gì bạn cần cho các API call. + +Cấu trúc file điển hình sẽ là: + + +- shared/ + - client.ts + - index.ts + - endpoints/ + - login.ts + + +File `client.ts` tập trung thiết lập HTTP request của bạn. Nó bao bọc phương thức bạn chọn (như `fetch()` hoặc một instance `axios`) và xử lý các cấu hình chung, chẳng hạn như: + +- Backend base URL. +- Default headers (ví dụ, cho authentication). +- Data serialization. + +Dưới đây là các ví dụ cho `axios` và `fetch`: + + + + +```ts title="shared/api/client.ts" +// Example using axios +import axios from 'axios'; + +export const client = axios.create({ + baseURL: 'https://your-api-domain.com/api/', + timeout: 5000, + headers: { 'X-Custom-Header': 'my-custom-value' } +}); +``` + + + +```ts title="shared/api/client.ts" +export const client = { + async post(endpoint: string, body: any, options?: RequestInit) { + const response = await fetch(`https://your-api-domain.com/api${endpoint}`, { + method: 'POST', + body: JSON.stringify(body), + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'my-custom-value', + ...options?.headers, + }, + }); + return response.json(); + } + // ... other methods like put, delete, etc. +}; +``` + + + + +Tổ chức các function API request riêng lẻ của bạn trong `shared/api/endpoints`, nhóm chúng theo API endpoint. + + + +```ts title="shared/api/endpoints/login.ts" +import { client } from '../client'; + +export interface LoginCredentials { + email: string; + password: string; +} + +export function login(credentials: LoginCredentials) { + return client.post('/login', credentials); +} +``` +Sử dụng file `index.ts` trong `shared/api` để export các function request của bạn. + +```ts title="shared/api/index.ts" +export { client } from './client'; // Nếu bạn muốn export client +export { login } from './endpoints/login'; +export type { LoginCredentials } from './endpoints/login'; +``` + +## Slice-Specific API Requests \{#slice-specific-api-requests} + +Nếu một API request chỉ được sử dụng bởi một slice cụ thể (như một page hoặc feature đơn lẻ) và sẽ không được tái sử dụng, hãy đặt nó trong segment api của slice đó. Điều này giúp logic slice-specific được chứa đóng một cách gọn gàng. + + +- pages/ + - login/ + - index.ts + - api/ + - login.ts + - ui/ + - LoginPage.tsx + + +```ts title="pages/login/api/login.ts" +import { client } from 'shared/api'; + +interface LoginCredentials { + email: string; + password: string; +} + +export function login(credentials: LoginCredentials) { + return client.post('/login', credentials); +} +``` + +Bạn không cần export function `login()` trong public API của page, vì không có khả năng nơi nào khác trong app sẽ cần request này. + + + +## Sử dụng Client Generators \{#client-generators} + +Nếu backend của bạn có OpenAPI specification, các công cụ như [orval](https://orval.dev/) hoặc [openapi-typescript](https://openapi-ts.dev/) có thể generate API types và request functions cho bạn. Đặt code được generate trong, ví dụ, `shared/api/openapi`. Đảm bảo bao gồm `README.md` để document những types đó là gì và cách generate chúng. + +## Tích hợp với Server State Libraries \{#server-state-libraries} + +Khi sử dụng server state libraries như [TanStack Query (React Query)](https://tanstack.com/query/latest) hoặc [Pinia Colada](https://pinia-colada.esm.dev/), bạn có thể cần chia sẻ types hoặc cache keys giữa các slices. Sử dụng layer `shared` cho những thứ như: + +- API data types +- Cache keys +- Common query/mutation options + +Để biết thêm chi tiết về cách làm việc với server state libraries, hãy tham khảo [bài viết React Query](/docs/guides/tech/with-react-query) diff --git a/src/content/docs/vi/docs/guides/examples/auth.mdx b/src/content/docs/vi/docs/guides/examples/auth.mdx new file mode 100644 index 0000000000..d5040e2f65 --- /dev/null +++ b/src/content/docs/vi/docs/guides/examples/auth.mdx @@ -0,0 +1,178 @@ +--- +title: Authentication +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Nói chung, authentication bao gồm các bước sau: + +1. Lấy credentials từ người dùng +1. Gửi chúng đến backend +1. Lưu trữ token để thực hiện các authenticated requests + +## Cách lấy credentials từ người dùng + +Chúng tôi giả định rằng app của bạn chịu trách nhiệm lấy credentials. Nếu bạn có authentication qua OAuth, bạn có thể đơn giản tạo một login page với link đến login page của OAuth provider và bỏ qua đến [bước 3](#how-to-store-the-token-for-authenticated-requests). + +### Page chuyên dụng cho login + +Thông thường, các websites có các pages chuyên dụng cho login, nơi bạn nhập username và password. Các pages này khá đơn giản, vì vậy chúng không yêu cầu decomposition. Login và registration forms khá giống nhau về ngoại hình, vì vậy chúng thậm chí có thể được nhóm vào một page. Tạo một slice cho login/registration page của bạn trên layer Pages: + + +- pages/ + - login/ + - ui/ + - LoginPage.tsx + - RegisterPage.tsx + - index.ts + - ... + + +Ở đây chúng tôi tạo hai components và export cả hai trong index file của slice. Các components này sẽ chứa forms chịu trách nhiệm trình bày cho người dùng các controls dễ hiểu để lấy credentials của họ. + +### Dialog cho login + +Nếu app của bạn có dialog cho login có thể được sử dụng trên bất kỳ page nào, hãy cân nhắc tạo dialog đó thành một widget. Bằng cách đó, bạn vẫn có thể tránh quá nhiều decomposition, nhưng có tự do tái sử dụng dialog này trên bất kỳ page nào. + + +- widgets/ + - login-dialog/ + - ui/ + - LoginDialog.tsx + - index.ts + - ... + + +Phần còn lại của hướng dẫn này được viết cho cách tiếp cận dedicated page, nhưng các nguyên tắc tương tự áp dụng cho dialog widget. + +### Client-side validation + +Thiệng thoảng, đặc biệt là cho registration, việc thực hiện client-side validation để cho người dùng biết nhanh chóng rằng họ đã mắc lỗi là hợp lý. Validation có thể diễn ra trong segment `model` của login page. Sử dụng một schema validation library, ví dụ, [Zod][ext-zod] cho JS/TS, và expose schema đó cho segment `ui`: + +```ts title="pages/login/model/registration-schema.ts" +import { z } from "zod"; + +export const registrationData = z.object({ + email: z.string().email(), + password: z.string().min(6), + confirmPassword: z.string(), +}).refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], +}); +``` + +Sau đó, trong segment `ui`, bạn có thể sử dụng schema này để validate user input: + +```tsx title="pages/login/ui/RegisterPage.tsx" +import { registrationData } from "../model/registration-schema"; + +function validate(formData: FormData) { + const data = Object.fromEntries(formData.entries()); + try { + registrationData.parse(data); + } catch (error) { + // TODO: Show error message to the user + } +} + +export function RegisterPage() { + return ( +
validate(new FormData(e.target))}> + + + + + + + + +
+ ) +} +``` + +## Cách gửi credentials đến backend + +Tạo một function thực hiện request đến login endpoint của backend. Function này có thể được gọi trực tiếp trong component code sử dụng mutation library (ví dụ TanStack Query), hoặc có thể được gọi như side effect trong state manager. Như được giải thích trong [hướng dẫn cho API requests][examples-api-requests], bạn có thể đặt request của mình trong `shared/api` hoặc trong segment `api` của login page. + +### Two-factor authentication + +Nếu app của bạn hỗ trợ two-factor authentication (2FA), bạn có thể phải redirect đến page khác nơi người dùng có thể nhập one-time password. Thông thường `POST /login` request của bạn sẽ trả về user object với flag chỉ ra rằng người dùng đã bật 2FA. Nếu flag đó được thiết lập, redirect người dùng đến 2FA page. + +Vì page này liên quan rất chặt chẽ đến logging in, bạn cũng có thể giữ nó trong cùng một slice, `login` trên layer Pages. + +Bạn cũng cần một request function khác, tương tự như `login()` mà chúng tôi đã tạo ở trên. Đặt chúng cùng nhau, hoặc trong Shared, hoặc trong segment `api` của page `login`. + +## Cách lưu trữ token cho authenticated requests \{#how-to-store-the-token-for-authenticated-requests} + +Bất kể authentication scheme nào bạn có, cho dù là login & password đơn giản, OAuth, hoặc two-factor authentication, cuối cùng bạn sẽ nhận được một token. Token này nên được lưu trữ để các requests tiếp theo có thể identify chính chúng. + +Lưu trữ token lý tưởng cho web app là **cookie** — nó không yêu cầu token storage hoặc handling thủ công. Vì vậy, cookie storage hầu như không cần cân nhắc gì từ phía frontend architecture. Nếu frontend framework của bạn có server side (ví dụ, [Remix][ext-remix]), thì bạn nên lưu trữ server-side cookie infrastructure trong `shared/api`. Có một ví dụ trong [phần Authentication của tutorial][tutorial-authentication] về cách thực hiện điều đó với Remix. + +Tuy nhiên, đôi khi cookie storage không phải là lựa chọn. Trong trường hợp này, bạn sẽ phải lưu trữ token thủ công. Ngoài việc lưu trữ token, bạn cũng có thể cần thiết lập logic để refresh token khi nó expires. Với FSD, có nhiều nơi bạn có thể lưu trữ token, cũng như nhiều cách để làm cho nó available cho phần còn lại của app. + +### Trong Shared + +Cách tiếp cận này hoạt động tốt với API client được define trong `shared/api` vì token có sẵn một cách tự do cho các request functions khác yêu cầu authentication để thành công. Bạn có thể làm cho API client giữ state, hoặc với reactive store hoặc đơn giản là module-level variable, và cập nhật state đó trong các functions `login()`/`logout()` của bạn. + +Automatic token refresh có thể được implement như middleware trong API client — thứ gì đó có thể thực thi mỗi khi bạn thực hiện bất kỳ request nào. Nó có thể hoạt động như thế này: + +- Authenticate và lưu trữ access token cũng như refresh token +- Thực hiện bất kỳ request nào yêu cầu authentication +- Nếu request thất bại với status code chỉ ra token expiration, và có token trong store, thực hiện refresh request, lưu trữ các tokens mới, và retry request gốc + +Một trong những drawbacks của cách tiếp cận này là logic managing và refreshing token không có một nơi chuyên dụng. Điều này có thể ổn đối với một số apps hoặc teams, nhưng nếu logic token management phức tạp hơn, có thể tốt hơn là tách biệt trách nhiệm của việc thực hiện requests và managing tokens. Bạn có thể làm điều đó bằng cách giữ requests và API client trong `shared/api`, nhưng token store và management logic trong `shared/auth`. + +Một drawback khác của cách tiếp cận này là nếu backend của bạn trả về object thông tin của current user cùng với token, bạn phải lưu trữ điều đó ở đâu đó hoặc bỏ qua thông tin đó và request lại từ endpoint như `/me` hoặc `/users/current`. + +### Trong Entities + +Thông thường các dự án FSD có một entity cho user và/hoặc một entity cho current user. Nó thậm chí có thể là cùng một entity cho cả hai. + + + +Để lưu trữ token trong User entity, tạo reactive store trong segment `model`. Store đó có thể chứa cả token và user object. + +Vì API client thường được define trong `shared/api` hoặc spread qua các entities, thách thức chính của cách tiếp cận này là làm cho token available cho các requests khác cần nó mà không vi phạm [import rule trên các layers][import-rule-on-layers]: + +> Một module (file) trong slice chỉ có thể import các slices khác khi chúng được đặt trên các layers ở phía dưới. + +Có nhiều giải pháp cho thách thức này: + +1. **Pass token thủ công mỗi lần bạn thực hiện request** + Đây là giải pháp đơn giản nhất, nhưng nó nhanh chóng trở nên cồng kềnh, và nếu bạn không có type safety, dễ quên. Nó cũng không tương thích với middlewares pattern cho API client trong Shared. +1. **Expose token cho toàn bộ app với context hoặc global store như `localStorage`** + Key để retrieve token sẽ được giữ trong `shared/api` để API client có thể truy cập nó. Reactive store của token sẽ được export từ User entity, và context provider (nếu cần) sẽ được thiết lập trên layer App. Điều này cho nhiều tự do hơn để thiết kế API client, tuy nhiên, nó tạo ra implicit dependency trên các layers cao hơn để cung cấp context. Khi theo cách tiếp cận này, hãy cân nhắc cung cấp các error messages hữu ích nếu context hoặc `localStorage` không được thiết lập chính xác. +1. **Inject token vào API client mỗi khi nó thay đổi** + Nếu store của bạn là reactive, bạn có thể tạo subscription sẽ cập nhật token store của API client mỗi khi store trong entity thay đổi. Điều này tương tự như giải pháp trước ở chỗ chúng đều tạo implicit dependency trên các layers cao hơn, nhưng cái này imperative hơn ("push"), trong khi cái trước declarative hơn ("pull"). + +Khi bạn vượt qua thách thức expose token được lưu trữ trong model của entity, bạn có thể encode nhiều business logic liên quan đến token management. Ví dụ, segment `model` có thể chứa logic để invalidate token sau một khoảng thời gian nhất định, hoặc refresh token khi nó expires. Để thực sự thực hiện requests đến backend, sử dụng segment `api` của User entity hoặc `shared/api`. + +### Trong Pages/Widgets (không được khuyến nghị) + +Không được khuyến khích lưu trữ app-wide state như access token trong pages hoặc widgets. Tránh đặt token store của bạn trong segment `model` của login page, thay vào đó hãy chọn từ hai giải pháp đầu tiên, Shared hoặc Entities. + +## Logout và token invalidation + +Thông thường, các apps không có một page hoàn chỉnh cho logging out, nhưng logout functionality vẫn rất quan trọng. Nó bao gồm authenticated request đến backend và cập nhật token store. + +Nếu bạn lưu trữ tất cả requests trong `shared/api`, hãy giữ logout request function ở đó, gần login function. Nếu không, hãy cân nhắc giữ logout request function gần button kích hoạt nó. Ví dụ, nếu bạn có header widget xuất hiện trên mỗi page và chứa logout link, hãy đặt request đó trong segment `api` của widget đó. + +Cập nhật token store sẽ phải được trigger từ vị trí của logout button, như header widget. Bạn có thể kết hợp request và store update trong segment `model` của widget đó. + +### Automatic logout + +Đừng quên xây dựng các failsafes cho khi request log out thất bại, hoặc request refresh login token thất bại. Trong cả hai trường hợp này, bạn nên clear token store. Nếu bạn giữ token trong Entities, code này có thể được đặt trong segment `model` vì nó là pure business logic. Nếu bạn giữ token trong Shared, đặt logic này trong `shared/api` có thể làm segment phình to và pha loãng mục đích của nó. Nếu bạn nhận thấy rằng API segment của mình chứa nhiều thứ không liên quan, hãy cân nhắc tách logic token management thành segment khác, ví dụ, `shared/auth`. + +[tutorial-authentication]: /docs/get-started/tutorial#authentication +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[examples-api-requests]: /docs/guides/examples/api-requests +[ext-remix]: https://remix.run +[ext-zod]: https://zod.dev diff --git a/src/content/docs/vi/docs/guides/examples/page-layout.mdx b/src/content/docs/vi/docs/guides/examples/page-layout.mdx new file mode 100644 index 0000000000..b256ff2e41 --- /dev/null +++ b/src/content/docs/vi/docs/guides/examples/page-layout.mdx @@ -0,0 +1,104 @@ +--- +title: Page Layouts +sidebar: + order: 3 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Hướng dẫn này xem xét abstraction của một _page layout_ — khi nhiều pages chia sẻ cùng một cấu trúc tổng thể, và chỉ khác nhau ở main content. + + + +## Simple layout + +Layout đơn giản nhất có thể được nhìn thấy trên trang bạn đang xem đây. Nó có header với site navigation, hai sidebars, và footer với external links. Không có business logic phức tạp, và các phần dynamic duy nhất là sidebars và switchers ở phía bên phải của header. Layout như vậy có thể được đặt hoàn toàn trong `shared/ui` hoặc trong `app/layouts`, với props điền vào content cho các sidebars: + +```tsx title="shared/ui/layout/Layout.tsx" +import { Link, Outlet } from "react-router-dom"; +import { useThemeSwitcher } from "./useThemeSwitcher"; + +export function Layout({ siblingPages, headings }) { + const [theme, toggleTheme] = useThemeSwitcher(); + + return ( +
+
+ + +
+
+ + {/* This is where the main content goes */} + +
+
+
    +
  • GitHub
  • +
  • Twitter
  • +
+
+
+ ); +} +``` + +```ts title="shared/ui/layout/useThemeSwitcher.ts" +export function useThemeSwitcher() { + const [theme, setTheme] = useState("light"); + + function toggleTheme() { + setTheme(theme === "light" ? "dark" : "light"); + } + + useEffect(() => { + document.body.classList.remove("light", "dark"); + document.body.classList.add(theme); + }, [theme]); + + return [theme, toggleTheme] as const; +} +``` + +Code của các sidebars được để lại như bài tập cho độc giả 😉. + +## Sử dụng widgets trong layout + +Thiệng thoảng bạn muốn bao gồm business logic nhất định trong layout, đặc biệt nếu bạn đang sử dụng deeply nested routes với router như [React Router][ext-react-router]. Khi đó bạn không thể lưu trữ layout trong Shared hoặc trong Widgets do [import rule trên các layers][import-rule-on-layers]: + +> Một module trong slice chỉ có thể import các slices khác khi chúng được đặt trên các layers ở phía dưới. + +Trước khi chúng ta thảo luận các giải pháp, chúng ta cần thảo luận liệu đó có phải là vấn đề ngay từ đầu hay không. Bạn có _thực sự cần_ layout đó không, và nếu có, liệu nó _thực sự cần_ là Widget không? Nếu block business logic đang bàn được tái sử dụng trên 2-3 pages, và layout chỉ đơn giản là wrapper nhỏ cho widget đó, hãy cân nhắc một trong hai lựa chọn này: + +1. **Viết layout inline trên layer App, nơi bạn cấu hình routing** + Điều này rất tốt cho các routers hỗ trợ nesting, vì bạn có thể nhóm các routes nhất định và chỉ áp dụng layout cho chúng. + +2. **Chỉ cần copy-paste nó** + Xu hướng abstract code thường bị đánh giá quá cao. Điều này đặc biệt đúng cho các layouts, hiếm khi thay đổi. Tại một thời điểm nào đó, nếu một trong những pages này cần thay đổi, bạn có thể đơn giản thực hiện thay đổi mà không cần thiết ảnh hưởng đến các pages khác. Nếu bạn lo lắng rằng ai đó có thể quên cập nhật các pages khác, bạn luôn có thể để lại comment mô tả mối quan hệ giữa các pages. + +Nếu không có điều nào ở trên áp dụng được, có hai giải pháp để bao gồm widget trong layout: + +1. **Sử dụng render props hoặc slots** + Hầu hết các frameworks cho phép bạn pass một phần UI từ bên ngoài. Trong React, được gọi là [render props][ext-render-props], trong Vue được gọi là [slots][ext-vue-slots]. +2. **Chuyển layout đến layer App** + Bạn cũng có thể lưu trữ layout của mình trên layer App, ví dụ, trong `app/layouts`, và compose bất kỳ widgets nào bạn muốn. + +## Đọc thêm + +- Có một ví dụ về cách xây dựng layout với authentication sử dụng React và Remix (tương đương với React Router) trong [tutorial][tutorial]. + +[tutorial]: /docs/get-started/tutorial +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-react-router]: https://reactrouter.com/ +[ext-render-props]: https://www.patterns.dev/react/render-props-pattern/ +[ext-vue-slots]: https://vuejs.org/guide/components/slots diff --git a/src/content/docs/vi/docs/guides/examples/types.mdx b/src/content/docs/vi/docs/guides/examples/types.mdx new file mode 100644 index 0000000000..60c2036385 --- /dev/null +++ b/src/content/docs/vi/docs/guides/examples/types.mdx @@ -0,0 +1,446 @@ +--- +title: Types +sidebar: + order: 2 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Hướng dẫn này liên quan đến data types từ các typed languages như TypeScript và mô tả chúng phù hợp ở đâu trong FSD. + + + +## Utility types + +Utility types là các types không có nhiều ý nghĩa riêng và thường được sử dụng với các types khác. Ví dụ: + +
+ +```ts +type ArrayValues = T[number]; +``` + +
+ Nguồn: https://github.com/sindresorhus/type-fest/blob/main/source/array-values.d.ts +
+ +
+ +Để làm cho utility types có sẵn trong toàn bộ dự án của bạn, hoặc là install một library như [`type-fest`][ext-type-fest], hoặc tạo library riêng của bạn trong `shared/lib`. Đảm bảo chỉ rõ ràng những types mới nào _nên_ được thêm vào library này, và những types nào _không thuộc về_ đó. Ví dụ, gọi nó là `shared/lib/utility-types` và thêm README bên trong mô tả utility type là gì trong team của bạn. + +Đừng đánh giá quá cao khả năng tái sử dụng của utility type. Chỉ vì nó có thể được tái sử dụng, không có nghĩa là nó sẽ được tái sử dụng, và vì vậy, không phải mọi utility type đều cần ở trong Shared. Một số utility types thì ổn ngay bên cạnh nơi chúng được cần: + + +- pages/ + - home/ + - api/ + - ArrayValues.ts utility type + - getMemoryUsageMetrics.ts code sử dụng utility type + + + + +## Business entities và cross-references của chúng + +Trong số những types quan trọng nhất trong app là types của business entities, tức là những thứ trong thế giới thực mà app của bạn làm việc với. Ví dụ, trong music streaming app, bạn có thể có business entities _Song_, _Album_, v.v. + +Business entities thường đến từ backend, vì vậy bước đầu tiên là type backend responses. Thật tiện lợi khi có function để thực hiện request đến mỗi endpoint, và type response của function này. Để có thêm type safety, bạn có thể muốn chạy response qua schema validation library như [Zod][ext-zod]. + +Ví dụ, nếu bạn giữ tất cả requests của mình trong Shared, bạn có thể làm như thế này: + +```ts title="shared/api/songs.ts" +import type { Artist } from "./artists"; + +interface Song { + id: number; + title: string; + artists: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +Bạn có thể nhận thấy rằng type `Song` tham chiếu đến một entity khác, `Artist`. Đây là lợi ích của việc lưu trữ requests của bạn trong Shared — các types thế giới thực thường đan xen. Nếu chúng ta giữ function này trong `entities/song/api`, chúng ta sẽ không thể đơn giản import `Artist` từ `entities/artist`, vì FSD hạn chế cross-imports giữa các slices với [import rule trên các layers][import-rule-on-layers]: + +> Một module trong slice chỉ có thể import các slices khác khi chúng được đặt trên các layers ở phía dưới. + +Có hai cách để giải quyết vấn đề này: + +1. **Tham số hóa các types của bạn** + Bạn có thể làm cho types của mình chấp nhận type arguments làm slots cho các kết nối với entities khác, và thậm chí áp dụng constraints trên những slots đó. Ví dụ: + + ```ts title="entities/song/model/song.ts" + interface Song { + id: number; + title: string; + artists: Array; + } + ``` + + Điều này hoạt động tốt hơn cho một số types so với những types khác. Một type đơn giản như `Cart = { items: Array }` có thể dễ dàng được làm để hoạt động với bất kỳ loại product nào. Các types kết nối nhiều hơn, như `Country` và `City`, có thể không dễ tách rời. + +2. **Cross-import (nhưng làm đúng cách)** + Để thực hiện cross-imports giữa các entities trong FSD, bạn có thể sử dụng public API đặc biệt dành riêng cho mỗi slice sẽ cross-importing. Ví dụ, nếu chúng ta có entities `song`, `artist`, và `playlist`, và hai cái sau cần tham chiếu `song`, chúng ta có thể tạo hai public APIs đặc biệt cho cả hai trong entity `song` với ký hiệu `@x`: + + + - entities/ + - song/ + - @x/ + - artist.ts public API cho entity `artist` để import + - playlist.ts public API cho entity `playlist` để import + - index.ts public API thông thường + + + Nội dung của file `📄 entities/song/@x/artist.ts` tương tự như `📄 entities/song/index.ts`: + + ```ts title="entities/song/@x/artist.ts" + export type { Song } from "../model/song.ts"; + ``` + + Sau đó `📄 entities/artist/model/artist.ts` có thể import `Song` như thế này: + + ```ts title="entities/artist/model/artist.ts" + import type { Song } from "entities/song/@x/artist"; + + export interface Artist { + name: string; + songs: Array; + } + ``` + + Bằng cách tạo kết nối rõ ràng giữa các entities, chúng ta kiểm soát được inter-dependencies và duy trì mức độ phân tách domain tốt. + +## Data transfer objects và mappers \{#data-transfer-objects-and-mappers} + +Data transfer objects, hay DTOs, là thuật ngữ mô tả hình dạng của dữ liệu đến từ backend. Đôi khi, DTO có thể sử dụng ngay, nhưng đôi khi nó không thuận tiện cho frontend. Đó là lúc mappers xuất hiện — chúng biến đổi DTO thành hình dạng thuận tiện hơn. + +### Đặt DTOs ở đâu + +Nếu bạn có backend types trong package riêng (ví dụ, nếu bạn chia sẻ code giữa frontend và backend), thì chỉ cần import DTOs từ đó và xong! Nếu bạn không chia sẻ code giữa backend và frontend, thì bạn cần giữ DTOs ở đâu đó trong frontend codebase, và chúng ta sẽ khám phá trường hợp này dưới đây. + +Nếu bạn có request functions trong `shared/api`, đó là nơi DTOs nên ở, ngay cạnh function sử dụng chúng: + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + artist_ids: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +Như đã đề cập trong phần trước, lưu trữ requests và DTOs của bạn trong Shared mang lại lợi ích có thể tham chiếu DTOs khác. + +### Đặt mappers ở đâu + +Mappers là các functions chấp nhận DTO để biến đổi, và vì vậy, chúng nên được đặt gần định nghĩa của DTO. Trong thực tế điều này có nghĩa là nếu requests và DTOs của bạn được định nghĩa trong `shared/api`, thì mappers cũng nên ở đó: + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} + +interface Song { + id: string; + title: string; + /** Tiêu đề đầy đủ của bài hát, bao gồm số đĩa. */ + fullTitle: string; + artistIds: Array; +} + +function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +Nếu requests và stores của bạn được định nghĩa trong entity slices, thì tất cả code này sẽ đi vào đó, nhớ lưu ý giới hạn của cross-imports giữa các slices: + +```ts title="entities/song/api/dto.ts" +import type { ArtistDTO } from "entities/artist/@x/song"; + +export interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} +``` + +```ts title="entities/song/api/mapper.ts" +import type { SongDTO } from "./dto"; + +export interface Song { + id: string; + title: string; + /** Tiêu đề đầy đủ của bài hát, bao gồm số đĩa. */ + fullTitle: string; + artistIds: Array; +} + +export function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} +``` + +```ts title="entities/song/api/listSongs.ts" +import { adaptSongDTO } from "./mapper"; + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +```ts title="entities/song/model/songs.ts" +import { createSlice, createEntityAdapter } from "@reduxjs/toolkit"; + +import { listSongs } from "../api/listSongs"; + +export const fetchSongs = createAsyncThunk('songs/fetchSongs', listSongs); + +const songAdapter = createEntityAdapter(); +const songsSlice = createSlice({ + name: "songs", + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSongs.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload); + }) + }, +}); +``` + +### Cách xử lý nested DTOs + +Phần có vấn đề nhất là khi response từ backend chứa nhiều entities. Ví dụ, nếu bài hát bao gồm không chỉ IDs của tác giả, mà cả toàn bộ author objects. Trong trường hợp này, không thể cho các entities không biết về nhau (trừ khi chúng ta muốn loại bỏ dữ liệu hoặc có cuộc trò chuyện nghiêm túc với backend team). Thay vì nghĩ ra giải pháp cho kết nối gián tiếp giữa các slices (như common middleware sẽ dispatch actions đến slices khác), ưu tiên cross-imports rõ ràng với ký hiệu `@x`. Đây là cách chúng ta có thể triển khai với Redux Toolkit: + +```ts title="entities/song/model/songs.ts" +import { + createSlice, + createEntityAdapter, + createAsyncThunk, + createSelector, +} from '@reduxjs/toolkit' +import { normalize, schema } from 'normalizr' + +import { getSong } from "../api/getSong"; + +// Định nghĩa normalizr entity schemas +export const artistEntity = new schema.Entity('artists') +export const songEntity = new schema.Entity('songs', { + artists: [artistEntity], +}) + +const songAdapter = createEntityAdapter() + +export const fetchSong = createAsyncThunk( + 'songs/fetchSong', + async (id: string) => { + const data = await getSong(id) + // Normalize dữ liệu để reducers có thể load payload dự đoán được, như: + // `action.payload = { songs: {}, artists: {} }` + const normalized = normalize(data, songEntity) + return normalized.entities + } +) + +export const slice = createSlice({ + name: 'songs', + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload.songs) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +```ts title="entities/song/@x/artist.ts" +export { fetchSong } from "../model/songs"; +``` + +```ts title="entities/artist/model/artists.ts" +import { createSlice, createEntityAdapter } from '@reduxjs/toolkit' + +import { fetchSong } from 'entities/song/@x/artist' + +const artistAdapter = createEntityAdapter() + +export const slice = createSlice({ + name: 'users', + initialState: artistAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + // Và xử lý cùng fetch result bằng cách chèn artists ở đây + artistAdapter.upsertMany(state, action.payload.artists) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +Điều này hạn chế một chút lợi ích của slice isolation, nhưng nó thể hiện chính xác kết nối giữa hai entities này mà chúng ta không kiểm soát được. Nếu các entities này cần được refactor, chúng phải được refactor cùng nhau. + +## Global types và Redux + +Global types là types sẽ được sử dụng trong toàn bộ ứng dụng. Có hai loại global types, dựa trên những gì chúng cần biết: +1. Generic types không có bất kỳ đặc điểm ứng dụng nào +2. Types cần biết về toàn bộ ứng dụng + +Trường hợp đầu tiên đơn giản để giải quyết — đặt types của bạn trong Shared, trong segment phù hợp. Ví dụ, nếu bạn có interface cho global variable cho analytics, bạn có thể đặt nó trong `shared/analytics`. + + + +Trường hợp thứ hai thường gặp trong các dự án với Redux không có RTK. Store type cuối cùng của bạn chỉ có sẵn khi bạn thêm tất cả reducers lại với nhau, nhưng store type này cần có sẵn cho selectors mà bạn sử dụng trong app. Ví dụ, đây là định nghĩa store điển hình của bạn: + +```ts title="app/store/index.ts" +import { combineReducers, rootReducer } from "redux"; + +import { songReducer } from "entities/song"; +import { artistReducer } from "entities/artist"; + +const rootReducer = combineReducers(songReducer, artistReducer); + +const store = createStore(rootReducer); + +type RootState = ReturnType; +type AppDispatch = typeof store.dispatch; +``` + +Sẽ tốt nếu có typed Redux hooks `useAppDispatch` và `useAppSelector` trong `shared/store`, nhưng chúng không thể import `RootState` và `AppDispatch` từ App layer do [import rule trên layers][import-rule-on-layers]: + +> Một module trong slice chỉ có thể import các slices khác khi chúng được đặt trên layers nghiêm ngặt bên dưới. + +Giải pháp được khuyến nghị trong trường hợp này là tạo implicit dependency giữa layers Shared và App. Hai types này, `RootState` và `AppDispatch` không chắc sẽ thay đổi, và chúng sẽ quen thuộc với Redux developers, vì vậy chúng ta không phải lo lắng về chúng nhiều. + +Trong TypeScript, bạn có thể làm điều đó bằng cách khai báo types là global như thế này: + +```ts title="app/store/index.ts" +/* cùng nội dung như trong code block trước… */ + +declare type RootState = ReturnType; +declare type AppDispatch = typeof store.dispatch; +``` + +```ts title="shared/store/index.ts" +import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux"; + +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector: TypedUseSelectorHook = useSelector; +``` + +## Enums + +Quy tắc chung với enums là chúng nên được định nghĩa **càng gần với vị trí sử dụng càng tốt**. Khi enum đại diện cho các giá trị cụ thể cho một feature duy nhất, nó nên được định nghĩa trong cùng feature đó. + +Việc chọn segment cũng nên được quyết định bởi vị trí sử dụng. Nếu enum của bạn chứa, ví dụ, vị trí của toast trên màn hình, nó nên được đặt trong segment `ui`. Nếu nó đại diện cho loading state của backend operation, nó nên được đặt trong segment `api`. + +Một số enums thực sự chung cho toàn bộ dự án, như general backend response statuses hoặc design system tokens. Trong trường hợp này, bạn có thể đặt chúng trong Shared, và chọn segment dựa trên enum đại diện cho gì (`api` cho response statuses, `ui` cho design tokens, v.v.). + +## Type validation schemas và Zod + +Nếu bạn muốn validate rằng dữ liệu của bạn phù hợp với hình dạng hoặc ràng buộc nhất định, bạn có thể định nghĩa validation schema. Trong TypeScript, library phổ biến cho công việc này là [Zod][ext-zod]. Validation schemas cũng nên được colocated với code sử dụng chúng, càng nhiều càng tốt. + +Validation schemas tương tự như mappers (như đã thảo luận trong phần [Data transfer objects và mappers](#data-transfer-objects-and-mappers)) ở chỗ chúng nhận data transfer object và parse nó, tạo ra lỗi nếu parsing thất bại. + +Một trong những trường hợp phổ biến nhất của validation là cho dữ liệu đến từ backend. Thông thường, bạn muốn request thất bại khi dữ liệu không khớp với schema, vì vậy hợp lý khi đặt schema ở cùng nơi với request function, thường là segment `api`. + +Nếu dữ liệu của bạn đến qua user input, như form, validation nên xảy ra khi dữ liệu đang được nhập. Bạn có thể đặt schema trong segment `ui`, cạnh form component, hoặc trong segment `model`, nếu segment `ui` quá đông. + +## Typings của component props và context + +Nói chung, tốt nhất là giữ props hoặc context interface trong cùng file với component hoặc context sử dụng chúng. Nếu bạn có framework với single-file components, như Vue hoặc Svelte, và bạn không thể định nghĩa props interface trong cùng file, hoặc bạn muốn chia sẻ interface đó giữa nhiều components, tạo file riêng trong cùng folder, thường là segment `ui`. + +Đây là ví dụ với JSX (React hoặc Solid): + +```ts title="pages/home/ui/RecentActions.tsx" +interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} + +export function RecentActions({ actions }: RecentActionsProps) { + /* … */ +} +``` + +Và đây là ví dụ với interface được lưu trong file riêng cho Vue: + +```ts title="pages/home/ui/RecentActionsProps.ts" +export interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} +``` + +```html title="pages/home/ui/RecentActions.vue" + +``` + +## Ambient declaration files (`*.d.ts`) + +Một số packages, ví dụ, [Vite][ext-vite] hoặc [ts-reset][ext-ts-reset], yêu cầu ambient declaration files để hoạt động trong app của bạn. Thường thì chúng không lớn hoặc phức tạp, vì vậy chúng thường không yêu cầu bất kỳ architecting nào, có thể chỉ cần đặt chúng trong folder `src/`. Để giữ `src` có tổ chức hơn, bạn có thể giữ chúng trên App layer, trong `app/ambient/`. + +Các packages khác đơn giản là không có typings, và bạn có thể muốn khai báo chúng là untyped hoặc thậm chí viết typings riêng cho chúng. Nơi tốt cho những typings đó sẽ là `shared/lib`, trong folder như `shared/lib/untyped-packages`. Tạo file `%LIBRARY_NAME%.d.ts` ở đó và khai báo types bạn cần: + +```ts title="shared/lib/untyped-packages/use-react-screenshot.d.ts" +// Library này không có typings, và chúng tôi không muốn phiền viết riêng. +declare module "use-react-screenshot"; +``` + +## Tự động sinh types + +Thường xuyên sinh types từ nguồn bên ngoài, ví dụ, sinh backend types từ OpenAPI schema. Trong trường hợp này, tạo nơi chuyên dụng trong codebase của bạn cho những types này, như `shared/api/openapi`. Lý tưởng nhất, bạn cũng nên bao gồm README trong folder đó mô tả những files này là gì, cách tái tạo chúng, v.v. + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-type-fest]: https://github.com/sindresorhus/type-fest +[ext-zod]: https://zod.dev +[ext-vite]: https://vitejs.dev +[ext-ts-reset]: https://www.totaltypescript.com/ts-reset diff --git a/src/content/docs/vi/docs/guides/migration/from-custom.mdx b/src/content/docs/vi/docs/guides/migration/from-custom.mdx new file mode 100644 index 0000000000..f45cd20b4a --- /dev/null +++ b/src/content/docs/vi/docs/guides/migration/from-custom.mdx @@ -0,0 +1,234 @@ +--- +title: Migration từ custom architecture +sidebar: + order: 1 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Hướng dẫn này mô tả cách tiếp cận có thể hữu ích khi migration từ custom self-made architecture sang Feature-Sliced Design. + +Đây là cấu trúc folder của một custom architecture điển hình. Chúng tôi sẽ sử dụng nó làm ví dụ trong hướng dẫn này. +Nhấp vào mũi tên xanh để mở folder. + + +- src/ + - actions/ + - product/ + - order/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - routes/ + - products.jsx + - products.[id].jsx + - utils/ + - reducers/ + - selectors/ + - styles/ + - App.jsx + - index.jsx + + +## Trước khi bạn bắt đầu \{#before-you-start} + +Câu hỏi quan trọng nhất để hỏi team của bạn khi cân nhắc chuyển sang Feature-Sliced Design là — _bạn có thực sự cần nó không?_ Chúng tôi yêu Feature-Sliced Design, nhưng thậm chí chúng tôi cũng nhận ra rằng một số dự án hoàn toàn ổn mà không cần nó. + +Đây là một số lý do để cân nhắc thực hiện sự chuyển đổi: + +1. Thành viên mới trong team phàn nàn rằng khó đạt đến mức độ hiệu quả +2. Thực hiện modifications ở một phần của code **thường** gây ra phần khác không liên quan bị hỏng +3. Thêm functionality mới khó khăn do số lượng lớn các thứ bạn cần suy nghĩ + +**Tránh chuyển sang FSD trái với ý muốn của các đồng đội**, ngay cả khi bạn là lead. +Trước tiên, hãy thuyết phục các đồng đội rằng lợi ích vượt trội so với chi phí migration và chi phí học một architecture mới thay vì architecture đã được thiết lập. + +Cũng cần nhớ rằng bất kỳ loại thay đổi kiến trúc nào cũng không ngay lập tức có thể quan sát được đối với quản lý. Hãy đảm bảo họ đồng ý với việc chuyển đổi trước khi bắt đầu và giải thích cho họ tại sao điều này có thể có lợi cho dự án. + + + +--- + +Nếu bạn đã quyết định bắt đầu migration, thì điều đầu tiên bạn muốn làm là thiết lập một alias cho `📁 src`. Điều này sẽ hữu ích sau này khi tham chiếu đến các folder tầng cao. Chúng tôi sẽ coi `@` là alias cho `./src` trong phần còn lại của hướng dẫn này. + +## Bước 1. Chia code theo pages \{#divide-code-by-pages} + +Hầu hết custom architectures đã có sự phân chia theo pages, dù logic nhỏ hay lớn. Nếu bạn đã có `📁 pages`, bạn có thể bỏ qua bước này. + +Nếu bạn chỉ có `📁 routes`, hãy tạo `📁 pages` và cố gắng di chuyển càng nhiều component code từ `📁 routes` càng tốt. Lý tưởng là bạn sẽ có một route nhỏ và một page lớn hơn. Khi đang di chuyển code, hãy tạo một folder cho mỗi page và thêm một index file: + + + +Route file: + +```js title="src/routes/products.[id].js" +export { ProductPage as default } from "@/pages/product" +``` + +Page index file: + +```js title="src/pages/product/index.js" +export { ProductPage } from "./ProductPage.jsx" +``` + +Page component file: + +```jsx title="src/pages/product/ProductPage.jsx" +export function ProductPage(props) { + return
; +} +``` + +## Bước 2. Tách mọi thứ khác ra khỏi pages \{#separate-everything-else-from-pages} + +Tầo folder `📁 src/shared` và di chuyển mọi thứ không import từ `📁 pages` hoặc `📁 routes` vào đó. Tạo folder `📁 src/app` và di chuyển mọi thứ có import pages hoặc routes vào đó, bao gồm cả chính các routes. + +Hãy nhớ rằng Shared layer không có slices, vì vậy không sao nếu các segment import lẫn nhau. + +Cuối cùng bạn sẽ có được cấu trúc tệp như thế này: + + +- src/ + - app/ + - routes/ + - products.jsx + - products.[id].jsx + - App.jsx + - index.js + - pages/ + - product/ + - index.js + - ui/ + - ProductPage.jsx + - catalog/ + - shared/ + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## Bước 3. Giải quyết cross-imports giữa các pages \{#tackle-cross-imports-between-pages} + +Tìm tất cả các trường hợp mà một page đang import từ page khác và làm một trong hai điều sau: + +1. Copy-paste code được import vào page phụ thuộc để loại bỏ dependency +2. Di chuyển code vào một segment thích hợp trong Shared: + - nếu nó là một phần của UI kit, di chuyển vào `📁 shared/ui`; + - nếu nó là một configuration constant, di chuyển vào `📁 shared/config`; + - nếu nó là một backend interaction, di chuyển vào `📁 shared/api`. + + + +## Bước 4. Giải nén Shared layer \{#unpack-shared-layer} + +Bạn có thể có rất nhiều thứ trong Shared layer ở bước này, và nói chung bạn muốn tránh điều đó. Lý do là Shared layer có thể là dependency cho bất kỳ layer nào khác trong codebase của bạn, vì vậy việc thay đổi code đó tự động dễ gây ra các hậu quả ngoài ý muốn hơn. + +Tìm tất cả các object chỉ được sử dụng trên một page và di chuyển nó vào slice của page đó. Và đúng rồi, _điều đó cũng áp dụng cho actions, reducers, và selectors_. Không có lợi ích gì khi nhóm tất cả actions lại với nhau, nhưng có lợi ích khi đặt các relevant actions gần với nơi sử dụng chúng. + +Cuối cùng bạn sẽ có được cấu trúc tệp như thế này: + + +- src/ + - app/ + - ... + - pages/ + - product/ + - actions/ + - reducers/ + - selectors/ + - ui/ + - Component.jsx + - Container.jsx + - ProductPage.jsx + - index.js + - catalog/ + - shared/ only objects that are reused + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## Bước 5. Tổ chức code theo mục đích kỹ thuật \{#organize-by-technical-purpose} + +Trong FSD, việc phân chia theo mục đích kỹ thuật được thực hiện với _segments_. Có một số loại phổ biến: + +- `ui` — mọi thứ liên quan đến hiển thị UI: UI components, date formatters, styles, v.v. +- `api` — các tương tác backend: request functions, data types, mappers, v.v. +- `model` — data model: schemas, interfaces, stores, và business logic. +- `lib` — library code mà các module khác trên slice này cần. +- `config` — các file cấu hình và feature flags. + +Bạn cũng có thể tạo các segment riêng của mình nếu cần. Hãy đảm bảo không tạo các segment nhóm code theo những gì nó là, như `components`, `actions`, `types`, `utils`. Thay vào đó, hãy nhóm code theo những gì nó dành cho. + +Tổ chức lại các pages của bạn để tách biệt code theo segments. Bạn nên đã có một `ui` segment, bây giờ là lúc tạo các segment khác, như `model` cho actions, reducers, và selectors của bạn, hoặc `api` cho thunks và mutations của bạn. + +Cũng tổ chức lại Shared layer để loại bỏ các folder này: +- `📁 components`, `📁 containers` — phần lớn nó nên trở thành `📁 shared/ui`; +- `📁 helpers`, `📁 utils` — nếu còn một số helpers được tái sử dụng, hãy nhóm chúng lại theo function, như dates hoặc type conversions, và di chuyển các nhóm này vào `📁 shared/lib`; +- `📁 constants` — lại, nhóm theo function và di chuyển vào `📁 shared/config`. + +## Các bước tùy chọn \{#optional-steps} + +### Bước 6. Tạo entities/features từ các Redux slice được sử dụng trên nhiều pages \{#form-entities-features-from-redux} + +Thường thì các Redux slice được tái sử dụng này sẽ mô tả điều gì đó liên quan đến nghiệp vụ, ví dụ như products hoặc users, vì vậy chúng có thể được di chuyển vào Entities layer, một entity một folder. Nếu Redux slice liên quan đến một action mà người dùng của bạn muốn thực hiện trong app, như comments, thì bạn có thể di chuyển nó vào Features layer. + +Entities và features có ý định độc lập với nhau. Nếu business domain của bạn chứa các kết nối bẩm sinh giữa các entity, hãy tham khảo [hướng dẫn về business entities][business-entities-cross-relations] để có lời khuyên về cách tổ chức các kết nối này. + +Các API functions liên quan đến các slice này có thể giữ lại trong `📁 shared/api`. + +### Bước 7. Refactor các modules của bạn \{#refactor-your-modules} + +Folder `📁 modules` thường được sử dụng cho business logic, vì vậy nó đã khá tương tự về bản chất với Features layer từ FSD. Một số module cũng có thể mô tả những khối lớn của UI, như app header. Trong trường hợp đó, bạn nên migration chúng vào Widgets layer. + +### Bước 8. Tạo một UI foundation sạch trong `shared/ui` \{#form-clean-ui-foundation} + +`📁 shared/ui` lý tưởng nên chứa một tập hợp các UI elements không có business logic nào được encode trong chúng. Chúng cũng nên có tính tái sử dụng cao. + +Refactor các UI components đã từng nằm trong `📁 components` và `📁 containers` để tách riêng business logic. Di chuyển business logic đó lên các layer cao hơn. Nếu nó không được sử dụng ở quá nhiều nơi, bạn thậm chí có thể cân nhắc copy-paste. + +## Xem thêm \{#see-also} + +- [(Bài nói tiếng Nga) Ilya Klimov — Крысиные бега бесконечного рефакторинга: как не дать техническому долгу убить мотивацию и продукт](https://youtu.be/aOiJ3k2UvO4) + +[ext-steiger]: https://github.com/feature-sliced/steiger +[business-entities-cross-relations]: /docs/guides/examples/types#business-entities-and-their-cross-references diff --git a/src/content/docs/vi/docs/guides/migration/from-v1.mdx b/src/content/docs/vi/docs/guides/migration/from-v1.mdx new file mode 100644 index 0000000000..1bcaa3fc78 --- /dev/null +++ b/src/content/docs/vi/docs/guides/migration/from-v1.mdx @@ -0,0 +1,173 @@ +--- +title: Migration từ v1 sang v2 +sidebar: + order: 2 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## Tại sao v2? + +Khái niệm gốc của **feature-slices** [đã được công bố][ext-tg-spb] vào năm 2018. + +Kể từ đó, nhiều sự biến đổi của phương pháp luận đã diễn ra, nhưng đồng thời **[các nguyên tắc cơ bản đã được bảo tồn][ext-v1]**: + +- Sử dụng cấu trúc dự án frontend *được tiêu chuẩn hóa* +- Chia nhỏ ứng dụng ngay từ đầu - theo *logic nghiệp vụ* +- Sử dụng các *feature độc lập* để ngăn chặn side effects ngầm định và phụ thuộc vòng tròn +- Sử dụng *Public API* với việc cấm "leo vào bên trong" của module + +Đồng thời, trong phiên bản trước của phương pháp luận, vẫn còn những **điểm yếu** mà: + +- Đôi khi dẫn đến boilerplate code +- Đôi khi dẫn đến sự phức tạp quá mức của code base và các quy tắc không rõ ràng giữa các abstraction +- Đôi khi dẫn đến các giải pháp kiến trúc ngầm định, ngăn cản việc kéo dự án lên và onboarding người mới + +Phiên bản mới của phương pháp luận ([v2][ext-v2]) được thiết kế **để loại bỏ những thiếu sót này, đồng thời bảo tồn các ưu điểm hiện có** của cách tiếp cận này. + +Kể từ năm 2018, [cũng đã phát triển][ext-fdd-issues] một phương pháp luận tương tự khác - [**feature-driven**][ext-fdd], được công bố lần đầu bởi [Oleg Isonen][ext-kof]. + +Sau khi hợp nhất hai cách tiếp cận, chúng tôi đã **cải thiện và tinh chỉnh các thực tiễn hiện có** - hướng tới sự linh hoạt, rõ ràng và hiệu quả hơn trong ứng dụng. + +> Kết quả là, điều này thậm chí đã ảnh hưởng đến tên của phương pháp luận - *"feature-slice**d**"* + +## Tại sao việc migration dự án sang v2 có ý nghĩa? + +> `WIP:` Phiên bản hiện tại của phương pháp luận đang được phát triển và một số chi tiết *có thể thay đổi* + +#### 🔍 Kiến trúc minh bạch và đơn giản hơn + +Phương pháp luận (v2) cung cấp **các abstraction trực quan hơn và phổ biến hơn cũng như các cách phân tách logic giữa các developer.** + +Tất cả điều này có tác động cực kỳ tích cực đến việc thu hút người mới, cũng như nghiên cứu trạng thái hiện tại của dự án, và phân phối logic nghiệp vụ của ứng dụng. + +#### 📦 Tính modular linh hoạt và trung thực hơn + +Phương pháp luận (v2) cho phép **phân phối logic theo cách linh hoạt hơn:** + +- Với khả năng refactor các phần riêng biệt từ đầu +- Với khả năng dựa vào cùng các abstraction, nhưng không có sự đan xen phụ thuộc không cần thiết +- Với các yêu cầu đơn giản hơn cho vị trí của module mới *(layer => slice => segment)* + +#### 🚀 Nhiều đặc tả, kế hoạch, cộng đồng hơn + +Hiện tại, `core-team` đang tích cực làm việc trên phiên bản mới nhất (v2) của phương pháp luận + +Vì vậy đối với nó: + +- sẽ có nhiều case / vấn đề được mô tả hơn +- sẽ có nhiều hướng dẫn về ứng dụng hơn +- sẽ có nhiều ví dụ thực tế hơn +- nói chung, sẽ có nhiều tài liệu hơn để onboarding người mới và nghiên cứu các khái niệm của phương pháp luận +- toolkit sẽ được phát triển trong tương lai để tuân thủ các khái niệm và quy ước về kiến trúc + +> Tất nhiên, cũng sẽ có hỗ trợ người dùng cho phiên bản đầu tiên - nhưng phiên bản mới nhất vẫn là ưu tiên của chúng tôi +> +> Trong tương lai, với các bản cập nhật major tiếp theo, bạn vẫn sẽ có quyền truy cập vào phiên bản hiện tại (v2) của phương pháp luận, **không có rủi ro cho team và dự án của bạn** + +## Changelog + +### `BREAKING` Layers + +Bây giờ phương pháp luận giả định việc phân bổ rõ ràng các layer ở tầng cao nhất + +- `/app` > `/processes` > **`/pages`** > **`/features`** > `/entities` > `/shared` +- *Tức là, không phải mọi thứ bây giờ đều được coi là features/pages* +- Cách tiếp cận này cho phép bạn [đặt quy tắc rõ ràng cho các layer][ext-tg-v2-draft]: +- **Càng cao layer** của module được đặt, càng nhiều **context** nó có + + *(nói cách khác - mỗi module của layer - chỉ có thể import các module của các layer bên dưới, nhưng không phải cao hơn)* + +- **Càng thấp layer** của module được đặt, càng nhiều **nguy hiểm và trách nhiệm** khi thực hiện thay đổi + + *(vì thường là các layer bên dưới được sử dụng nhiều hơn)* + +### `BREAKING` Shared + +Các infrastructure abstraction `/ui`, `/lib`, `/api`, trước đây nằm trong src root của dự án, bây giờ được tách biệt bởi thư mục riêng biệt `/src/shared` + +- `shared/ui` - Vẫn là uikit tổng quát giống như cũ của ứng dụng (tùy chọn) + - *Đồng thời, không ai cấm sử dụng `Atomic Design` ở đây như trước* +- `shared/lib` - Tập hợp các thư viện phụ trợ để triển khai logic + - *Vẫn - không có dump của helpers* +- `shared/api` - Điểm vào chung để truy cập API + - *Cũng có thể đăng ký cục bộ trong mỗi feature / page - nhưng không được khuyến khích* +- Như trước - không nên có ràng buộc rõ ràng với business logic trong `shared` + - *Nếu cần thiết, bạn cần đưa mối quan hệ này lên tầng `entities` hoặc thậm chí cao hơn* + +### `NEW` Entities, Processes + +Trong v2 **, các abstraction mới khác** đã được thêm vào để loại bỏ các vấn đề về độ phức tạp logic và coupling cao. + +- `/entities` - layer **business entities** chứa các slice có liên quan trực tiếp đến các business model hoặc synthetic entities chỉ cần thiết trên frontend + - *Ví dụ: `user`, `i18n`, `order`, `blog`* +- `/processes` - layer **business processes**, xuyên suốt app + - **Layer này là tùy chọn**, thường được khuyến khích sử dụng khi *logic phát triển và bắt đầu mờ nhạt trong nhiều page* + - *Ví dụ: `payment`, `auth`, `quick-tour`* + +### `BREAKING` Abstractions & Naming + +Bây giờ các abstraction cụ thể và [khuyến nghị rõ ràng cho việc đặt tên chúng][refs-adaptability] đã được định nghĩa + +[disc-process]: https://github.com/feature-sliced/documentation/discussions/20 +[disc-features]: https://github.com/feature-sliced/documentation/discussions/23 +[disc-entities]: https://github.com/feature-sliced/documentation/discussions/18#discussioncomment-422649 +[disc-shared]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453020 + +[disc-ui]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453132 +[disc-model]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-472645 +[disc-api]: https://github.com/feature-sliced/documentation/discussions/66 + +#### Layers + +- `/app` — **layer khởi tạo ứng dụng** + - *Các phiên bản trước: `app`, `core`,`init`, `src/index` (và điều này cũng xảy ra)* +- `/processes` — [**layer business process**][disc-process] + - *Các phiên bản trước: `processes`, `flows`, `workflows`* +- `/pages` — **layer page ứng dụng** + - *Các phiên bản trước: `pages`, `screens`, `views`, `layouts`, `components`, `containers`* +- `/features` — [**layer các phần functionality**][disc-features] + - *Các phiên bản trước: `features`, `components`, `containers`* +- `/entities` — [**layer business entity**][disc-entities] + - *Các phiên bản trước: `entities`, `models`, `shared`* +- `/shared` — [**layer của infrastructure code tái sử dụng**][disc-shared] 🔥 + - *Các phiên bản trước: `shared`, `common`, `lib`* + +#### Segments + +- `/ui` — [**UI segment**][disc-ui] 🔥 + - *Các phiên bản trước: `ui`, `components`, `view`* +- `/model` — [**BL-segment**][disc-model] 🔥 + - *Các phiên bản trước: `model`, `store`, `state`, `services`, `controller`* +- `/lib` — segment **của auxiliary code** + - *Các phiên bản trước: `lib`, `libs`, `utils`, `helpers`* +- `/api` — [**API segment**][disc-api] + - *Các phiên bản trước: `api`, `service`, `requests`, `queries`* +- `/config` — **segment cấu hình ứng dụng** + - *Các phiên bản trước: `config`, `env`, `get-env`* + +### `REFINED` Low coupling + +Bây giờ việc [tuân thủ nguyên tắc low coupling][refs-low-coupling] giữa các module dễ dàng hơn nhiều, nhờ vào các layer mới. + +*Đồng thời, vẫn được khuyến khích tránh càng nhiều càng tốt các trường hợp khi cực kỳ khó để "uncouple" các module* + +## Xem thêm + +- [Ghi chú từ báo cáo "React SPB Meetup #1"][ext-tg-spb] +- [React Berlin Talk - Oleg Isonen "Feature Driven Architecture"][ext-kof-fdd] +- [So sánh với v1 (community-chat)](https://t.me/feature_sliced/493) +- [Ý tưởng mới v2 với giải thích (atomicdesign-chat)][ext-tg-v2-draft] +- [Thảo luận về các abstraction và naming cho phiên bản mới của phương pháp luận (v2)](https://github.com/feature-sliced/documentation/discussions/31) + +[refs-low-coupling]: /docs/reference/slices-segments#zero-coupling-high-cohesion +[refs-adaptability]: /docs/about/understanding/naming + +[ext-v1]: https://feature-sliced.github.io/featureslices.dev/v1.0.html +[ext-tg-spb]: https://t.me/feature_slices +[ext-fdd]: https://github.com/feature-sliced/documentation/tree/rc/feature-driven +[ext-fdd-issues]: https://github.com/kof/feature-driven-architecture/issues +[ext-v2]: https://github.com/feature-sliced/documentation +[ext-kof]: https://github.com/kof +[ext-kof-fdd]: https://www.youtube.com/watch?v=BWAeYuWFHhs +[ext-tg-v2-draft]: https://t.me/atomicdesign/18708 diff --git a/src/content/docs/vi/docs/guides/migration/from-v2-0.mdx b/src/content/docs/vi/docs/guides/migration/from-v2-0.mdx new file mode 100644 index 0000000000..d862e9b249 --- /dev/null +++ b/src/content/docs/vi/docs/guides/migration/from-v2-0.mdx @@ -0,0 +1,47 @@ +--- +title: Migration từ v2.0 sang v2.1 +sidebar: + order: 3 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Thay đổi chính trong v2.1 là mental model mới để phân tách interface — pages first. + +Trong v2.0, FSD khuyến nghị xác định entities và features trong interface của bạn, cân nhắc thậm chí những bit nhỏ nhất của entity representation và tính tương tác để phân tách. Sau đó bạn sẽ xây dựng widgets và pages từ entities và features. Trong model phân tách này, phần lớn logic nằm trong entities và features, và pages chỉ là các compositional layer không có nhiều ý nghĩa riêng biệt. + +Trong v2.1, chúng tôi khuyến nghị bắt đầu với pages, và có thể thậm chí dừng lại ở đó. Hầu hết mọi người đã biết cách tách app thành các page riêng biệt, và pages cũng là điểm khởi đầu phổ biến khi cố gắng tìm một component trong codebase. Trong model phân tách mới này, bạn giữ phần lớn UI và logic trong mỗi page riêng biệt, duy trì một foundation có thể tái sử dụng trong Shared. Nếu phát sinh nhu cầu tái sử dụng business logic trên nhiều page, bạn có thể di chuyển nó xuống layer bên dưới. + +Một bổ sung khác cho Feature-Sliced Design là việc tiêu chuẩn hóa cross-imports giữa các entity với ký hiệu `@x`. + +## Cách migration \{#how-to-migrate} + +Không có breaking changes trong v2.1, điều này có nghĩa là một dự án được viết với FSD v2.0 cũng là một dự án hợp lệ trong FSD v2.1. Tuy nhiên, chúng tôi tin rằng mental model mới có lợi hơn cho các team và đặc biệt là onboarding các developer mới, vì vậy chúng tôi khuyến nghị thực hiện các điều chỉnh nhỏ đối với việc phân tách của bạn. + +### Merge slices + +Một cách đơn giản để bắt đầu là chạy linter của chúng tôi, [Steiger][steiger], trên dự án. Steiger được xây dựng với mental model mới, và các rule hữu ích nhất sẽ là: + +- [`insignificant-slice`][insignificant-slice] — nếu một entity hoặc feature chỉ được sử dụng trong một page, rule này sẽ đề xuất merge entity hoặc feature đó hoàn toàn vào page. +- [`excessive-slicing`][excessive-slicing] — nếu một layer có quá nhiều slices, thường là dấu hiệu cho thấy việc phân tách quá chi tiết. Rule này sẽ đề xuất merge hoặc nhóm một số slices để hỗ trợ navigation dự án. + +```bash +npx steiger src +``` + +Điều này sẽ giúp bạn xác định những slice chỉ được sử dụng một lần, để bạn có thể cân nhắc lại xem chúng có thực sự cần thiết không. Trong những cân nhắc như vậy, hãy nhớ rằng một layer tạo thành một loại global namespace cho tất cả các slice bên trong nó. Giống như bản sẽ không làm ô nhiễm global namespace với các biến chỉ được sử dụng một lần, bạn nên coi một vị trí trong namespace của layer là có giá trị, được sử dụng một cách tiết kiệm. + +### Tiêu chuẩn hóa cross-imports + +Nếu trước đây bạn đã có cross-imports giữa trong dự án của mình (chúng tôi không phán xét!), bây giờ bạn có thể tận dụng một ký hiệu mới cho cross-importing trong Feature-Sliced Design — ký hiệu `@x`. Nó trông như thế này: + +```ts title="entities/B/some/file.ts" +import type { EntityA } from "entities/A/@x/B"; +``` + +Để biết thêm chi tiết, hãy xem phần [Public API for cross-imports][public-api-for-cross-imports] trong reference. + +[insignificant-slice]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice +[steiger]: https://github.com/feature-sliced/steiger +[excessive-slicing]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/vi/docs/guides/tech/with-electron.mdx b/src/content/docs/vi/docs/guides/tech/with-electron.mdx new file mode 100644 index 0000000000..e7952845f2 --- /dev/null +++ b/src/content/docs/vi/docs/guides/tech/with-electron.mdx @@ -0,0 +1,137 @@ +--- +title: Sử dụng với Electron +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +Các ứng dụng Electron có kiến trúc đặc biệt gồm nhiều process với các trách nhiệm khác nhau. Việc áp dụng FSD trong bối cảnh như vậy yêu cầu phải thích nghi cấu trúc với các đặc điểm của Electron. + + +- src + - app Common app layer + - main Main process + - index.ts Main process entry point + - preload Preload script and Context Bridge + - index.ts Preload entry point + - renderer Renderer process + - index.html Renderer process entry point + - main + - features + - user + - ipc + - get-user.ts + - send-user.ts + - entities + - shared + - renderer + - pages + - settings + - ipc + - get-user.ts + - save-user.ts + - ui + - user.tsx + - index.ts + - home + - ui + - home.tsx + - index.ts + - widgets + - features + - entities + - shared + - shared Common code between main and renderer + - ipc IPC description (event names, contracts) + + +## Quy tắc Public API +Mỗi process phải có public API riêng của nó. Ví dụ, bạn không thể import các module từ `main` vào `renderer`. +Chỉ có thư mục `src/shared` là public cho cả hai process. +Nó cũng cần thiết để mô tả các hợp đồng cho tương tác giữa các process. + +## Các thay đổi bổ sung cho cấu trúc chuẩn +Được đề xuất sử dụng segment `ipc` mới, nơi diễn ra tương tác giữa các process. +Các layer `pages` và `widgets`, dựa trên tên gọi của chúng, không nên có mặt trong `src/main`. Bạn có thể sử dụng `features`, `entities` và `shared`. +Layer `app` trong `src` chứa các điểm đầu vào cho `main` và `renderer`, cũng như IPC. +Không mong muốn các segment trong layer `app` có điểm giao nhau + +## Ví dụ về tương tác + +```typescript title="src/shared/ipc/channels.ts" +export const CHANNELS = { + GET_USER_DATA: 'GET_USER_DATA', + SAVE_USER: 'SAVE_USER', +} as const; + +export type TChannelKeys = keyof typeof CHANNELS; +``` + +```typescript title="src/shared/ipc/events.ts" +import { CHANNELS } from './channels'; + +export interface IEvents { + [CHANNELS.GET_USER_DATA]: { + args: void, + response?: { name: string; email: string; }; + }; + [CHANNELS.SAVE_USER]: { + args: { name: string; }; + response: void; + }; +} +``` + +```typescript title="src/shared/ipc/preload.ts" +import { CHANNELS } from './channels'; +import type { IEvents } from './events'; + +type TOptionalArgs = T extends void ? [] : [args: T]; + +export type TElectronAPI = { + [K in keyof typeof CHANNELS]: (...args: TOptionalArgs) => IEvents[typeof CHANNELS[K]]['response']; +}; +``` + +```typescript title="src/app/preload/index.ts" +import { contextBridge, ipcRenderer } from 'electron'; +import { CHANNELS, type TElectronAPI } from 'shared/ipc'; + +const API: TElectronAPI = { + [CHANNELS.GET_USER_DATA]: () => ipcRenderer.sendSync(CHANNELS.GET_USER_DATA), + [CHANNELS.SAVE_USER]: args => ipcRenderer.invoke(CHANNELS.SAVE_USER, args), +} as const; + +contextBridge.exposeInMainWorld('electron', API); +``` + +```typescript title="src/main/features/user/ipc/send-user.ts" +import { ipcMain } from 'electron'; +import { CHANNELS } from 'shared/ipc'; + +export const sendUser = () => { + ipcMain.on(CHANNELS.GET_USER_DATA, ev => { + ev.returnValue = { + name: 'John Doe', + email: 'john.doe@example.com', + }; + }); +}; +``` + +```typescript title="src/renderer/pages/user-settings/ipc/get-user.ts" +import { CHANNELS } from 'shared/ipc'; + +export const getUser = () => { + const user = window.electron[CHANNELS.GET_USER_DATA](); + + return user ?? { name: 'John Donte', email: 'john.donte@example.com' }; +}; +``` + +## Xem thêm +- [Process Model Documentation](https://www.electronjs.org/docs/latest/tutorial/process-model) +- [Context Isolation Documentation](https://www.electronjs.org/docs/latest/tutorial/context-isolation) +- [Inter-Process Communication Documentation](https://www.electronjs.org/docs/latest/tutorial/ipc) +- [Example](https://github.com/feature-sliced/examples/tree/master/examples/electron) diff --git a/src/content/docs/vi/docs/guides/tech/with-nextjs.mdx b/src/content/docs/vi/docs/guides/tech/with-nextjs.mdx new file mode 100644 index 0000000000..a4c924ba01 --- /dev/null +++ b/src/content/docs/vi/docs/guides/tech/with-nextjs.mdx @@ -0,0 +1,196 @@ +--- +title: Sử dụng với Next.js +sidebar: + order: 1 +--- + +import { Tabs, TabItem, FileTree } from '@astrojs/starlight/components'; + +FSD tương thích với Next.js trong cả phiên bản App Router và Pages Router nếu bạn giải quyết được xung đột chính — thư mục `app` và `pages`. + +## App Router \{#app-router} + +### Xung đột giữa FSD và Next.js trong layer `app` \{#conflict-between-fsd-and-nextjs-in-the-app-layer} + +Next.js đề xuất sử dụng thư mục `app` để định nghĩa các route của ứng dụng. Nó mong đợi các file trong thư mục `app` tương ứng với các pathname. Cơ chế routing này **không phù hợp** với khái niệm FSD, vì không thể duy trì cấu trúc slice phẳng. + +Giải pháp là di chuyển thư mục `app` của Next.js vào thư mục gốc của dự án và import các pages FSD từ `src`, nơi chứa các layer FSD, vào thư mục `app` của Next.js. + +Bạn cũng cần thêm thư mục `pages` vào thư mục gốc của dự án, nếu không Next.js sẽ cố gắng sử dụng `src/pages` như Pages Router ngay cả khi bạn sử dụng App Router, điều này sẽ làm hỏng quá trình build. Cũng nên đặt file `README.md` bên trong thư mục `pages` gốc này để mô tả tại sao nó cần thiết, mặc dù nó trống. + + +- app App folder (Next.js) + - api + - get-example + - route.ts + - example + - page.tsx +- pages Empty pages folder (Next.js) + - README.md +- src + - app + - api-routes API routes + - pages + - example + - index.ts + - ui + - example.tsx + - widgets/ + - features/ + - entities/ + - shared/ + + +Ví dụ về việc re-export một page từ `src/pages` trong `app` của Next.js: + +```tsx title="app/example/page.tsx" +export { ExamplePage as default, metadata } from '@/pages/example'; +``` + +### Middleware \{#middleware} + +Nếu bạn sử dụng middleware trong dự án, nó phải được đặt ở thư mục gốc của dự án cùng với thư mục `app` và `pages` của Next.js. + +### Instrumentation \{#instrumentation} + +File `instrumentation.js` cho phép bạn giám sát hiệu suất và hành vi của ứng dụng. Nếu bạn sử dụng nó, nó phải được đặt ở thư mục gốc của dự án, tương tự như `middleware.js`. + +## Pages Router \{#pages-router} + +### Xung đột giữa FSD và Next.js trong layer `pages` \{#conflict-between-fsd-and-nextjs-in-the-pages-layer} + +Các route nên được đặt trong thư mục `pages` ở thư mục gốc của dự án, tương tự như thư mục `app` cho App Router. Cấu trúc bên trong `src` nơi các thư mục layer được đặt vẫn không thay đổi. + + +- pages Pages folder (Next.js) + - _app.tsx + - api + - example.ts API route re-export + - example + - index.tsx +- src + - app + - custom-app + - custom-app.tsx Custom App component + - api-routes + - get-example-data.ts API route + - pages + - example + - index.ts + - ui + - example.tsx + - widgets/ + - features/ + - entities/ + - shared/ + + +Ví dụ về việc re-export một page từ `src/pages` trong `pages` của Next.js: + +```tsx title="pages/example/index.tsx" +export { Example as default } from '@/pages/example'; +``` + +### Custom `_app` component \{#custom-_app-component} + +Bạn có thể đặt Custom App component của mình trong `src/app/_app` hoặc `src/app/custom-app`: + +```tsx title="src/app/custom-app/custom-app.tsx" +import type { AppProps } from 'next/app'; + +export const MyApp = ({ Component, pageProps }: AppProps) => { + return ( + <> +

My Custom App component

+ + + ); +}; +``` + +```tsx title="pages/_app.tsx" +export { App as default } from '@/app/custom-app'; +``` + +## Route Handlers (API routes) \{#route-handlers-api-routes} + +Sử dụng segment `api-routes` trong layer `app` để làm việc với Route Handlers. + +Hãy chú ý khi viết code backend trong cấu trúc FSD — FSD chủ yếu dành cho frontend, nghĩa là đó là điều mà mọi người sẽ mong đợi tìm thấy. +Nếu bạn cần nhiều endpoint, hãy cân nhắc tách chúng thành một package khác trong một monorepo. + + + + + +```tsx title="src/app/api-routes/get-example-data.ts" +import { getExamplesList } from '@/shared/db'; + +export const getExampleData = () => { + try { + const examplesList = getExamplesList(); + + return Response.json({ examplesList }); + } catch { + return Response.json(null, { + status: 500, + statusText: 'Ouch, something went wrong', + }); + } +}; +``` + +```tsx title="app/api/example/route.ts" +export { getExampleData as GET } from '@/app/api-routes'; +``` + + + + + +```tsx title="src/app/api-routes/get-example-data.ts" +import type { NextApiRequest, NextApiResponse } from 'next'; + +const config = { + api: { + bodyParser: { + sizeLimit: '1mb', + }, + }, + maxDuration: 5, +}; + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + res.status(200).json({ message: 'Hello from FSD' }); +}; + +export const getExampleData = { config, handler } as const; +``` + +```tsx title="src/app/api-routes/index.ts" +export { getExampleData } from './get-example-data'; +``` + +```tsx title="app/api/example.ts" +import { getExampleData } from '@/app/api-routes'; + +export const config = getExampleData.config; +export default getExampleData.handler; +``` + + + + + +## Các đề xuất bổ sung \{#additional-recommendations} + +- Sử dụng segment `db` trong layer `shared` để mô tả các database query và việc sử dụng chúng ở các layer cao hơn. +- Logic caching và revalidating queries tốt nhất nên được giữ cùng chỗ với các query. + +## Xem thêm \{#see-also} + +- [Next.js Project Structure](https://nextjs.org/docs/app/getting-started/project-structure) +- [Next.js Page Layouts](https://nextjs.org/docs/app/getting-started/layouts-and-pages) + +[project-knowledge]: /docs/about/understanding/knowledge-types +[ext-app-router-stackblitz]: https://stackblitz.com/edit/stackblitz-starters-aiez55?file=README.md diff --git a/src/content/docs/vi/docs/guides/tech/with-nuxtjs.mdx b/src/content/docs/vi/docs/guides/tech/with-nuxtjs.mdx new file mode 100644 index 0000000000..2226507f2e --- /dev/null +++ b/src/content/docs/vi/docs/guides/tech/with-nuxtjs.mdx @@ -0,0 +1,180 @@ +--- +title: Sử dụng với NuxtJS +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +Có thể triển khai FSD trong dự án NuxtJS, nhưng xảy ra xung đột do sự khác biệt giữa yêu cầu cấu trúc dự án của NuxtJS và các nguyên tắc FSD: + +- Ban đầu, NuxtJS cung cấp cấu trúc file dự án không có thư mục `src`, tức là ở thư mục gốc của dự án. +- File routing nằm trong thư mục `pages`, trong khi ở FSD thư mục này được dành riêng cho cấu trúc slice phẳng. + + +## Thêm alias cho thư mục `src` + +Thêm đối tượng `alias` vào config của bạn: +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Not FSD related, enabled at project startup + alias: { + "@": '../src' + }, +}) +``` +## Chọn cách cấu hình router + +Trong NuxtJS, có hai cách để tùy chỉnh routing - sử dụng config và sử dụng cấu trúc file. +Trong trường hợp file-based routing, bạn sẽ tạo các file index.vue trong các thư mục bên trong thư mục app/routes, và trong trường hợp configure, bạn sẽ cấu hình các router trong file `router.options.ts`. + + +### Routing sử dụng config + +Trong layer `app`, tạo file `router.options.ts` và export một config object từ nó: +```ts title="app/router.options.ts" +import type { RouterConfig } from '@nuxt/schema'; + +export default { + routes: (_routes) => [], +}; + +``` + +Để thêm page `Home` vào dự án của bạn, bạn cần thực hiện các bước sau: +- Thêm một page slice bên trong layer `pages` +- Thêm route phù hợp vào config `app/router.config.ts` + + +Để tạo một page slice, hãy sử dụng [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +Tạo file ``home-page.vue`` bên trong segment ui, truy cập nó bằng Public API + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +Vậy, cấu trúc file sẽ trông như thế này: + +- src + - app + - router.config.ts + - pages + - home + - ui + - home-page.vue + - index.ts + +Cuối cùng, hãy thêm một route vào config: + +```ts title="app/router.config.ts" +import type { RouterConfig } from '@nuxt/schema' + +export default { + routes: (_routes) => [ + { + name: 'home', + path: '/', + component: () => import('@/pages/home.vue').then(r => r.default || r) + } + ], +} +``` + +### File Routing + +Trước tiên, tạo thư mục `src` trong thư mục gốc của dự án của bạn, và tạo các layer app và pages bên trong thư mục này cùng với thư mục routes bên trong layer app. +Vậy, cấu trúc file của bạn nên trông như thế này: + + +- src + - app + - routes + - pages Pages folder, related to FSD + + +Để NuxtJS sử dụng thư mục routes bên trong layer `app` cho file routing, bạn cần sửa đổi `nuxt.config.ts` như sau: +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Not FSD related, enabled at project startup + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes' + } +}) +``` + +Bây giờ, bạn có thể tạo các route cho pages trong `app` và kết nối các page từ `pages` với chúng. + +Ví dụ, để thêm page `Home` vào dự án của bạn, bạn cần thực hiện các bước sau: +- Thêm một page slice bên trong layer `pages` +- Thêm route tương ứng bên trong layer `app` +- Kết nối page từ slice với route + +Để tạo một page slice, hãy sử dụng [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +Tạo file ``home-page.vue`` bên trong segment ui, truy cập nó bằng Public API + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +Tạo một route cho page này bên trong layer `app`: + + +- src + - app + - routes + - index.vue + - pages + - home + - ui + - home-page.vue + - index.ts + + +Thêm page component của bạn vào bên trong file `index.vue`: + +```html title="src/app/routes/index.vue" + + + +``` + +## Làm gì với `layouts`? + +Bạn có thể đặt layouts bên trong layer `app`, để làm điều này bạn cần sửa đổi config như sau: + +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Not related to FSD, enabled at project startup + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes', + layouts: './src/app/layouts' + } +}) +``` + + +## Xem thêm + +- [Documentation on changing directory config in NuxtJS](https://nuxt.com/docs/api/nuxt-config#dir) +- [Documentation on changing router config in NuxtJS](https://nuxt.com/docs/guide/recipes/custom-routing#router-config) +- [Documentation on changing aliases in NuxtJS](https://nuxt.com/docs/api/nuxt-config#alias) diff --git a/src/content/docs/vi/docs/guides/tech/with-react-query.mdx b/src/content/docs/vi/docs/guides/tech/with-react-query.mdx new file mode 100644 index 0000000000..db3b8c89af --- /dev/null +++ b/src/content/docs/vi/docs/guides/tech/with-react-query.mdx @@ -0,0 +1,440 @@ +--- +title: Sử dụng với React Query +sidebar: + order: 10 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## Vấn đề "nên đặt các key ở đâu" + +### Giải pháp — phân chia theo entities + +Nếu dự án đã có sự phân chia thành các entity, và mỗi request tương ứng với một entity duy nhất, +cách phân chia thuần khiết nhất sẽ là theo entity. Trong trường hợp này, chúng tôi đề xuất sử dụng cấu trúc sau: + +- src/ + - app/ + - ... + - pages/ + - ... + - entities/ + - \{entity\}/ + - ... + - api/ + - `{entity}.query` Query-factory where are the keys and functions + - `get-{entity}` Entity getter function + - `create-{entity}` Entity creation function + - `update-{entity}` Entity update function + - `delete-{entity}` Entity delete function + - ... + - features/ + - ... + - widgets/ + - ... + - shared/ + - ... + + +Nếu có kết nối giữa các entity (ví dụ, entity Country có field-list của các entity City), +thì bạn có thể sử dụng [public API for cross-imports][public-api-for-cross-imports] hoặc cân nhắc giải pháp thay thế bên dưới. + +### Giải pháp thay thế — giữ trong shared + +Trong các trường hợp mà việc tách biệt entity không phù hợp, có thể cân nhắc cấu trúc sau: + + +- src/ + - ... + - shared/ + - api/ + - ... + - queries Query-factories + - document.ts + - background-jobs.ts + - ... + - index.ts + + +Sau đó trong `@/shared/api/index.ts`: + +```ts title="@/shared/api/index.ts" +export { documentQueries } from "./queries/document"; +``` + +## Vấn đề "Đặt mutations ở đâu?" + +Không nên trộn lẫn mutations với queries. Có hai lựa chọn: + +### 1. Định nghĩa một custom hook trong segment `api` gần nơi sử dụng + +```tsx title="@/features/update-post/api/use-update-title.ts" +export const useUpdateTitle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, newTitle }) => + apiClient + .patch(`/posts/${id}`, { title: newTitle }) + .then((data) => console.log(data)), + + onSuccess: (newPost) => { + queryClient.setQueryData(postsQueries.ids(id), newPost); + }, + }); +}; +``` + +### 2. Định nghĩa một mutation function ở nơi khác (Shared hoặc Entities) và sử dụng `useMutation` trực tiếp trong component + +```tsx +const { mutateAsync, isPending } = useMutation({ + mutationFn: postApi.createPost, +}); +``` + +```tsx title="@/pages/post-create/ui/post-create-page.tsx" +export const CreatePost = () => { + const { classes } = useStyles(); + const [title, setTitle] = useState(""); + + const { mutate, isPending } = useMutation({ + mutationFn: postApi.createPost, + }); + + const handleChange = (e: ChangeEvent) => + setTitle(e.target.value); + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutate({ title, userId: DEFAULT_USER_ID }); + }; + + return ( +
+ + + Create + + + ); +}; +``` + +## Tổ chức các request + +### Query factory + +Một query factory là một object mà các giá trị key là các function trả về một danh sách các query key. Đây là cách sử dụng nó: + +```ts +const keyFactory = { + all: () => ["entity"], + lists: () => [...postQueries.all(), "list"], +}; +``` + +:::info +`queryOptions` là một utility tích hợp sẵn trong react-query@v5 (tùy chọn) + +```ts +queryOptions({ + queryKey, + ...options, +}); +``` + +Để có type safety tốt hơn, tương thích với các phiên bản tương lai của react-query, và dễ dàng truy cập các function và query key, +bạn có thể sử dụng function queryOptions tích hợp sẵn từ "@tanstack/react-query" +[(Chi tiết thêm tại đây)](https://tkdodo.eu/blog/the-query-options-api#queryoptions). +::: + +### 1. Tạo một Query Factory + +```tsx title="@/entities/post/api/post.queries.ts" +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; +import { getDetailPost } from "./get-detail-post"; +import { PostDetailQuery } from "./query/post.query"; + +export const postQueries = { + all: () => ["posts"], + + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), + + details: () => [...postQueries.all(), "detail"], + detail: (query?: PostDetailQuery) => + queryOptions({ + queryKey: [...postQueries.details(), query?.id], + queryFn: () => getDetailPost({ id: query?.id }), + staleTime: 5000, + }), +}; +``` + +### 2. Sử dụng Query Factory trong code ứng dụng +```tsx +import { useParams } from "react-router-dom"; +import { postApi } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; + +type Params = { + postId: string; +}; + +export const PostPage = () => { + const { postId } = useParams(); + const id = parseInt(postId || ""); + const { + data: post, + error, + isLoading, + isError, + } = useQuery(postApi.postQueries.detail({ id })); + + if (isLoading) { + return
Loading...
; + } + + if (isError || !post) { + return <>{error?.message}; + } + + return ( +
+

Post id: {post.id}

+
+

{post.title}

+
+

{post.body}

+
+
+
Owner: {post.userId}
+
+ ); +}; +``` + +### Lợi ích của việc sử dụng Query Factory +- **Cấu trúc hóa request:** Factory cho phép bạn tổ chức tất cả API request tại một nơi, giúp code dễ đọc và bảo trì hơn. +- **Truy cập thuận tiện vào query và key:** Factory cung cấp các method thuận tiện để truy cập các loại query khác nhau và key của chúng. +- **Khả năng refetch query:** Factory cho phép refetch dễ dàng mà không cần thay đổi query key ở các phần khác nhau của ứng dụng. + +## Phân trang + +Trong phần này, chúng ta sẽ xem xét ví dụ về function `getPosts`, thực hiện API request để lấy các post entity sử dụng phân trang. + +### 1. Tạo function `getPosts` +Function getPosts nằm trong file `get-posts.ts`, được đặt trong segment `api` + +```tsx title="@/pages/post-feed/api/get-posts.ts" +import { apiClient } from "@/shared/api/base"; + +import { PostWithPaginationDto } from "./dto/post-with-pagination.dto"; +import { PostQuery } from "./query/post.query"; +import { mapPost } from "./mapper/map-post"; +import { PostWithPagination } from "../model/post-with-pagination"; + +const calculatePostPage = (totalCount: number, limit: number) => + Math.floor(totalCount / limit); + +export const getPosts = async ( + page: number, + limit: number, +): Promise => { + const skip = page * limit; + const query: PostQuery = { skip, limit }; + const result = await apiClient.get("/posts", query); + + return { + posts: result.posts.map((post) => mapPost(post)), + limit: result.limit, + skip: result.skip, + total: result.total, + totalPages: calculatePostPage(result.total, limit), + }; +}; +``` + +### 2. Query factory cho phân trang +Query factory `postQueries` định nghĩa các query option khác nhau để làm việc với post, +bao gồm request danh sách post với page và limit cụ thể. + +```tsx +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; + +export const postQueries = { + all: () => ["posts"], + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), +}; +``` + + +### 3. Sử dụng trong code ứng dụng + +```tsx title="@/pages/home/ui/index.tsx" +export const HomePage = () => { + const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN; + const [page, setPage] = usePageParam(DEFAULT_PAGE); + const { data, isFetching, isLoading } = useQuery( + postApi.postQueries.list(page, itemsOnScreen), + ); + return ( + <> + setPage(page)} + page={page} + count={data?.totalPages} + variant="outlined" + color="primary" + /> + + + ); +}; +``` + + +## `QueryProvider` để quản lý queries +Trong hướng dẫn này, chúng ta sẽ xem cách tổ chức một `QueryProvider`. + +### 1. Tạo một `QueryProvider` +File `query-provider.tsx` nằm tại đường dẫn `@/app/providers/query-provider.tsx`. + +```tsx title="@/app/providers/query-provider.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; + client: QueryClient; +}; + +export const QueryProvider = ({ client, children }: Props) => { + return ( + + {children} + + + ); +}; +``` + +### 2. Tạo một `QueryClient` +`QueryClient` là một instance được sử dụng để quản lý các API request. +File `query-client.ts` nằm tại `@/shared/api/query-client.ts`. +`QueryClient` được tạo với các cài đặt nhất định cho việc cache query. + +```tsx title="@/shared/api/query-client.ts" +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }, + }, +}); +``` + +## Tự động sinh code + +Có các công cụ có thể tự động sinh API code cho bạn, nhưng chúng kém linh hoạt hơn so với cách tiếp cận thủ công được mô tả ở trên. +Nếu file Swagger của bạn có cấu trúc tốt, +và bạn đang sử dụng một trong những công cụ này, việc sinh tất cả code trong thư mục `@/shared/api` có thể hợp lý. + + +## Lời khuyên bổ sung cho việc tổ chức RQ +### API Client + +Sử dụng một class API client tùy chỉnh trong layer shared, +bạn có thể chuẩn hóa cấu hình và làm việc với API trong dự án. +Điều này cho phép bạn quản lý logging, +header và định dạng trao đổi dữ liệu (như JSON hoặc XML) từ một nơi. +Cách tiếp cận này giúp dễ dàng bảo trì và phát triển dự án vì nó đơn giản hóa các thay đổi và cập nhật tương tác với API. + +```tsx title="@/shared/api/api-client.ts" +import { API_URL } from "@/shared/config"; + +export class ApiClient { + private baseUrl: string; + + constructor(url: string) { + this.baseUrl = url; + } + + async handleResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + try { + return await response.json(); + } catch (error) { + throw new Error("Error parsing JSON response"); + } + } + + public async get( + endpoint: string, + queryParams?: Record, + ): Promise { + const url = new URL(endpoint, this.baseUrl); + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.append(key, value.toString()); + }); + } + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + return this.handleResponse(response); + } + + public async post>( + endpoint: string, + body: TData, + ): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return this.handleResponse(response); + } +} + +export const apiClient = new ApiClient(API_URL); +``` + +## Xem thêm \{#see-also} + +- [(GitHub) Project mẫu](https://github.com/ruslan4432013/fsd-react-query-example) +- [(CodeSandbox) Project mẫu](https://codesandbox.io/p/github/ruslan4432013/fsd-react-query-example/main) +- [Về query factory](https://tkdodo.eu/blog/the-query-options-api) + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/vi/docs/guides/tech/with-sveltekit.mdx b/src/content/docs/vi/docs/guides/tech/with-sveltekit.mdx new file mode 100644 index 0000000000..c3e80fac5f --- /dev/null +++ b/src/content/docs/vi/docs/guides/tech/with-sveltekit.mdx @@ -0,0 +1,100 @@ +--- +title: Sử dụng với SvelteKit +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +Có thể triển khai FSD trong dự án SvelteKit, nhưng xảy ra xung đột do sự khác biệt giữa yêu cầu cấu trúc của dự án SvelteKit và các nguyên tắc của FSD: + +- Ban đầu, SvelteKit cung cấp cấu trúc file bên trong thư mục `src/routes`, trong khi ở FSD thì routing phải là một phần của layer `app`. +- SvelteKit đề xuất đặt mọi thứ không liên quan đến routing trong thư mục `src/lib`. + + +## Hãy thiết lập config + +```ts title="svelte.config.ts" +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config}*/ +const config = { + preprocess: [vitePreprocess()], + kit: { + adapter: adapter(), + files: { + routes: 'src/app/routes', // move routing inside the app layer + lib: 'src', + appTemplate: 'src/app/index.html', // Move the application entry point inside the app layer + assets: 'public' + }, + alias: { + '@/*': 'src/*' // Create an alias for the src directory + } + } +}; +export default config; +``` + +## Di chuyển file routing vào `src/app`. + +Hãy tạo một layer app, di chuyển điểm đầu vào `index.html` của app vào đó, và tạo một thư mục routes. +Vậy, cấu trúc file của bạn nên trông như thế này: + + +- src + - app + - index.html + - routes + - pages FSD Pages folder + + +Bây giờ, bạn có thể tạo các route cho pages trong `app` và kết nối các page từ `pages` với chúng. + +Ví dụ, để thêm một home page vào dự án của bạn, bạn cần thực hiện các bước sau: +- Thêm một page slice bên trong layer `pages` +- Thêm route tương ứng vào thư mục `routes` từ layer `app` +- Canh chỉnh page từ slice với route + +Để tạo một page slice, hãy sử dụng [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +Tạo file ``home-page.svelte`` bên trong segment ui, truy cập nó bằng Public API + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page.svelte'; +``` + +Tạo một route cho page này bên trong layer `app`: + + +- src + - app + - routes + - +page.svelte + - index.html + - pages + - home + - ui + - home-page.svelte + - index.ts + + +Thêm page component của bạn vào bên trong file `+page.svelte`: + +```html title="src/app/routes/+page.svelte" + + + + +``` + +## Xem thêm + +- [Documentation on changing directory config in SvelteKit](https://kit.svelte.dev/docs/configuration#files) diff --git a/src/content/docs/vi/docs/llms.mdx b/src/content/docs/vi/docs/llms.mdx new file mode 100644 index 0000000000..4eeedae424 --- /dev/null +++ b/src/content/docs/vi/docs/llms.mdx @@ -0,0 +1,18 @@ +--- +title: Tài liệu cho LLMs +--- + +Trang này cung cấp các liên kết và hướng dẫn cho các LLM crawler. + +- Spec: https://llmstxt.org/ + +### Files + +- llms.txt +- llms-small.txt +- llms-full.txt + +### Ghi chú + +- Các file được phục vụ từ root của site, bất kể đường dẫn trang hiện tại. +- Trong các triển khai có base URL không phải root (ví dụ `/documentation/`), các liên kết phía trên sẽ được tự động thêm prefix. diff --git a/src/content/docs/vi/docs/reference/layers.mdx b/src/content/docs/vi/docs/reference/layers.mdx new file mode 100644 index 0000000000..84f3965bf0 --- /dev/null +++ b/src/content/docs/vi/docs/reference/layers.mdx @@ -0,0 +1,153 @@ +--- +title: Layers +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +Layer là cấp độ đầu tiên của hệ thống phân cấp tổ chức trong Feature-Sliced Design. Mục đích của chúng là phân tách code dựa trên mức độ trách nhiệm cần thiết và số lượng module khác trong app mà nó phụ thuộc vào. Mỗi layer mang ý nghĩa ngữ nghĩa đặc biệt để giúp bạn xác định mức độ trách nhiệm mà bạn nên phân bổ cho code của mình. + +Có tổng cộng **7 layer**, được sắp xếp từ nhiều trách nhiệm và dependency nhất đến ít nhất: + +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. + +1. App +2. Processes (deprecated) +3. Pages +4. Widgets +5. Features +6. Entities +7. Shared + +Bạn không cần phải sử dụng mọi layer trong dự án của mình — chỉ thêm chúng nếu bạn nghĩ nó mang lại giá trị cho dự án của bạn. Thông thường, hầu hết các dự án frontend sẽ có ít nhất các layer Shared, Pages và App. + +Trong thực tế, layer là các folder với tên viết thường (ví dụ: `📁 shared`, `📁 pages`, `📁 app`). Việc thêm layer mới _không được khuyến nghị_ vì ngữ nghĩa của chúng đã được chuẩn hóa. + +## Import rule trên layer + +Layer được tạo thành từ các _slice_ — các nhóm module có tính gắn kết cao. Dependency giữa các slice được điều chỉnh bởi **import rule trên layer**: + +> _Một module (file) trong slice chỉ có thể import các slice khác khi chúng nằm trên các layer thấp hơn một cách nghiêm ngặt._ + +Ví dụ, folder `📁 ~/features/aaa` là một slice với tên "aaa". Một file bên trong nó, `~/features/aaa/api/request.ts`, không thể import code từ bất kỳ file nào trong `📁 ~/features/bbb`, nhưng có thể import code từ `📁 ~/entities` và `📁 ~/shared`, cũng như bất kỳ code anh em nào từ `📁 ~/features/aaa`, ví dụ `~/features/aaa/lib/cache.ts`. + +Layer App và Shared là **ngoại lệ** của quy tắc này — chúng vừa là layer vừa là slice cùng một lúc. Slice phân chia code theo business domain, và hai layer này là ngoại lệ vì Shared không có business domain, và App kết hợp tất cả business domain. + +Trong thực tế, điều này có nghĩa là layer App và Shared được tạo thành từ các segment, và các segment có thể import lẫn nhau một cách tự do. + +## Định nghĩa layer + +Phần này mô tả ý nghĩa ngữ nghĩa của từng layer để tạo ra trực giác về loại code nào thuộc về đó. + +### Shared + +Layer này tạo thành nền tảng cho phần còn lại của app. Đây là nơi tạo kết nối với thế giới bên ngoài, ví dụ: backend, third-party library, environment. Đây cũng là nơi định nghĩa các library có tính chứa đựng cao của riêng bạn. + +Layer này, giống như layer App, _không chứa slice_. Slice được dùng để chia layer thành các business domain, nhưng business domain không tồn tại trong Shared. Điều này có nghĩa là tất cả file trong Shared có thể tham chiếu và import lẫn nhau. + +Dưới đây là các segment mà bạn thường có thể tìm thấy trong layer này: + +- `📁 api` — API client và có thể cả các function để thực hiện request đến các endpoint backend cụ thể. +- `📁 ui` — bộ UI kit của ứng dụng. + Các component trên layer này không nên chứa business logic, nhưng có thể có chủ đề business. Ví dụ, bạn có thể đặt logo công ty và layout trang ở đây. Các component có UI logic cũng được cho phép (ví dụ: autocomplete hoặc search bar). +- `📁 lib` — tập hợp các internal library. + Folder này không nên được coi như helper hoặc utility ([đọc ở đây tại sao những folder này thường trở thành bãi rác][ext-sova-utility-dump]). Thay vào đó, mỗi library trong folder này nên có một lĩnh vực tập trung, ví dụ: date, color, text manipulation, v.v. Lĩnh vực tập trung đó nên được ghi lại trong file README. Các developer trong team của bạn nên biết có thể thêm gì và không thể thêm gì vào những library này. +- `📁 config` — environment variable, global feature flag và các cấu hình global khác cho app của bạn. +- `📁 routes` — route constant hoặc pattern để matching route. +- `📁 i18n` — setup code cho translation, global translation string. + +Bạn được tự do thêm nhiều segment hơn, nhưng hãy đảm bảo rằng tên của những segment này mô tả mục đích của nội dung, không phải bản chất của nó. Ví dụ, `components`, `hooks`, và `types` là những tên segment tệ vì chúng không hữu ích khi bạn đang tìm kiếm code. + +### Entities + +Các slice trên layer này đại diện cho các khái niệm từ thế giới thực mà dự án đang làm việc. Thông thường, chúng là các thuật ngữ mà business sử dụng để mô tả sản phẩm. Ví dụ, một mạng xã hội có thể làm việc với các business entity như User, Post và Group. + +Một entity slice có thể chứa data storage (`📁 model`), data validation schema (`📁 model`), các function API request liên quan đến entity (`📁 api`), cũng như visual representation của entity này trong interface (`📁 ui`). Visual representation không cần phải tạo ra một UI block hoàn chỉnh — nó chủ yếu nhằm tái sử dụng cùng một appearance trên nhiều page trong app, và các business logic khác nhau có thể được gắn vào nó thông qua props hoặc slot. + +#### Mối quan hệ entity + +Entity trong FSD là các slice, và mặc định, các slice không thể biết về nhau. Tuy nhiên, trong đời thực, các entity thường tương tác với nhau, và đôi khi một entity sở hữu hoặc chứa các entity khác. Vì vậy, business logic của những tương tác này tốt nhất nên được giữ ở các layer cao hơn, như Features hoặc Pages. + +Khi data object của một entity chứa các data object khác, thường là ý tưởng tốt để làm cho kết nối giữa các entity trở nên rõ ràng và bỏ qua slice isolation bằng cách tạo cross-reference API với ký hiệu `@x`. Lý do là các entity được kết nối cần được refactor cùng nhau, vì vậy tốt nhất là làm cho kết nối không thể bỏ sót. + +For example: + +```ts title="entities/artist/model/artist.ts" +import type { Song } from "entities/song/@x/artist"; + +export interface Artist { + name: string; + songs: Array; +} +``` + +```ts title="entities/song/@x/artist.ts" +export type { Song } from "../model/song.ts"; +``` + +Tìm hiểu thêm về ký hiệu `@x` trong phần [Public API cho cross-import][public-api-for-cross-imports]. + +### Features + +Layer này dành cho các tương tác chính trong app của bạn, những thứ mà người dùng quan tâm để làm. Các tương tác này thường liên quan đến các business entity, vì đó là nội dung của app. + +Một nguyên tắc quan trọng để sử dụng layer Features hiệu quả là: **không phải mọi thứ đều cần là feature**. Một chỉ báo tốt để biết thứ gì cần là feature là nó được tái sử dụng trên nhiều page. + +Ví dụ, nếu app có nhiều editor, và tất cả chúng đều có comment, thì comment là một feature được tái sử dụng. Hãy nhớ rằng slice là cơ chế để tìm code nhanh chóng, và nếu có quá nhiều feature, những cái quan trọng sẽ bị chìm nghỉm. + +Lý tưởng, khi bạn đến một dự án mới, bạn sẽ khám phá tính năng của nó bằng cách xem qua các page và feature. Khi quyết định thứ gì nên là feature, hãy tối ưu hóa cho trải nghiệm của người mới vào dự án để nhanh chóng khám phá các khu vực code lớn quan trọng. + +Một feature slice có thể chứa UI để thực hiện tương tác như form (`📁 ui`), các API call cần thiết để thực hiện action (`📁 api`), validation và internal state (`📁 model`), feature flag (`📁 config`). + +### Widgets + +Layer Widgets được thiết kế cho các UI block lớn tự đủ. Widget hữu ích nhất khi chúng được tái sử dụng trên nhiều page, hoặc khi page mà chúng thuộc về có nhiều block độc lập lớn, và đây là một trong số chúng. + +Nếu một UI block tạo nên phần lớn nội dung thú vị trên page, và không bao giờ được tái sử dụng, nó **không nên là widget**, và thay vào đó nên được đặt trực tiếp bên trong page đó. + + + +### Pages + +Page là thứ tạo nên các website và application (cũng được biết đến là screen hoặc activity). Một page thường tương ứng với một slice, tuy nhiên, nếu có nhiều page rất giống nhau, chúng có thể được nhóm vào một slice, ví dụ registration và login form. + +Không có giới hạn về lượng code bạn có thể đặt trong page slice miễn là team của bạn vẫn thấy dễ navigate. Nếu một UI block trên page không được tái sử dụng, hoàn toàn ổn khi giữ nó bên trong page slice. + +Trong page slice bạn thường có thể tìm thấy UI của page cũng như loading state và error boundary (`📁 ui`) và các data fetching và mutating request (`📁 api`). Không phổ biến đề page có data model riêng biệt, và các bit state nhỏ có thể được giữ trong chính các component. + +### Processes + + + +Process là escape hatch cho các tương tác nhiều page. + +Layer này cố tình được để không định nghĩa. Hầu hết các ứng dụng không nên sử dụng layer này, và giữ logic cấp router và cấp server trên layer App. Chỉ cân nhắc sử dụng layer này khi layer App phát triển đủ lớn để trở nên không thể bảo trì và cần giảm tải. + +### App + +Mọi loại vấn đề app-wide, cả trong nghĩa kỹ thuật (ví dụ: context provider) và trong nghĩa business (ví dụ: analytics). + +Layer này thường không chứa slice, cũng giống như Shared, thay vào đó có các segment trực tiếp. + +Dưới đây là các segment mà bạn thường có thể tìm thấy trong layer này: + +- `📁 routes` — router configuration +- `📁 store` — global store configuration +- `📁 styles` — global style +- `📁 entrypoint` — entrypoint đến application code, framework-specific + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports +[ext-remix]: https://remix.run +[ext-sova-utility-dump]: https://dev.to/sergeysova/why-utils-helpers-is-a-dump-45fo diff --git a/src/content/docs/vi/docs/reference/public-api.mdx b/src/content/docs/vi/docs/reference/public-api.mdx new file mode 100644 index 0000000000..88e799a245 --- /dev/null +++ b/src/content/docs/vi/docs/reference/public-api.mdx @@ -0,0 +1,157 @@ +--- +title: Public API +sidebar: + order: 3 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Public API là một _hợp đồng_ giữa một nhóm module, như một slice, và code sử dụng nó. Nó cũng hoạt động như một cổng kiểm soát, chỉ cho phép truy cập đến các đối tượng nhất định và chỉ thông qua public API đó. + +Trong thực tế, nó thường được triển khai dưới dạng file index với các re-export: + +```js title="pages/auth/index.js" +export { LoginPage } from "./ui/LoginPage"; +export { RegisterPage } from "./ui/RegisterPage"; +``` + +## Điều gì tạo nên một public API tốt? + +Một public API tốt làm cho việc sử dụng và tích hợp slice vào code khác trở nên thuận tiện và đáng tin cậy. Điều này có thể đạt được bằng cách thiết lập ba mục tiêu sau: + +1. Phần còn lại của ứng dụng phải được bảo vệ khỏi các thay đổi cấu trúc của slice, như refactoring +1. Những thay đổi đáng kể trong hành vi của slice mà phá vỡ các kỳ vọng trước đó phải gây ra thay đổi trong public API +1. Chỉ những phần cần thiết của slice mới nên được expose + +Mục tiêu cuối cùng có một số hàm ý thực tế quan trọng. Có thể rất hấp dẫn khi tạo wildcard re-export cho mọi thứ, đặc biệt trong giai đoạn phát triển đầu của slice, vì bất kỳ đối tượng mới nào bạn export từ các file cũng sẽ tự động được export từ slice: + +```js title="Bad practice, features/comments/index.js" +// ❌ BAD CODE BELOW, DON'T DO THIS +export * from "./ui/Comment"; // 👎 don't try this at home +export * from "./model/comments"; // 💩 this is bad practice +``` + +Điều này làm tổn hại khả năng khám phá của slice vì bạn không thể dễ dàng biết được interface của slice này là gì. Không biết interface có nghĩa là bạn phải đào sâu vào code của slice để hiểu cách tích hợp nó. Một vấn đề khác là bạn có thể vô tình expose các module internal, điều này sẽ khiến refactoring trở nên khó khăn nếu ai đó bắt đầu phụ thuộc vào chúng. + +## Public API cho cross-imports \{#public-api-for-cross-imports} + +Cross-import là tình huống khi một slice import từ slice khác trên cùng layer. Thông thường điều này bị cấm bởi [import rule on layers][import-rule-on-layers], nhưng thường có những lý do chính đáng để cross-import. Ví dụ, các business entity thường tham chiếu lẫn nhau trong thế giới thực, và tốt nhất là phản ánh những mối quan hệ này trong code thay vì tránh chúng. + +Cho mục đích này, có một loại public API đặc biệt, còn được biết đến với tên gọi `@x`-notation. Nếu bạn có entity A và B, và entity B cần import từ entity A, thì entity A có thể khai báo một public API riêng chỉ dành cho entity B. + + +- entities + - A + - @x + - B.ts một public API đặc biệt chỉ dành cho code bên trong `entities/B/` + - index.ts public API thông thường + + +Sau đó code bên trong `entities/B/` có thể import từ `entities/A/@x/B`: + +```ts +import type { EntityA } from "entities/A/@x/B"; +``` + +Ký hiệu `A/@x/B` có nghĩa là "A crossed with B". + + + +## Vấn đề với index file + +Các index file như `index.js`, còn được gọi là barrel file, là cách phổ biến nhất để định nghĩa public API. Chúng dễ tạo, nhưng được biết đến là gây ra vấn đề với một số bundler và framework nhất định. + +### Circular import + +Circular import là khi hai hoặc nhiều file import lẫn nhau theo vòng tròn. + +
+ Three files importing each other in a circle + Three files importing each other in a circle +
+ Minh họa ở trên: ba file, `fileA.js`, `fileB.js`, và `fileC.js`, import lẫn nhau theo vòng tròn. +
+
+ +Những tình huống này thường khó khăn để bundler xử lý, và trong một số trường hợp chúng thậm chí có thể dẫn đến runtime error khó debug. + +Circular import có thể xảy ra mà không cần index file, nhưng việc có index file tạo ra cơ hội rõ ràng để vô tình tạo circular import. Điều này thường xảy ra khi bạn có hai object được expose trong public API của slice, ví dụ `HomePage` và `loadUserStatistics`, và `HomePage` cần truy cập `loadUserStatistics`, nhưng nó làm như thế này: + +```jsx title="pages/home/ui/HomePage.jsx" +import { loadUserStatistics } from "../"; // importing from pages/home/index.js + +export function HomePage() { /* … */ } +``` + +```js title="pages/home/index.js" +export { HomePage } from "./ui/HomePage"; +export { loadUserStatistics } from "./api/loadUserStatistics"; +``` + +Tình huống này tạo ra circular import, vì `index.js` import `ui/HomePage.jsx`, nhưng `ui/HomePage.jsx` import `index.js`. + +Để ngăn chặn vấn đề này, hãy xem xét hai nguyên tắc sau. Nếu bạn có hai file, và một file import từ file kia: +- Khi chúng ở trong cùng slice, luôn sử dụng import _relative_ và viết đầy đủ đường dẫn import +- Khi chúng ở trong các slice khác nhau, luôn sử dụng import _absolute_, ví dụ với alias + +### Bundle lớn và tree-shaking bị hỏng trong Shared \{#large-bundles} + +Một số bundler có thể gặp khó khăn trong việc tree-shake (loại bỏ code không được import) khi bạn có index file re-export mọi thứ. + +Thông thường đây không phải là vấn đề cho public API, vì nội dung của một module thường có liên quan chặt chẽ với nhau, nên bạn hiếm khi cần import một thứ và tree-shake đi thứ khác. Tuy nhiên, có hai trường hợp rất phổ biến khi các quy tắc thông thường của public API trong FSD có thể dẫn đến vấn đề — `shared/ui` và `shared/lib`. + +Hai folder này đều là tập hợp các thứ không liên quan mà thường không cần thiết tất cả ở một nơi. Ví dụ, `shared/ui` có thể có module cho mỗi component trong UI library: + + +- shared/ + - ui/ + - button/ + - text-field/ + - carousel/ + - accordion/ + + +Vấn đề này trở nên tồi tệ hơn khi một trong những module này có dependency nặng, như syntax highlighter hoặc drag'n'drop library. Bạn không muốn kéo chúng vào mọi page sử dụng thứ gì đó từ `shared/ui`, ví dụ như một button. + +Nếu bundle của bạn phát triển không mong muốn do một public API duy nhất trong `shared/ui` hoặc `shared/lib`, được khuyến nghị thay vào đó hãy có một index file riêng cho mỗi component hoặc library: + + +- shared/ + - ui/ + - button/ + - index.js + - text-field/ + - index.js + + +Sau đó các consumer của những component này có thể import chúng trực tiếp như thế này: + +```js title="pages/sign-in/ui/SignInPage.jsx" +import { Button } from '@/shared/ui/button'; +import { TextField } from '@/shared/ui/text-field'; +``` + +### Không có bảo vệ thực sự chống lại việc bỏ qua public API + +Khi bạn tạo index file cho slice, bạn không thực sự cấm ai đó không sử dụng nó và import trực tiếp. Điều này đặc biệt là vấn đề với auto-import, vì có nhiều nơi mà một object có thể được import, nên IDE phải quyết định cho bạn. Đôi khi nó có thể chọn import trực tiếp, phá vỡ quy tắc public API trên slice. + +Để tự động phát hiện những vấn đề này, chúng tôi khuyên sử dụng [Steiger][ext-steiger], một architectural linter với ruleset cho Feature-Sliced Design. + +### Hiệu suất bundler kém hơn trên các dự án lớn + +Việc có một lượng lớn index file trong dự án có thể làm chậm development server, như TkDodo đã lưu ý trong [bài viết "Please Stop Using Barrel Files"][ext-please-stop-using-barrel-files] của anh ấy. + +Có một số điều bạn có thể làm để giải quyết vấn đề này: +1. Lời khuyên giống như trong vấn đề ["Bundle lớn và tree-shaking bị hỏng trong Shared"](#large-bundles) — có index file riêng cho từng component/library trong `shared/ui` và `shared/lib` thay vì một file lớn +2. Tránh có index file trong segment trên các layer có slice. + Ví dụ, nếu bạn có index cho feature "comments", `📄 features/comments/index.js`, thì không có lý do gì để có thêm index cho segment `ui` của feature đó, `📄 features/comments/ui/index.js`. +3. Nếu bạn có một dự án rất lớn, có khả năng cao là ứng dụng của bạn có thể được chia thành nhiều chunk lớn. + Ví dụ, Google Docs có trách nhiệm rất khác nhau cho document editor và file browser. Bạn có thể tạo monorepo setup nơi mỗi package là một FSD root riêng biệt, với bộ layer riêng. Một số package chỉ có thể có layer Shared và Entities, những package khác có thể chỉ có Pages và App, những package khác nữa có thể bao gồm Shared nhỏ của riêng mình, nhưng vẫn sử dụng cái lớn từ package khác. + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-please-stop-using-barrel-files]: https://tkdodo.eu/blog/please-stop-using-barrel-files diff --git a/src/content/docs/vi/docs/reference/slices-segments.mdx b/src/content/docs/vi/docs/reference/slices-segments.mdx new file mode 100644 index 0000000000..59378e9dff --- /dev/null +++ b/src/content/docs/vi/docs/reference/slices-segments.mdx @@ -0,0 +1,69 @@ +--- +title: Slice và segment +sidebar: + order: 2 +--- + +## Slice + +Slice là cấp độ thứ hai trong hệ thống phân cấp tổ chức của Feature-Sliced Design. Mục đích chính của chúng là nhóm code theo ý nghĩa của nó đối với sản phẩm, business, hoặc chỉ đơn giản là application. + +Tên của các slice không được chuẩn hóa vì chúng được xác định trực tiếp bởi business domain của ứng dụng của bạn. Ví dụ, một photo gallery có thể có các slice `photo`, `effects`, `gallery-page`. Một mạng xã hội sẽ yêu cầu các slice khác nhau, ví dụ `post`, `comments`, `news-feed`. + +Các layer Shared và App không chứa slice. Đó là vì Shared không nên chứa business logic nào cả, do đó không có ý nghĩa gì đối với sản phẩm, và App chỉ nên chứa code liên quan đến toàn bộ ứng dụng, vì vậy không cần phải chia tách. + +### Zero coupling và high cohesion \{#zero-coupling-high-cohesion} + +Slice được thiết kế để là các nhóm file code độc lập và có tính gắn kết cao. Hình minh họa dưới đây có thể giúp hình dung các khái niệm khó hiểu về _cohesion_ và _coupling_: + +
+ + +
+ Image inspired by https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/ +
+
+ +Một slice lý tưởng là độc lập với các slice khác trên layer của nó (zero coupling) và chứa hầu hết code liên quan đến mục tiêu chính của nó (high cohesion). + +Tính độc lập của các slice được thực thi bởi [import rule trên layer][layers--import-rule]: + +> _Một module (file) trong slice chỉ có thể import các slice khác khi chúng được đặt trên các layer thấp hơn một cách nghiêm ngặt._ + +### Public API rule trên slice + +Bên trong slice, code có thể được tổ chức theo bất kỳ cách nào mà bạn muốn. Điều đó không gây ra vấn đề gì miễn là slice cung cấp public API tốt cho các slice khác sử dụng nó. Điều này được thực thi với **public API rule trên slice**: + +> _Mỗi slice (và segment trên các layer không có slice) phải chứa một định nghĩa public API._ +> +> _Các module bên ngoài slice/segment này chỉ có thể tham chiếu public API, không phải cấu trúc file nội bộ của slice/segment._ + +Đọc thêm về lý lẽ của public API và best practice để tạo một cái trong [Public API reference][ref-public-api]. + +### Nhóm slice + +Các slice liên quan chặt chẽ có thể được nhóm về mặt cấu trúc trong một folder, nhưng chúng nên thực hiện các quy tắc cô lập giống như các slice khác — không nên có **code sharing** trong folder đó. + +![Features "compose", "like" and "delete" grouped in a folder "post". In that folder there is also a file "some-shared-code.ts" that is crossed out to imply that it's not allowed.](/img/graphic-nested-slices.svg) + +## Segment + +Segment là cấp độ thứ ba và cuối cùng trong hệ thống phân cấp tổ chức, và mục đích của chúng là nhóm code theo bản chất kỹ thuật của nó. + +Có một số tên segment được chuẩn hóa: + +- `ui` — mọi thứ liên quan đến hiển thị UI: UI component, date formatter, style, v.v. +- `api` — tương tác backend: request function, data type, mapper, v.v. +- `model` — data model: schema, interface, store, và business logic. +- `lib` — library code mà các module khác trên slice này cần. +- `config` — configuration file và feature flag. + +Xem [trang Layer][layers--layer-definitions] để biết ví dụ về cách sử dụng từng segment này trên các layer khác nhau. + +Bạn cũng có thể tạo custom segment. Những nơi phổ biến nhất cho custom segment là layer App và layer Shared, nơi mà slice không có ý nghĩa. + +Hãy đảm bảo rằng tên của những segment này mô tả mục đích của nội dung, không phải bản chất của nó. Ví dụ, `components`, `hooks`, và `types` là những tên segment tệ vì chúng không hữu ích khi bạn đang tìm kiếm code. + +[layers--layer-definitions]: /docs/reference/layers#layer-definitions +[layers--import-rule]: /docs/reference/layers#import-rule-on-layers +[ref-public-api]: /docs/reference/public-api diff --git a/src/content/docs/zh/docs/about/alternatives.mdx b/src/content/docs/zh/docs/about/alternatives.mdx new file mode 100644 index 0000000000..6feb50a018 --- /dev/null +++ b/src/content/docs/zh/docs/about/alternatives.mdx @@ -0,0 +1,136 @@ +--- +title: 替代方案 +sidebar: + order: 3 + badge: + text: WIP + variant: caution +--- + +import { FileTree } from '@astrojs/starlight/components'; + +架构方法的历史 + +## 大泥球(Big Ball of Mud) + +> 什么是大泥球;为什么它如此常见;何时开始带来问题;该怎么做以及FSD如何在这方面提供帮助 + +- [(文章) Oleg Isonen - 在AI接管之前关于UI架构的最后话语](https://oleg008.medium.com/last-words-on-ui-architecture-before-an-ai-takes-over-468c78f18f0d) +- [(报告) Julia Nikolaeva, iSpring - 大泥球和单体的其他问题,我们已经处理过](http://youtu.be/gna4Ynz1YNI) +- [(文章) DD - 大泥球](https://thedomaindrivendesign.io/big-ball-of-mud/) + + +## 智能和愚蠢组件(Smart & Dumb components) + +> 关于这种方法;关于在前端的适用性;方法论立场 + +关于过时性,关于方法论的新观点 + +为什么容器组件方法是有害的? + +- [(文章) Dan Abramov - 展示型和容器型组件(TLDR:已弃用)](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) + + +## 设计原则 + +> 我们在谈论什么;FSD立场 + +SOLID、GRASP、KISS、YAGNI等 - 以及为什么它们在实践中不能很好地协同工作 + +以及它如何聚合这些实践 + +- [(演讲) Ilya Azin - Feature-Sliced Design(关于设计原则的片段)](https://youtu.be/SnzPAr_FJ7w?t=380) + + +## DDD + +> 关于这种方法;为什么它在实践中效果不佳 + +有什么不同,如何改善适用性,在哪里采用实践 + +- [(文章) DDD、六边形、洋葱、清洁、CQRS等...我如何将它们整合在一起](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) +- [(演讲) Ilya Azin - Feature-Sliced Design(关于整洁架构、DDD的片段)](https://youtu.be/SnzPAr_FJ7w?t=528) + + +## 整洁架构(Clean Architecture) + +> 关于这种方法;关于在前端的适用性;FSD立场 + +它们如何相似(对许多人来说),它们如何不同 + +- [(讨论串) 关于方法论中的用例/交互器](https://t.me/feature_sliced/3897) +- [(讨论串) 关于方法论中的依赖注入](https://t.me/feature_sliced/4592) +- [(文章) Alex Bespoyasov - 前端的整洁架构](https://bespoyasov.me/blog/clean-architecture-on-frontend/) +- [(文章) DDD、六边形、洋葱、清洁、CQRS等...我如何将它们整合在一起](https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/) +- [(演讲) Ilya Azin - Feature-Sliced Design(关于整洁架构、DDD的片段)](https://youtu.be/SnzPAr_FJ7w?t=528) +- [(文章) 整洁架构的误解](http://habr.com/ru/company/mobileup/blog/335382/) + + +## 框架 + +> 关于在前端的适用性;为什么框架不能解决问题;为什么没有单一方法;FSD立场 + +框架无关,约定方法 + +- [(文章) 关于创建方法论的原因(关于框架的片段)](/docs/about/motivation) +- [(讨论串) 关于方法论对不同框架的适用性](https://t.me/feature_sliced/3867) + + +## 原子设计(Atomic Design) + +### 什么是原子设计? +在原子设计中,职责范围被划分为标准化的层级。 + +原子设计分为**5个层级**(从上到下): + +1. `pages`(页面)- 功能类似于FSD中的`pages`层。 +2. `templates`(模板)- 定义页面结构而不绑定到特定内容的组件。 +3. `organisms`(有机体)- 由分子组成并具有业务逻辑的模块。 +4. `molecules`(分子)- 通常不包含业务逻辑的更复杂组件。 +5. `atoms`(原子)- 没有业务逻辑的UI组件。 + +一个层级的模块只与下面层级的模块交互,类似于FSD。 +也就是说,分子由原子构建,有机体由分子构建,模板由有机体构建,页面由模板构建。 +原子设计还意味着在模块内使用公共API来实现隔离。 + +### 对前端的适用性 + +原子设计在项目中相对常见。原子设计在网页设计师中比在开发中更受欢迎。 +网页设计师经常使用原子设计来创建可扩展且易于维护的设计。 +在开发中,原子设计经常与其他架构方法论混合使用。 + +然而,由于原子设计专注于UI组件及其组合,在架构内实现业务逻辑时会出现问题。 + +问题在于原子设计没有为业务逻辑提供明确的职责级别, +导致业务逻辑分散在各种组件和级别中,使维护和测试变得复杂。 +业务逻辑变得模糊,使得难以清楚地分离职责,并使代码变得不够模块化和可重用。 + +### 它与FSD的关系如何? + +在FSD的上下文中,原子设计的一些元素可以应用于创建灵活且可扩展的UI组件。 +`atoms`和`molecules`层可以在FSD的`shared/ui`中实现,简化基本UI元素的重用和维护。 + + +- shared/ + - ui/ + - atoms/ + - molecules/ + + +FSD和原子设计的比较显示,两种方法论都追求模块化和可重用性, +但专注于不同的方面。原子设计面向视觉组件及其组合。 +FSD专注于将应用程序功能划分为独立模块及其相互连接。 + +- [原子设计方法论](https://atomicdesign.bradfrost.com/table-of-contents/) +- [(讨论串) 关于在shared/ui中的适用性](https://t.me/feature_sliced/1653) +- [(视频) 原子设计简介](https://youtu.be/Yi-A20x2dcA) +- [(演讲) Ilya Azin - Feature-Sliced Design(关于原子设计的片段)](https://youtu.be/SnzPAr_FJ7w?t=587) + +## 功能驱动(Feature Driven) + +> 关于这种方法;关于在前端的适用性;FSD立场 + +关于兼容性、历史发展和比较 + +- [(演讲) Oleg Isonen - 功能驱动架构](https://youtu.be/BWAeYuWFHhs) +- [功能驱动简短规范(从FSD的角度)](https://github.com/feature-sliced/documentation/tree/rc/feature-driven) diff --git a/src/content/docs/zh/docs/about/mission.mdx b/src/content/docs/zh/docs/about/mission.mdx new file mode 100644 index 0000000000..da0119a122 --- /dev/null +++ b/src/content/docs/zh/docs/about/mission.mdx @@ -0,0 +1,51 @@ +--- +title: 使命 +sidebar: + order: 1 +--- + +在这里我们描述方法论适用性的目标和限制——这是我们在开发该方法论时所遵循的指导原则 + +- 我们的目标是在理念和简单性之间取得平衡 +- 我们无法制造一个适合所有人的银弹 + +**尽管如此,该方法论应该对相当广泛的开发者群体来说是亲近且可访问的** + +## 目标 + +### 对广泛开发者的直观清晰度 + +该方法论应该是可访问的 - 对于项目中的大部分团队成员 + +*因为即使有了所有未来的工具,如果只有经验丰富的高级开发者/领导者才能理解该方法论,那也是不够的* + +### 解决日常问题 + +该方法论应该阐述我们在开发项目时遇到的日常问题的原因和解决方案 + +**并且还要为所有这些提供工具(cli、linters)** + +让开发者可以使用一种*经过实战检验*的方法,让他们能够绕过架构和开发中的长期问题 + +> *@sergeysova: 想象一下,一个开发者在该方法论的框架内编写代码,他遇到问题的频率减少了10倍,仅仅因为其他人已经考虑并解决了许多问题。* + +## 限制 + +我们不想*强加我们的观点*,同时我们理解*作为开发者,我们的许多习惯每天都在干扰我们* + +每个人在设计和开发系统方面都有自己的经验水平,**因此,值得理解以下几点:** + +- **不会起作用**:非常简单、非常清晰、适用于所有人 + > *@sergeysova: 某些概念在你遇到问题并花费多年时间解决它们之前,是无法直观理解的。* + > + > - *在数学世界中:是图论。* + > - *在物理学中:量子力学。* + > - *在编程中:应用程序架构。* + +- **可能且期望的**:简单性、可扩展性 + +## 参见 + +- [架构问题][refs-architecture--problems] + +[refs-architecture--problems]: /docs/about/understanding/architecture#problems diff --git a/src/content/docs/zh/docs/about/motivation.mdx b/src/content/docs/zh/docs/about/motivation.mdx new file mode 100644 index 0000000000..cab3f29156 --- /dev/null +++ b/src/content/docs/zh/docs/about/motivation.mdx @@ -0,0 +1,149 @@ +--- +title: 动机 +sidebar: + order: 2 +--- + +import { Aside } from '@astrojs/starlight/components'; + +**Feature-Sliced Design**(特性分层设计)的主要理念是基于[结合研究成果,讨论各种类型开发者的广泛经验][ext-discussions],来促进和降低复杂项目开发的成本。 + +显然,这不会是万能的解决方案,当然,该方法论也会有自己的[适用限制][refs-mission]。 + +尽管如此,关于*这种方法论整体可行性*仍然存在合理的质疑。 + + + +## 为什么现有解决方案还不够? +> 通常会有这些论点: +> +> - *"为什么需要一些新的方法论,如果你已经有了长期建立的设计方法和原则,如 `SOLID`、`KISS`、`YAGNI`、`DDD`、`GRASP`、`DRY` 等。"* +> - *"所有问题都可以通过良好的项目文档、测试和结构化流程来解决"* +> - *"如果所有开发者都遵循上述所有原则,问题就不会发生"* +> - *"一切都在你之前就被发明了,你只是不会使用它"* +> - *"采用 \{框架名称\} - 那里已经为你决定了一切"* + +### 仅有原则是不够的 + +**仅仅存在原则并不足以设计出良好的架构** + +不是每个人都完全了解这些原则,更少的人能够正确理解和应用它们。 + +*设计原则过于宽泛,没有给出具体问题的明确答案:"如何设计可扩展和灵活应用程序的结构和架构?"* + +### 流程并不总是有效 + +*文档/测试/流程*当然是好的,但遗憾的是,即使在它们上面投入高成本 - **它们也不总能解决架构提出的问题和新人加入项目的问题** + +- 每个开发者进入项目的时间并没有大幅减少,因为文档往往会变得庞大/过时 +- 持续确保每个人都以相同方式理解架构 - 这也需要大量资源 +- 不要忘记总线因子(bus-factor) + +### 现有框架不能在所有地方应用 + +- 现有解决方案通常有很高的入门门槛,这使得寻找新开发者变得困难 +- 此外,技术选择通常在项目出现严重问题之前就已经确定,因此你需要能够"使用现有的" - **而不被技术绑定** + +> 问:*"在我的项目 `React/Vue/Redux/Effector/Mobx/{你的技术}` 中 - 我如何更好地构建实体结构和它们之间的关系?"* + +### 结果 + +我们得到了*"像雪花一样独特"*的项目,每个项目都需要员工长时间的沉浸,而这些知识不太可能适用于另一个项目。 + +> @sergeysova: *"这正是我们前端开发领域目前存在的情况:每个技术负责人都会发明不同的架构和项目结构,虽然这些结构不一定能经受时间的考验,结果是除了他之外最多只有两个人可以开发项目,每个新开发者都需要重新沉浸其中。"* + +## 为什么开发者需要这个方法论? + +### 专注于业务功能,而不是架构问题 + +该方法论允许你节省设计可扩展和灵活架构的资源,而是将开发者的注意力引导到主要功能的开发上。同时,架构解决方案本身在项目之间是标准化的。 + +*一个单独的问题是,该方法论应该赢得社区的信任,这样其他开发者可以熟悉它,并在他可用的时间内依靠它来解决他项目的问题* + +### 经过经验验证的解决方案 + +该方法论是为那些致力于*设计复杂业务逻辑的经过验证解决方案*的开发者而设计的。 + +*然而,很明显,该方法论通常是关于一套最佳实践、文章,这些文章解决开发过程中的某些问题和案例。因此,该方法论对其他开发者也会有用 - 那些在开发和设计过程中以某种方式面临问题的人* + +### 项目健康 + +该方法论将允许*提前解决和跟踪项目问题,而不需要大量资源* + +**技术债务往往会随着时间的推移而积累,解决它的责任既在技术负责人身上,也在团队身上** + +该方法论将允许你提前*警告*项目扩展和开发中的可能问题。 + +## 为什么企业需要方法论? + +### 快速入职 + +有了方法论,你可以雇佣一个**已经熟悉这种方法的人到项目中,而不需要重新培训** + +*人们开始更快地理解和为项目带来价值,并且有额外的保证为项目的下一次迭代找到人员* + +### 经过经验验证的解决方案 + +有了方法论,企业将获得*系统开发过程中出现的大多数问题的解决方案* + +因为企业最常想要获得一个框架/解决方案,能够解决项目开发过程中的大部分问题。 + +### 对项目不同阶段的适用性 + +该方法论可以在*项目支持和开发阶段以及MVP阶段*为项目带来好处 + +是的,对于MVP来说最重要的是*"功能,而不是为未来奠定的架构"*。但即使在有限的截止日期条件下,了解方法论中的最佳实践,你也可以在设计系统的MVP版本时*"用很少的代价"*找到合理的妥协 +(而不是"随机"建模功能) + +*测试也是如此* + +## 什么时候我们的方法论不需要? + +- 如果项目只会存在很短时间 +- 如果项目不需要支持的架构 +- 如果企业不认为代码库和功能交付速度之间存在联系 +- 如果对企业来说更重要的是尽快完成订单,而不需要进一步支持 + +### 企业规模 + +- **小企业** - 最常需要现成的和非常快速的解决方案。只有当企业增长(至少到接近平均水平)时,他才明白为了让客户继续使用,除其他外,有必要将时间投入到正在开发的解决方案的质量和稳定性上 +- **中型企业** - 通常理解开发的所有问题,即使有必要*"安排功能竞赛"*,他仍然会花时间进行质量改进、重构和测试(当然 - 还有可扩展的架构) +- **大企业** - 通常已经有广泛的受众、员工和更广泛的实践集合,甚至可能有自己的架构方法,所以采用别人的想法对他们来说并不常见 + +## 计划 + +目标的主要部分[在这里阐述][refs-mission--goals],但此外,值得谈论我们对未来方法论的期望。 + +### 结合经验 + +现在我们正在尝试结合`核心团队`的所有多样化经验,并因此获得一个经过实践锤炼的方法论。 + +当然,我们可能最终得到Angular 3.0,但这里更重要的是**调查设计复杂系统架构的根本问题** + +*是的 - 我们对当前版本的方法论有抱怨,但我们希望共同努力达成一个单一和最优的解决方案(考虑到社区的经验等)* + +### 规范之外的生活 + +如果一切顺利,那么方法论将不仅限于规范和工具包 + +- 也许会有报告、文章 +- 可能会有用于将根据方法论编写的项目迁移到其他技术的`CODE_MOD` +- 可能最终我们能够接触到大型技术解决方案的维护者 + - *特别是对于React,与其他框架相比 - 这是主要问题,因为它没有说明如何解决某些问题* + +## 另请参阅 + +- [(讨论)不需要方法论?][disc-src] +- [关于方法论的使命:目标和限制][refs-mission] +- [项目中的知识类型][refs-knowledge] + +[refs-mission]: /docs/about/mission +[refs-mission--goals]: /docs/about/mission#goals +[refs-knowledge]: /docs/about/understanding/knowledge-types + +[disc-src]: https://github.com/feature-sliced/documentation/discussions/27 +[ext-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/zh/docs/about/promote/integration.mdx b/src/content/docs/zh/docs/about/promote/integration.mdx new file mode 100644 index 0000000000..6149388b7a --- /dev/null +++ b/src/content/docs/zh/docs/about/promote/integration.mdx @@ -0,0 +1,25 @@ +--- +title: 集成方面 +sidebar: + order: 1 +--- + +## 总结 + +前 5 分钟(俄语): + + + +## 另外 + +**优势:** +- [概览](/docs/get-started/overview) +- 代码审查 +- 入职 + + +**缺点:** +- 心理复杂性 +- 高准入门槛 +- "Layers 地狱" +- 基于 feature 方法的典型问题 diff --git a/src/content/docs/zh/docs/about/understanding/architecture.mdx b/src/content/docs/zh/docs/about/understanding/architecture.mdx new file mode 100644 index 0000000000..f30077acc8 --- /dev/null +++ b/src/content/docs/zh/docs/about/understanding/architecture.mdx @@ -0,0 +1,99 @@ +--- +title: 关于架构 +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +## 问题 + +通常,当由于项目中的某些问题导致开发停止时,就会提出关于架构的讨论。 + +### Bus-factor 和入职 + +只有有限的人数理解项目及其架构 + +**示例:** + +- *"很难将一个人加入开发中"* +- *"对于每个问题,每个人都有自己的解决方案意见"(让我们嫌妒 angular)* +- *"我不理解这个大型单体块中发生了什么"* + +### 隐式和不可控制的后果 + +开发/重构过程中有很多隐式的副作用 *("一切都依赖于一切")* + +**示例:** + +- *"feature 导入 feature"* +- *"我更新了一个页面的 store,另一个页面的功能就失效了"* +- *"逻辑散布在整个应用程序中,无法追踪哪里是开始,哪里是结束"* + +### 不可控制的逻辑重用 + +很难重用/修改现有逻辑 + +同时,通常存在[两个极端](https://github.com/feature-sliced/documentation/discussions/14): + +- 要么为每个模块完全从头开始编写逻辑 *(在现有代码库中可能存在重复)* +- 要么倾向于将所有实现的模块转移到 `shared` 文件夹,从而创建一个大型的模块转储场 *(其中大多数只在一个地方使用)* + +**示例:** + +- *"我的项目中有 **N** 个相同业务逻辑的实现,我仍然在为此付出代价"* +- *"项目中有 6 个不同的按钮/弹窗/... 组件"* +- *"helpers 的转储场"* + +## 需求 + +因此,提出*理想架构的期望需求*似乎是合乎逻辑的: + + + +### 明确性 + +- 应该**易于掌握和解释**项目及其架构给团队 +- 结构应该反映项目的真实**业务价值** +- 抽象之间必须有明确的**副作用和连接** +- 应该**易于检测重复逻辑**而不干扰独特实现 +- 项目中不应该有**逻辑分散** +- 对于良好的架构,不应该有**太多异构抽象和规则** + +### 控制 + +- 良好的架构应该**加速任务解决和功能引入** +- 应该能够控制项目的开发 +- 应该易于**扩展、修改、删除代码** +- 必须遵守功能的**分解和隔离** +- 系统的每个组件都必须**易于替换和移除** + - *[无需为变更优化][ext-kof-not-modification] - 我们无法预测未来* + - *[更好地为删除优化][ext-kof-but-removing] - 基于已存在的上下文* + +### 适应性 + +- 良好的架构应该适用于**大多数项目** + - *具有现有基础设施解决方案* + - *在开发的任何阶段* +- 不应该依赖于框架和平台 +- 应该能够**轻松扩展项目和团队**,具有开发并行化的可能性 +- 应该易于**适应不断变化的需求和环境** + +## 参见 + +- [(React Berlin Talk) Oleg Isonen - Feature Driven Architecture][ext-kof] +- [(React SPB Meetup #1) Sergey Sova - Feature Slices][ext-slices-spb] +- [(文章) 关于项目模块化][ext-medium] +- [(文章) 关于关注点分离和按功能构建][ext-ryanlanciaux] + +[ext-kof-not-modification]: https://youtu.be/BWAeYuWFHhs?t=1631 +[ext-kof-but-removing]: https://youtu.be/BWAeYuWFHhs?t=1666 + +[ext-slices-spb]: https://t.me/feature_slices +[ext-kof]: https://youtu.be/BWAeYuWFHhs +[ext-medium]: https://alexmngn.medium.com/why-react-developers-should-modularize-their-applications-d26d381854c1 +[ext-ryanlanciaux]: https://ryanlanciaux.com/blog/2017/08/20/a-feature-based-approach-to-react-development/ diff --git a/src/content/docs/zh/docs/about/understanding/knowledge-types.mdx b/src/content/docs/zh/docs/about/understanding/knowledge-types.mdx new file mode 100644 index 0000000000..07c30fc6b7 --- /dev/null +++ b/src/content/docs/zh/docs/about/understanding/knowledge-types.mdx @@ -0,0 +1,31 @@ +--- +title: 项目中的知识类型 +sidebar: + label: 知识类型 + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + +在任何项目中都可以区分以下"知识类型": + +* **基础知识** + 随时间变化不大的知识,如算法、计算机科学、编程语言机制及其 API。 + +* **技术栈** + 对项目中使用的技术解决方案集合的了解,包括编程语言、框架和库。 + +* **项目知识** + 特定于当前项目且在项目外没有价值的知识。这种知识对于新入职的开发人员能够有效贡献至关重要。 + + + +## 另请参阅 \{#see-also} + +- [(视频 🇷🇺)Ilya Klimov - 关于知识类型][ext-klimov] + +[ext-klimov]: https://youtu.be/4xyb_tA-uw0?t=249 diff --git a/src/content/docs/zh/docs/about/understanding/naming.mdx b/src/content/docs/zh/docs/about/understanding/naming.mdx new file mode 100644 index 0000000000..3a1c94c453 --- /dev/null +++ b/src/content/docs/zh/docs/about/understanding/naming.mdx @@ -0,0 +1,48 @@ +--- +title: 命名 +sidebar: + order: 4 +--- + +不同的开发者有不同的经验和上下文,当相同的实体被不同地命名时,这可能导致团队中的误解。例如: + +- 用于显示的组件可以被称为 "ui"、"components"、"ui-kit"、"views"… +- 在整个应用程序中重用的代码可以被称为 "core"、"shared"、"app"… +- 业务逻辑代码可以被称为 "store"、"model"、"state"… + +## Feature-Sliced Design 中的命名 \{#naming-in-fsd} + +该方法论使用特定的术语,例如: + +- "app"、"process"、"page"、"feature"、"entity"、"shared" 作为 layer 名称, +- "ui"、"model"、"lib"、"api"、"config" 作为 segment 名称。 + +坚持使用这些术语非常重要,以防止团队成员和加入项目的新开发者之间的混淆。使用标准名称也有助于向社区寻求帮助。 + +## 命名冲突 \{#when-can-naming-interfere} + +当 FSD 方法论中使用的术语与业务中使用的术语重叠时,可能发生命名冲突: + +- `FSD#process` vs 应用程序中的模拟进程, +- `FSD#page` vs 日志页面, +- `FSD#model` vs 汽车型号。 + +例如,开发者在代码中看到 "process" 这个词时,会花费额外的时间试图弄清楚指的是哪个进程。这样的**冲突可能会破坏开发过程**。 + +当项目术语表包含 FSD 特有的术语时,在与团队和技术不相关的各方讨论这些术语时要格外小心。 + +为了与团队有效沟通,建议使用缩写 "FSD" 作为方法论术语的前缀。例如,在谈论进程时,您可能会说:"我们可以将这个进程放在 FSD features layer 上。" + +相反,在与非技术利益相关者沟通时,最好限制使用 FSD 术语,并避免提及代码库的内部结构。 + +## 参见 \{#see-also} + +- [(讨论) 命名的适应性][disc-src] +- [(讨论) Entity 命名调查][disc-naming] +- [(讨论) "processes" vs "flows" vs ...][disc-processes] +- [(讨论) "model" vs "store" vs ...][disc-model] + +[disc-model]: https://github.com/feature-sliced/documentation/discussions/68 +[disc-naming]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-464894 +[disc-processes]: https://github.com/feature-sliced/documentation/discussions/20 +[disc-src]: https://github.com/feature-sliced/documentation/discussions/16 diff --git a/src/content/docs/zh/docs/about/understanding/needs-driven.mdx b/src/content/docs/zh/docs/about/understanding/needs-driven.mdx new file mode 100644 index 0000000000..1839e6293a --- /dev/null +++ b/src/content/docs/zh/docs/about/understanding/needs-driven.mdx @@ -0,0 +1,161 @@ +--- +title: 需求驱动 +sidebar: + order: 2 +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +## 为什么? + +要为实体选择一个清晰的名称并理解其组件,**你需要清楚地理解所有这些代码要解决什么任务。** + +> _@sergeysova: 在开发过程中,我们试图给每个实体或函数一个清楚反映代码执行意图和含义的名称。_ + +_毕竟,如果不理解任务,就不可能编写覆盖最重要情况的正确测试,在正确的地方放置帮助用户的错误提示,甚至不能避免因为可修复的非关键错误而中断用户流程。_ + +## 我们在谈论什么任务? + +前端开发为最终用户开发应用程序和界面,所以我们解决这些消费者的任务。 + +当一个人来到我们这里时,**他想要解决自己的某个痛点或满足某个需求。** + +_管理者和分析师的任务是明确表述这个需求,开发者在考虑Web开发特性(通信丢失、后端错误、拼写错误、鼠标或手指操作失误)的情况下实现它。_ + +**用户带着的这个目标,就是开发者的任务。** + +> _一个小的已解决问题就是Feature-Sliced Design方法论中的一个功能(feature)— 你需要将项目任务的整个范围切分为小目标。_ + +## 这如何影响开发? + +### 任务分解 + +当开发者开始实现任务时,为了简化代码的理解和支持,他在心理上**将其切分为阶段**: + +* 首先_分解为顶级实体_并_实现它们_, +* 然后将这些实体_分解为更小的实体_ +* 以此类推 + +_在分解为实体的过程中,开发者被迫给它们起一个能清楚反映他的想法的名称,并在阅读代码清单时帮助理解代码解决什么任务_ +_同时,我们不要忘记我们正在努力帮助用户减少痛点或实现需求_ + +### 理解任务的本质 + +但要给实体起一个清晰的名称,**开发者必须充分了解其目的** + +* 他将如何使用这个实体, +* 它实现用户任务的哪一部分,这个实体还能在哪里应用, +* 它还能参与哪些其他任务, +* 等等 + +不难得出结论:**当开发者在方法论框架内思考实体名称时,他甚至能在编写代码之前就发现表述不清的任务。** + +> 如果你不能很好地理解一个实体能解决什么任务,如何给它起名?如果你不能很好地理解一个任务,又如何将任务分解为实体? + +## 如何表述? + +**要表述功能(features)解决的任务,你需要理解任务本身**,这已经是项目经理和分析师的责任。 + +_方法论只能告诉开发者产品经理应该密切关注哪些任务。_ + +> _@sergeysova: 整个前端主要是信息显示,任何组件首先是显示,然后"向用户显示某些东西"的任务没有实际价值。_ +> +> _即使不考虑前端的特性,也可以问"为什么我必须向你显示",这样你可以继续问下去,直到找到消费者的痛点或需求。_ + +一旦我们能够找到基本需求或痛点,我们就可以回过头来弄清楚**你的产品或服务如何帮助用户实现他的目标** + +你跟踪器中的任何新任务都旨在解决业务问题,而业务试图在解决用户任务的同时从中赚钱。这意味着每个任务都有特定的目标,即使它们没有在描述文本中明确说明。 + +_**开发者必须清楚地理解这个或那个任务追求什么目标**,但不是每个公司都能完美地构建流程,虽然这是另一个话题,但开发者完全可以自己"ping"合适的管理者来了解这一点,并有效地完成自己的工作部分。_ + +## 有什么好处? + +现在让我们从头到尾看整个过程。 + +### 1. 理解用户任务 + +当开发者理解用户的痛点以及业务如何解决它们时,他可以提供由于Web开发特性而对业务不可见的解决方案。 + +> 但当然,所有这些只有在开发者对自己在做什么以及为什么做不漠不关心的情况下才能起作用,否则_为什么还需要方法论和某些方法?_ + +### 2. 结构化和排序 + +随着对任务的理解,**在头脑中和任务以及代码中都有了清晰的结构** + +### 3. 理解功能及其组件 + +**一个功能就是为用户提供的一个有用功能** + +* 当在一个功能中实现多个功能时,这是**边界违反** +* 功能可以是不可分割的和不断增长的 - **这并不坏** +* **坏的** - 是当功能不能回答_"对用户的业务价值是什么?"_这个问题时 +* 不能有"地图-办公室"功能 + * 但`在地图上预订会议`、`搜索员工`、`更换工作场所` - **可以** + +> _@sergeysova: 重点是功能只包含实现功能本身的代码_,没有不必要的细节和内部解决方案(理想情况下)* +> +> *打开功能代码**只看到与任务相关的内容** - 不多不少* + +### 4. 收益 + +业务很少会彻底改变其方向,这意味着**在前端应用程序代码中反映业务任务是一个非常重要的收益。** + +_然后你不必向每个新团队成员解释这段或那段代码做什么,以及为什么添加它 - **一切都将通过已经反映在代码中的业务任务来解释。**_ + +> 这就是[领域驱动开发中所谓的"业务语言"][ext-ubiq-lang] + +--- + +## 回到现实 + +如果在设计阶段理解了业务流程并给出了好的名称 - _那么将这种理解和逻辑转移到代码中就不是特别有问题的。_ + +**然而,在实践中**,任务和功能通常是"过度"迭代开发的,和/或没有时间思考设计。 + +**结果,功能在今天是有意义的,如果你在一个月后扩展这个功能,你可能需要重写项目的一半。** + +> *[[来自讨论][disc-src]]:开发者试图提前思考2-3步,考虑未来的需求,但在这里他依赖于自己的经验* +> +> _有经验的工程师通常立即看到10步之前,并理解在哪里分割一个功能并与另一个功能结合_ +> +> _但有时会遇到必须面对经验的任务,而无处获得如何正确分解的理解,以便在未来产生最少的不幸后果_ + +## 方法论的作用 + +**方法论帮助解决开发者的问题,以便更容易解决用户的问题。** + +没有仅仅为了开发者而解决开发者问题的方案 + +但为了让开发者解决他的任务,**需要理解用户的任务** - 反之则不行 + +### 方法论要求 + +很明显,需要为**Feature-Sliced Design**确定至少两个要求: + +1. 方法论应该说明**如何创建功能、流程和实体** + + * 这意味着它应该清楚地解释_如何在它们之间分配代码_,这意味着这些实体的命名也应该在规范中确定。 + +2. 方法论应该帮助架构**[轻松适应项目不断变化的需求][refs-arch--adaptability]** + +## 另请参阅 + +* [(帖子) 清晰表述任务的激励(+ 讨论)][disc-src] + > _**当前文章**是这个讨论的改编,你可以在链接中阅读完整的未删减版本_ +* [(讨论) 如何分解功能以及它是什么][tg-src] +* [(文章) "如何更好地组织你的应用程序"][ext-medium] + +[refs-arch--adaptability]: architecture#adaptability + +[ext-medium]: https://alexmngn.medium.com/how-to-better-organize-your-react-applications-2fd3ea1920f1 +[disc-src]: https://t.me/sergeysova/318 +[tg-src]: https://t.me/atomicdesign/18972 +[ext-ubiq-lang]: https://thedomaindrivendesign.io/developing-the-ubiquitous-language diff --git a/src/content/docs/zh/docs/branding.mdx b/src/content/docs/zh/docs/branding.mdx new file mode 100644 index 0000000000..92fe6f598a --- /dev/null +++ b/src/content/docs/zh/docs/branding.mdx @@ -0,0 +1,81 @@ +--- +title: 品牌指南 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +FSD 的视觉身份基于其核心概念:`分层`、`切片式自包含部分`、`部分和组合`、`分段`。 +但我们也倾向于设计简单、美丽的身份,它应该传达 FSD 的哲学并易于识别。 + +**请按原样使用 FSD 的身份,不要更改,但可以使用我们的资产以方便您使用。**此品牌指南将帮助您正确使用 FSD 的身份。 + + + +## 标题 + +- ✅ **正确:** `Feature-Sliced Design`、`FSD` +- ❌ **错误:** `Feature-Sliced`、`Feature Sliced`、`FeatureSliced`、`feature-sliced`、`feature sliced`、`FS` + +## Emoji + +蛋糕 🍰 图像很好地代表了 FSD 的核心概念,所以它被选为我们的标志性 emoji + +> 示例:*"🍰 前端项目的架构设计方法论"* + +## Logo 和调色板 + +FSD 有几种适用于不同上下文的 logo 变体,但建议优先使用 **primary** + + + + + + + + + + + + + + + + + + + + + + + + + + + +
主题Logo (Ctrl/Cmd + 点击下载)用法
primary
(#29BEDC, #517AED)
logo-primary在大多数情况下首选
flat
(#3193FF)
logo-flat用于单色上下文
monochrome
(#FFF)
logo-monocrhome用于灰度上下文
square
(#3193FF)
logo-square用于方形边界
+ +## 横幅和方案 + +banner-primary +banner-monochrome + +## 社交预览 + +正在进行中... + +## 演示模板 + +正在进行中... + +## 另请参阅 + +- [Discussion (github)](https://github.com/feature-sliced/documentation/discussions/399) +- [History of development with references (figma)](https://www.figma.com/file/RPphccpoeasVB0lMpZwPVR/FSD-Brand?node-id=0%3A1) diff --git a/src/content/docs/zh/docs/get-started/faq.mdx b/src/content/docs/zh/docs/get-started/faq.mdx new file mode 100644 index 0000000000..92273fe86e --- /dev/null +++ b/src/content/docs/zh/docs/get-started/faq.mdx @@ -0,0 +1,69 @@ +--- +title: 常见问题 +sidebar: + order: 20 +--- + +import { Aside } from '@astrojs/starlight/components'; + + + +### 有工具包或代码检查器吗? + +有!我们有一个名为 [Steiger][ext-steiger] 的代码检查器来检查您项目的架构,以及通过 CLI 或 IDE 的[文件夹生成器][ext-tools]。 + +### 在哪里存储页面的布局/模板? + +如果您需要纯标记布局,您可以将它们保存在 `shared/ui` 中。如果您需要在内部使用更高的 layers,有几个选项: + +- 也许您根本不需要布局?如果布局只有几行,在每个页面中复制代码而不是试图抽象它可能是合理的。 +- 如果您确实需要布局,您可以将它们作为单独的 widgets 或 pages,并在 App 中的路由配置中组合它们。嵌套路由是另一个选项。 + +### feature 和 entity 之间有什么区别? + +_entity_ 是您的应用程序正在处理的现实生活概念。_feature_ 是为您的应用程序用户提供现实生活价值的交互,是人们想要对您的 entities 做的事情。 + +有关更多信息和示例,请参阅 [slices][reference-entities] 的参考页面。 + +### 我可以将 pages/features/entities 嵌入彼此吗? + +可以,但这种嵌入应该在更高的 layers 中发生。例如,在 widget 内部,您可以导入两个 features,然后将一个 feature 作为 props/children 插入到另一个 feature 中。 + +您不能从一个 feature 导入另一个 feature,这被 [**layers 上的导入规则**][import-rule-layers] 禁止。 + +### Atomic Design 怎么办? + +该方法论的当前版本不要求也不禁止将 Atomic Design 与 Feature-Sliced Design 一起使用。 + +例如,Atomic Design [可以很好地应用](https://t.me/feature_sliced/1653)于模块的 `ui` segment。 + +### 有关于 FSD 的有用资源/文章等吗? + +有!https://github.com/feature-sliced/awesome + +### 为什么我需要 Feature-Sliced Design? + +它帮助您和您的团队在主要价值组件方面快速概览项目。标准化架构有助于加快入职速度并解决关于代码结构的争议。请参阅[动机][motivation]页面了解更多关于为什么创建 FSD 的信息。 + +### 新手开发者需要架构/方法论吗? + +更倾向于需要 + +*通常,当您独自设计和开发项目时,一切都很顺利。但如果开发过程中有暂停,团队中添加了新的开发者 - 那么问题就会出现* + +### 如何处理授权上下文? + +在[这里](/docs/guides/examples/auth)有答案 + +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[import-rule-layers]: /docs/reference/layers#import-rule-on-layers +[reference-entities]: /docs/reference/layers#entities +[motivation]: /docs/about/motivation +[telegram]: https://t.me/feature_sliced +[discord]: https://discord.gg/S8MzWTUsmp +[github-discussions]: https://github.com/feature-sliced/documentation/discussions diff --git a/src/content/docs/zh/docs/get-started/overview.mdx b/src/content/docs/zh/docs/get-started/overview.mdx new file mode 100644 index 0000000000..100964a621 --- /dev/null +++ b/src/content/docs/zh/docs/get-started/overview.mdx @@ -0,0 +1,150 @@ +--- +title: 概览 +sidebar: + order: 1 +--- + +import { FileTree, LinkCard, Aside } from '@astrojs/starlight/components'; + +**Feature-Sliced Design**(FSD)是一种用于构建前端应用程序的架构方法论。简单来说,它是组织代码的规则和约定的汇编。该方法论的主要目的是在不断变化的业务需求面前,使项目更加易于理解和稳定。 + +除了一系列约定外,FSD 还是一个工具链。我们有一个 [代码检查器][ext-steiger] 来检查您项目的架构,通过 CLI 或 IDE 的[文件夹生成器][ext-tools],以及丰富的[示例][examples]库。 + +## 它适合我吗? \{#is-it-right-for-me} + +FSD 可以在任何规模的项目和团队中实施。如果您的项目符合以下条件,那么它就适合您: + +- 您正在做**前端**开发(网页、移动端、桌面端等 UI) +- 您正在构建一个**应用程序**,而不是一个库 + +就是这样!对于您使用的编程语言、UI 框架或状态管理器没有任何限制。您也可以逐步采用 FSD,在 monorepos 中使用它,并通过将应用程序分解为包并在其中单独实施 FSD 来扩展到很大的长度。 + +如果您已经有了一个架构并正在考虑切换到 FSD,请确保当前的架构在您的团队中**造成了麻烦**。例如,如果您的项目变得过于庞大和相互连接,无法高效地实现新功能,或者如果您期望有很多新成员加入团队。如果当前的架构运作良好,也许不值得更改。但如果您确实决定迁移,请参阅[迁移][migration]部分获取指导。 + +## 基本示例 \{#basic-example} + +这里是一个实现了 FSD 的简单项目: + + +- app/ +- pages/ +- shared/ + + +这些顶级文件夹被称为_层_。让我们更深入地看看: + + + +- app/ + - routes/ + - analytics/ +- pages/ + - home/ + - article-reader/ + - ui/ + - api/ + - setings/ +- shared/ + - ui/ + - api/ + + + +`📂 pages` 内的文件夹被称为_切片_。它们按领域分割层(在这种情况下,按页面分割)。 + +`📂 app`、`📂 shared` 和 `📂 pages/article-reader` 内的文件夹被称为_段_,它们按技术目的分割切片(或层),即代码的用途。 + +## 概念 \{#concepts} + +Layers、slices 和 segments 形成这样的层次结构: + +
+ ![Hierarchy of FSD concepts, described below](../../../../../../static/img/visual_schema.jpg) + +
+

上图显示:三个支柱,从左到右分别标记为 "Layers"、"Slices" 和 "Segments"。

+

"Layers" 支柱包含七个从上到下排列的部分,分别标记为 "app"、"processes"、"pages"、"widgets"、"features"、"entities" 和 "shared"。"processes" 部分被划掉了。"entities" 部分连接到第二个支柱 "Slices",表示第二个支柱是 "entities" 的内容。

+

"Slices" 支柱包含三个从上到下排列的部分,分别标记为 "user"、"post" 和 "comment"。"post" 部分以同样的方式连接到第三个支柱 "Segments",表示它是 "post" 的内容。

+

"Segments" 支柱包含三个从上到下排列的部分,分别标记为 "ui"、"model" 和 "api"。

+
+
+ +### Layers \{#layers} + +Layers 在所有 FSD 项目中都是标准化的。您不必使用所有的 layers,但它们的名称很重要。目前有七个(从上到下): + +1. **App** — 使应用程序运行的一切 — 路由、入口点、全局样式、providers。 +2. **Processes**(已废弃)— 复杂的跨页面场景。 +3. **Pages** — 完整页面或嵌套路由中页面的大部分。 +4. **Widgets** — 大型自包含的功能或 UI 块,通常提供整个用例。 +5. **Features** — 整个产品功能的_可重用_实现,即为用户带来业务价值的操作。 +6. **Entities** — 项目处理的业务实体,如 `user` 或 `product`。 +7. **Shared** — 可重用功能,特别是当它与项目/业务的具体细节分离时,但不一定如此。 + + +Layers 的技巧是一个 layer 上的模块只能了解并从严格位于下方的 layers 的模块中导入。 + +### Slices \{#slices} + +接下来是 slices,它们按业务领域分割代码。您可以自由选择它们的名称,并根据需要创建任意数量。Slices 通过将逻辑相关的模块保持在一起,使您的代码库更容易导航。 + +Slices 不能使用同一 layer 上的其他 slices,这有助于实现高聚合性和低耦合性。 + +### Segments \{#segments} + +Slices 以及 layers App 和 Shared 由 segments 组成,segments 按代码的目的对代码进行分组。Segment 名称不受标准约束,但有几个最常见目的的传统名称: + +- `ui` — 与 UI 显示相关的一切:UI 组件、日期格式化程序、样式等。 +- `api` — 后端交互:请求函数、数据类型、mappers 等。 +- `model` — 数据模型:schemas、interfaces、stores 和业务逻辑。 +- `lib` — 此 slice 上其他模块需要的库代码。 +- `config` — 配置文件和 feature flags。 + +通常这些 segments 对于大多数 layers 来说已经足够,您只会在 Shared 或 App 中创建自己的 segments,但这不是一个规则。 + +## 优势 \{#advantages} + +- **统一性** + 由于结构是标准化的,项目变得更加统一,这使得团队新成员的入职更加容易。 + +- **面对变化和重构的稳定性** + 一个 layer 上的模块不能使用同一 layer 上的其他模块,或者上层的 layers。 + 这允许您进行独立的修改,而不会对应用程序的其余部分产生不可预见的后果。 + +- **可控的逻辑重用** + 根据 layer,您可以使代码非常可重用或非常本地化。 + 这在遵循 **DRY** 原则和实用性之间保持平衡。 + +- **面向业务和用户需求** + 应用程序被分割为业务领域,并鼓励在命名中使用业务语言,这样您可以在不完全理解项目的所有其他不相关部分的情况下做有用的产品工作。 + +## 渐进式采用 \{#incremental-adoption} + +如果您有一个现有的代码库想要迁移到 FSD,我们建议以下策略。我们在自己的迁移经验中发现它很有用。 + +1. 首先逐模块地慢慢塑造 App 和 Shared layers 以创建基础。 + +2. 使用粗略的笔触将所有现有 UI 分布在 Widgets 和 Pages 中,即使它们有违反 FSD 规则的依赖。 + +3. 开始逐渐解决导入违规,并提取 Entities,甚至可能提取 Features。 + +建议在重构时避免添加大型新实体,或者只重构项目的某些部分。 + +## 下一步 \{#next-steps} + +- **想要好好掌握如何用 FSD 思维?**查看[Tutorial][tutorial]。 +- **喜欢从示例中学习?**我们在 [Examples][examples] 部分有很多内容。 +- **有问题?**访问我们的 [Telegram 聊天][ext-telegram] 并从社区获得帮助。 + +[tutorial]: /docs/get-started/tutorial +[examples]: /examples +[migration]: /docs/guides/migration/from-custom +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-tools]: https://github.com/feature-sliced/awesome?tab=readme-ov-file#tools +[ext-telegram]: https://t.me/feature_sliced diff --git a/src/content/docs/zh/docs/get-started/tutorial.mdx b/src/content/docs/zh/docs/get-started/tutorial.mdx new file mode 100644 index 0000000000..a4f67be530 --- /dev/null +++ b/src/content/docs/zh/docs/get-started/tutorial.mdx @@ -0,0 +1,2265 @@ +--- +title: 教程 +sidebar: + order: 2 +--- + +import { FileTree, Code } from '@astrojs/starlight/components'; + +## 第一部分。理论上 + +本教程将检查 Real World App,也称为 Conduit。Conduit 是一个基本的 [Medium](https://medium.com/) 克隆 — 它让您阅读和编写文章,以及对他人的文章进行评论。 + +![Conduit home page](/img/tutorial/realworld-feed-anonymous.jpg) + +这是一个相当小的应用程序,所以我们将保持简单并避免过度分解。整个应用程序很可能只需要三个 layers:**App**、**Pages** 和 **Shared**。如果不是,我们将在过程中引入额外的 layers。准备好了吗? + +### 从列出页面开始 + +如果我们查看上面的截图,我们可以至少假设以下页面: + +- 主页(文章流) +- 登录和注册 +- 文章阅读器 +- 文章编辑器 +- 用户资料查看器 +- 用户资料编辑器(用户设置) + +这些页面中的每一个都将成为 Pages *layer* 上的自己的 *slice*。回忆一下概览中的内容,slices 简单来说就是 layers 内部的文件夹,而 layers 简单来说就是具有预定义名称的文件夹,如 `pages`。 + +因此,我们的 Pages 文件夹将如下所示: + + +- pages/ + - feed/ + - sign-in/ + - article-read/ + - article-edit/ + - profile/ + - settings/ + + +Feature-Sliced Design 与无规则代码结构的关键区别是页面不能相互引用。也就是说,一个页面不能从另一个页面导入代码。这是由于 **layers 上的导入规则**: + +*slice 中的模块(文件)只能在其他 slices 位于严格低于当前的 layers 时才能导入它们。* + +在这种情况下,页面是一个 slice,所以这个页面内部的模块(文件)只能引用下层 layers 的代码,而不能引用同一 layer Pages 的代码。 + +### 仔细查看 feed + +
+ ![Anonymous user’s perspective](../../../../../../static/img/tutorial/realworld-feed-anonymous.jpg) +
+ _Anonymous user’s perspective_ +
+
+ +
+ ![Authenticated user’s perspective](../../../../../../static/img/tutorial/realworld-feed-authenticated.jpg) +
+ _Authenticated user’s perspective_ +
+
+ +feed 页面上有三个动态区域: + +1. 带有登录状态指示的登录链接 +2. 触发 feed 中过滤的标签列表 +3. 一个/两个文章 feeds,每篇文章都有一个点赞按钮 + +登录链接是所有页面通用的头部的一部分,我们将单独重新访问它。 + +#### 标签列表 + +要构建标签列表,我们需要获取可用的标签,将每个标签渲染为芯片,并将选中的标签存储在客户端存储中。这些操作分别属于“API 交互”、“用户界面”和“存储”类别。在 Feature-Sliced Design 中,代码使用 *segments* 按目的分离。Segments 是 slices 中的文件夹,它们可以有描述目的的任意名称,但某些目的非常常见,以至于某些 segment 名称有约定: + +- 📂 `api/` 用于后端交互 +- 📂 `ui/` 用于处理渲染和外观的代码 +- 📂 `model/` 用于存储和业务逻辑 +- 📂 `config/` 用于 feature flags、环境变量和其他形式的配置 + +我们将获取标签的代码放入 `api`,标签组件放入 `ui`,存储交互放入 `model`。 + +#### 文章 + +使用相同的分组原则,我们可以将文章 feed 分解为相同的三个 segments: + +- 📂 `api/`: 获取带有点赞数的分页文章;点赞文章 +- 📂 `ui/`: + - 可以在选中标签时渲染额外选项卡的选项卡列表 + - 单个文章 + - 功能分页 +- 📂 `model/`: 当前加载的文章和当前页面的客户端存储(如果需要) + +### 重用通用代码 + +大多数页面在意图上非常不同,但某些东西在整个应用程序中保持不变 — 例如,符合设计语言的 UI 套件,或后端上使用相同认证方法的 REST API 来完成所有事情的约定。由于 slices 旨在被隔离,代码重用由更低的 layer **Shared** 促进。 + +Shared 与其他 layers 不同,它包含 segments 而不是 slices。这样,Shared layer 可以被认为是 layer 和 slice 之间的混合体。 + +通常,Shared 中的代码不是提前计划的,而是在开发过程中提取的,因为只有在开发过程中才能明确哪些代码部分实际上是共享的。然而,记住哪种代码自然属于 Shared 仍然是有帮助的: + +- 📂 `ui/` — the UI kit, pure appearance, no business logic. For example, buttons, modal dialogs, form inputs. +- 📂 `api/` — convenience wrappers around request making primitives (like `fetch()` on the Web) and, optionally, functions for triggering particular requests according to the backend specification. +- 📂 `config/` — parsing environment variables +- 📂 `i18n/` — configuration of language support +- 📂 `router/` — routing primitives and route constants + +这些只是 Shared 中 segment 名称的几个示例,但您可以省略其中任何一个或创建自己的。创建新 segments 时要记住的唯一重要事情是,segment 名称应该描述**目的(为什么),而不是本质(是什么)**。像 "components"、"hooks"、"modals" 这样的名称*不应该*使用,因为它们描述了这些文件是什么,但不能帮助在内部导航代码。这要求团队中的人在这样的文件夹中挖掘每个文件,并且也保持不相关的代码接近,这导致了重构影响的代码区域广泛,从而使代码审查和测试更加困难。 + +### 定义严格的 public API + +在 Feature-Sliced Design 的上下文中,术语 *public API* 指的是 slice 或 segment 声明项目中的其他模块可以从它导入什么。例如,在 JavaScript 中,这可以是一个 `index.js` 文件,从 slice 中的其他文件重新导出对象。这使得在 slice 内部重构代码的自由度成为可能,只要与外部世界的契约(即 public API)保持不变。 + +对于没有 slices 的 Shared layer,通常为每个 segment 定义单独的 public API 比定义 Shared 中所有内容的一个单一索引更方便。这使得从 Shared 的导入按意图自然地组织。对于具有 slices 的其他 layers,情况相反 — 通常每个 slice 定义一个索引并让 slice 决定外部世界未知的自己的 segments 集合更实用,因为其他 layers 通常有更少的导出。 + +我们的 slices/segments 将以以下方式相互出现: + + +- pages/ + - feed/ + - index + - sign-in/ + - index + - article-read/ + - index + - ... +- shared/ + - ui/ + - index + - api/ + - index + - ... + + +像 `pages/feed` 或 `shared/ui` 这样的文件夹内部的任何内容只有这些文件夹知道,其他文件不应该依赖这些文件夹的内部结构。 + +### UI 中的大型重用块 + +早些时候我们记录了要重新访问出现在每个页面上的头部。在每个页面上从头开始重建它是不切实际的,所以想要重用它是很自然的。我们已经有 Shared 来促进代码重用,然而,在 Shared 中放置大型 UI 块有一个警告 — Shared layer 不应该了解上面的任何 layers。 + +在 Shared 和 Pages 之间有三个其他 layers:Entities、Features 和 Widgets。某些项目可能在这些 layers 中有他们在大型可重用块中需要的东西,这意味着我们不能将该可重用块放在 Shared 中,否则它将从上层 layers 导入,这是被禁止的。这就是 Widgets layer 的用武之地。它位于 Shared、Entities 和 Features 之上,所以它可以使用它们所有。 + +在我们的情况下,头部非常简单 — 它是一个静态 logo 和顶级导航。导航需要向 API 发出请求以确定用户当前是否已登录,但这可以通过从 `api` segment 的简单导入来处理。因此,我们将把我们的头部保留在 Shared 中。 + +### 仔细查看带有表单的页面 + +让我们也检查一个用于编辑而不是阅读的页面。例如,文章编写器: + +![Conduit post editor](/img/tutorial/realworld-editor-authenticated.jpg) + +它看起来微不足道,但包含了我们尚未探索的应用程序开发的几个方面 — 表单验证、错误状态和数据持久化。 + +如果我们要构建这个页面,我们会从 Shared 中获取一些输入和按钮,并在此页面的 `ui` segment 中组合一个表单。然后,在 `api` segment 中,我们将定义一个变更请求以在后端创建文章。 + +为了在发送之前验证请求,我们需要一个验证模式,一个好地方是 `model` segment,因为它是数据模型。在那里我们将产生错误消息并使用 `ui` segment 中的另一个组件显示它们。 + +为了改善用户体验,我们还可以持久化输入以防止意外数据丢失。这也是 `model` segment 的工作。 + +### 总结 + +我们已经检查了几个页面并为我们的应用程序概述了初步结构: + +1. Shared layer + 1. `ui` 将包含我们可重用的 UI 套件 + 2. `api` 将包含我们与后端的原始交互 + 3. 其余将根据需要安排 +2. Pages layer — 每个页面都是一个单独的 slice + 1. `ui` 将包含页面本身及其所有部分 + 2. `api` 将包含更专门的数据获取,使用 `shared/api` + 3. `model` 可能包含我们将显示的数据的客户端存储 + +让我们开始构建吧! + +## 第二部分。在代码中 + +现在我们有了计划,让我们付诸实践。我们将使用 React 和 [Remix](https://remix.run)。 + +有一个为此项目准备的模板,从 GitHub 克隆它以获得先机:[https://github.com/feature-sliced/tutorial-conduit/tree/clean](https://github.com/feature-sliced/tutorial-conduit/tree/clean)。 + +使用 `npm install` 安装依赖项并使用 `npm run dev` 启动开发服务器。打开 [http://localhost:3000](http://localhost:3000),您应该看到一个空白应用程序。 + +### 布局页面 + +让我们首先为所有页面创建空白组件。在您的项目中运行以下命令: + +```bash +npx fsd pages feed sign-in article-read article-edit profile settings --segments ui +``` + +这将为每个页面创建像 `pages/feed/ui/` 这样的文件夹和一个索引文件 `pages/feed/index.ts`。 + +### 连接 feed 页面 + +让我们将应用程序的根路由连接到 feed 页面。在 `pages/feed/ui` 中创建一个组件 `FeedPage.tsx` 并将以下内容放入其中: + + +```tsx title="pages/feed/ui/FeedPage.tsx" +export function FeedPage() { + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+
+ ); +} +``` + +然后在 feed 页面的 public API,`pages/feed/index.ts` 文件中重新导出此组件: + + + +```ts title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +``` + +现在将它连接到根路由。在 Remix 中,路由是基于文件的,路由文件位于 `app/routes` 文件夹中,这与 Feature-Sliced Design 很好地契合。 + +在 `app/routes/_index.tsx` 中使用 `FeedPage` 组件: + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +然后,如果您运行开发服务器并打开应用程序,您应该会看到 Conduit 横幅! + +![The banner of Conduit](/img/tutorial/conduit-banner.jpg) + +### API 客户端 + +为了与 RealWorld 后端通信,让我们在 Shared 中创建一个方便的 API 客户端。创建两个 segments,`api` 用于客户端,`config` 用于像后端基础 URL 这样的变量: + +```bash +npx fsd shared --segments api config +``` + +然后创建 `shared/config/backend.ts`: + +```tsx title="shared/config/backend.ts" +export { mockBackendUrl as backendBaseUrl } from "mocks/handlers"; +``` + +```tsx title="shared/config/index.ts" +export { backendBaseUrl } from "./backend"; +``` + +由于 RealWorld 项目方便地提供了 [OpenAPI 规范](https://github.com/gothinkster/realworld/blob/main/api/openapi.yml),我们可以利用为我们的客户端自动生成的类型。我们将使用 [the `openapi-fetch` package](https://openapi-ts.pages.dev/openapi-fetch/),它附带一个额外的类型生成器。 + +运行以下命令生成最新的 API 类型: + +```bash +npm run generate-api-types +``` + +这将创建一个文件 `shared/api/v1.d.ts`。我们将使用此文件在 `shared/api/client.ts` 中创建一个类型化的 API 客户端: + +```tsx title="shared/api/client.ts" +import createClient from "openapi-fetch"; + +import { backendBaseUrl } from "shared/config"; +import type { paths } from "./v1"; + +export const { GET, POST, PUT, DELETE } = createClient({ baseUrl: backendBaseUrl }); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; +``` + +### feed 中的真实数据 + +我们现在可以继续向 feed 添加从后端获取的文章。让我们首先实现一个文章预览组件。 + +使用以下内容创建 `pages/feed/ui/ArticlePreview.tsx`: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +export function ArticlePreview({ article }) { /* TODO */ } +``` + +由于我们用 TypeScript 编写,有一个类型化的 article 对象会很好。如果我们探索生成的 `v1.d.ts`,我们可以看到 article 对象可以通过 `components["schemas"]["Article"]` 获得。所以让我们在 Shared 中创建一个包含我们数据模型的文件并导出模型: + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; +``` + +现在我们可以回到文章预览组件并用数据填充标记。使用以下内容更新组件: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+ +
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +点赞按钮目前不做任何事情,我们将在到达文章阅读器页面并实现点赞功能时修复它。 + +现在我们可以获取文章并渲染出一堆这些卡片。在 Remix 中获取数据是通过 *loaders* 完成的 — 服务器端函数,获取页面所需的确切内容。Loaders 代表页面与 API 交互,所以我们将它们放在页面的 `api` segment 中: + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; + +import { GET } from "shared/api"; + +export const loader = async () => { + const { data: articles, error, response } = await GET("/articles"); + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return json({ articles }); +}; +``` + +要将它连接到页面,我们需要从路由文件中以名称 `loader` 导出它: + +```tsx title="pages/feed/index.ts" +export { FeedPage } from "./ui/FeedPage"; +export { loader } from "./api/loader"; +``` + +```tsx title="app/routes/_index.tsx" +import type { MetaFunction } from "@remix-run/node"; +import { FeedPage } from "pages/feed"; + +export { loader } from "pages/feed"; + +export const meta: MetaFunction = () => { + return [{ title: "Conduit" }]; +}; + +export default FeedPage; +``` + +最后一步是在 feed 中渲染这些卡片。使用以下代码更新您的 `FeedPage`: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+
+
+
+ ); +} +``` + +### 按标签过滤 + +关于标签,我们的工作是从后端获取它们并存储当前选中的标签。我们已经知道如何进行获取 — 这是来自 loader 的另一个请求。我们将使用来自已安装的 `remix-utils` 包的便利函数 `promiseHash`。 + +使用以下代码更新 loader 文件 `pages/feed/api/loader.ts`: + +```tsx title="pages/feed/api/loader.ts" +import { json } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async () => { + return json( + await promiseHash({ + articles: throwAnyErrors(GET("/articles")), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +您可能会注意到我们将错误处理提取到一个通用函数 `throwAnyErrors` 中。它看起来非常有用,所以我们可能希望稍后重用它,但现在让我们先留意一下。 + +现在,到标签列表。它需要是交互式的 — 点击标签应该使该标签被选中。按照 Remix 约定,我们将使用 URL 搜索参数作为我们选中标签的存储。让浏览器处理存储,而我们专注于更重要的事情。 + +使用以下代码更新 `pages/feed/ui/FeedPage.tsx`: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const { articles, tags } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +然后我们需要在我们的 loader 中使用 `tag` 搜索参数。将 `pages/feed/api/loader.ts` 中的 `loader` 函数更改为以下内容: + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { params: { query: { tag: selectedTag } } }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +就是这样,不需要 `model` segment。Remix 非常整洁。 + +### 分页 + +以类似的方式,我们可以实现分页。随意自己尝试一下或直接复制下面的代码。反正没有人会判断您。 + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; + +export function FeedPage() { + const [searchParams] = useSearchParams(); + const { articles, tags } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ {articles.articles.map((article) => ( + + ))} + +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ +
+ +
+
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+
+
+
+
+ ); +} +``` + +这样也完成了。还有选项卡列表可以类似地实现,但让我们等到实现身份验证时再处理。说到这个! + +### 身份验证 + +身份验证涉及两个页面 — 一个用于登录,另一个用于注册。它们大部分相同,所以将它们保持在同一个 slice `sign-in` 中是有意义的,这样它们可以在需要时重用代码。 + +在 `pages/sign-in` 的 `ui` segment 中创建 `RegisterPage.tsx`,内容如下: + +```tsx title="pages/sign-in/ui/RegisterPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { register } from "../api/register"; + +export function RegisterPage() { + const registerData = useActionData(); + + return ( +
+
+
+
+

Sign up

+

+ Have an account? +

+ + {registerData?.error && ( +
    + {registerData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +我们现在有一个损坏的导入要修复。它涉及一个新的 segment,所以创建它: + +```bash +npx fsd pages sign-in -s api +``` + +然而,在我们可以实现注册的后端部分之前,我们需要一些供 Remix 处理会话的基础设施代码。这放在 Shared 中,以防其他页面需要它。 + +将以下代码放入 `shared/api/auth.server.ts`。这高度特定于 Remix,所以不要太担心,只需复制粘贴: + +```tsx title="shared/api/auth.server.ts" +import { createCookieSessionStorage, redirect } from "@remix-run/node"; +import invariant from "tiny-invariant"; + +import type { User } from "./models"; + +invariant( + process.env.SESSION_SECRET, + "SESSION_SECRET must be set for authentication to work", +); + +const sessionStorage = createCookieSessionStorage<{ + user: User; +}>({ + cookie: { + name: "__session", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [process.env.SESSION_SECRET], + secure: process.env.NODE_ENV === "production", + }, +}); + +export async function createUserSession({ + request, + user, + redirectTo, +}: { + request: Request; + user: User; + redirectTo: string; +}) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + session.set("user", user); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 7, // 7 days + }), + }, + }); +} + +export async function getUserFromSession(request: Request) { + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); + + return session.get("user") ?? null; +} + +export async function requireUser(request: Request) { + const user = await getUserFromSession(request); + + if (user === null) { + throw redirect("/login"); + } + + return user; +} +``` + +同时从旁边的 `models.ts` 文件中导出 `User` 模型: + +```tsx title="shared/api/models.ts" +import type { components } from "./v1"; + +export type Article = components["schemas"]["Article"]; +export type User = components["schemas"]["User"]; +``` + +在此代码能够工作之前,需要设置 `SESSION_SECRET` 环境变量。在项目根目录中创建一个名为 `.env` 的文件,写入 `SESSION_SECRET=`,然后在键盘上随意敲击一些键来创建一个长的随机字符串。您应该得到类似这样的东西: + +```bash title=".env" +SESSION_SECRET=dontyoudarecopypastethis +``` + +最后,向 public API 添加一些导出以使用此代码: + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +``` + +现在我们可以编写与 RealWorld 后端通信以实际进行注册的代码。我们将其保存在 `pages/sign-in/api` 中。创建一个名为 `register.ts` 的文件,并将以下代码放入其中: + +```tsx title="pages/sign-in/api/register.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const register = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const username = formData.get("username")?.toString() ?? ""; + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users", { + body: { user: { email, password, username } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +``` + +几乎完成了!只需要将页面和操作连接到 `/register` 路由。在 `app/routes` 中创建 `register.tsx`: + +```tsx title="app/routes/register.tsx" +import { RegisterPage, register } from "pages/sign-in"; + +export { register as action }; + +export default RegisterPage; +``` + +现在如果您转到 [http://localhost:3000/register](http://localhost:3000/register),您应该能够创建用户!应用程序的其余部分还不会对此做出反应,我们将立即解决这个问题。 + +以非常类似的方式,我们可以实现登录页面。尝试一下或直接获取代码并继续: + +```tsx title="pages/sign-in/api/sign-in.ts" +import { json, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, createUserSession } from "shared/api"; + +export const signIn = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const email = formData.get("email")?.toString() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + + const { data, error } = await POST("/users/login", { + body: { user: { email, password } }, + }); + + if (error) { + return json({ error }, { status: 400 }); + } else { + return createUserSession({ + request: request, + user: data.user, + redirectTo: "/", + }); + } +}; +``` + +```tsx title="pages/sign-in/ui/SignInPage.tsx" +import { Form, Link, useActionData } from "@remix-run/react"; + +import type { signIn } from "../api/sign-in"; + +export function SignInPage() { + const signInData = useActionData(); + + return ( +
+
+
+
+

Sign in

+

+ Need an account? +

+ + {signInData?.error && ( +
    + {signInData.error.errors.body.map((error) => ( +
  • {error}
  • + ))} +
+ )} + +
+
+ +
+
+ +
+ +
+
+
+
+
+ ); +} +``` + +```tsx title="pages/sign-in/index.ts" +export { RegisterPage } from './ui/RegisterPage'; +export { register } from './api/register'; +export { SignInPage } from './ui/SignInPage'; +export { signIn } from './api/sign-in'; +``` + +```tsx title="app/routes/login.tsx" +import { SignInPage, signIn } from "pages/sign-in"; + +export { signIn as action }; + +export default SignInPage; +``` + +现在让我们给用户一种实际达到这些页面的方法。 + +### 头部 + +正如我们在第一部分中讨论的,应用程序头部通常放在 Widgets 或 Shared 中。我们将其放在 Shared 中,因为它非常简单,所有业务逻辑都可以保持在它之外。让我们为它创建一个地方: + +```bash +npx fsd shared ui +``` + +现在创建 `shared/ui/Header.tsx`,内容如下: + +```tsx title="shared/ui/Header.tsx" +import { useContext } from "react"; +import { Link, useLocation } from "@remix-run/react"; + +import { CurrentUser } from "../api/currentUser"; + +export function Header() { + const currentUser = useContext(CurrentUser); + const { pathname } = useLocation(); + + return ( + + ); +} +``` + +从 `shared/ui` 导出此组件: + +```tsx title="shared/ui/index.ts" +export { Header } from "./Header"; +``` + +在头部中,我们依赖保存在 `shared/api` 中的上下文。也创建它: + +```tsx title="shared/api/currentUser.ts" +import { createContext } from "react"; + +import type { User } from "./models"; + +export const CurrentUser = createContext(null); +``` + +```tsx title="shared/api/index.ts" +export { GET, POST, PUT, DELETE } from "./client"; + +export type { Article } from "./models"; + +export { createUserSession, getUserFromSession, requireUser } from "./auth.server"; +export { CurrentUser } from "./currentUser"; +``` + +现在让我们将头部添加到页面。我们希望它出现在每一个页面上,所以简单地将其添加到根路由并用 `CurrentUser` 上下文提供者包装 outlet(页面将被渲染的地方)是有意义的。这样我们的整个应用程序以及头部都可以访问当前用户对象。我们还将添加一个 loader 来实际从 cookies 中获取当前用户对象。将以下内容放入 `app/root.tsx`: + +```tsx title="app/root.tsx" +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from "@remix-run/react"; + +import { Header } from "shared/ui"; +import { getUserFromSession, CurrentUser } from "shared/api"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + +export const loader = ({ request }: LoaderFunctionArgs) => + getUserFromSession(request); + +export default function App() { + const user = useLoaderData(); + + return ( + + + + + + + + + + + + + +
+ + + + + + + + ); +} +``` + +在这一点,您应该在主页上得到以下结果: + +
+ ![The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing.](../../../../../../static/img/tutorial/realworld-feed-without-tabs.jpg) + +
The feed page of Conduit, including the header, the feed, and the tags. The tabs are still missing.
+
+ +### 选项卡 + +现在我们可以检测身份验证状态,让我们也快速实现选项卡和帖子点赞来完成 feed 页面。我们需要另一个表单,但这个页面文件正在变得有点大,所以让我们将这些表单移动到相邻的文件中。我们将创建 `Tabs.tsx`、`PopularTags.tsx` 和 `Pagination.tsx`,内容如下: + +```tsx title="pages/feed/ui/Tabs.tsx" +import { useContext } from "react"; +import { Form, useSearchParams } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; + +export function Tabs() { + const [searchParams] = useSearchParams(); + const currentUser = useContext(CurrentUser); + + return ( +
+
+
    + {currentUser !== null && ( +
  • + +
  • + )} +
  • + +
  • + {searchParams.has("tag") && ( +
  • + + {searchParams.get("tag")} + +
  • + )} +
+
+
+ ); +} +``` + +```tsx title="pages/feed/ui/PopularTags.tsx" +import { Form, useLoaderData } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import type { loader } from "../api/loader"; + +export function PopularTags() { + const { tags } = useLoaderData(); + + return ( +
+

Popular Tags

+ +
+ +
+ {tags.tags.map((tag) => ( + + ))} +
+ +
+ ); +} +``` + +```tsx title="pages/feed/ui/Pagination.tsx" +import { Form, useLoaderData, useSearchParams } from "@remix-run/react"; +import { ExistingSearchParams } from "remix-utils/existing-search-params"; + +import { LIMIT, type loader } from "../api/loader"; + +export function Pagination() { + const [searchParams] = useSearchParams(); + const { articles } = useLoaderData(); + const pageAmount = Math.ceil(articles.articlesCount / LIMIT); + const currentPage = parseInt(searchParams.get("page") ?? "1", 10); + + return ( +
+ +
    + {Array(pageAmount) + .fill(null) + .map((_, index) => + index + 1 === currentPage ? ( +
  • + {index + 1} +
  • + ) : ( +
  • + +
  • + ), + )} +
+ + ); +} +``` + +现在我们可以显著简化 feed 页面本身: + +```tsx title="pages/feed/ui/FeedPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticlePreview } from "./ArticlePreview"; +import { Tabs } from "./Tabs"; +import { PopularTags } from "./PopularTags"; +import { Pagination } from "./Pagination"; + +export function FeedPage() { + const { articles } = useLoaderData(); + + return ( +
+
+
+

conduit

+

A place to share your knowledge.

+
+
+ +
+
+
+ + + {articles.articles.map((article) => ( + + ))} + + +
+ +
+ +
+
+
+
+ ); +} +``` + +我们还需要在 loader 函数中考虑新选项卡: + +```tsx title="pages/feed/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + /* unchanged */ +} + +/** Amount of articles on one page. */ +export const LIMIT = 20; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const selectedTag = url.searchParams.get("tag") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "", 10); + + if (url.searchParams.get("source") === "my-feed") { + const userSession = await requireUser(request); + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles/feed", { + params: { + query: { + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + headers: { Authorization: `Token ${userSession.token}` }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); + } + + return json( + await promiseHash({ + articles: throwAnyErrors( + GET("/articles", { + params: { + query: { + tag: selectedTag, + limit: LIMIT, + offset: !Number.isNaN(page) ? page * LIMIT : undefined, + }, + }, + }), + ), + tags: throwAnyErrors(GET("/tags")), + }), + ); +}; +``` + +在我们离开 feed 页面之前,让我们添加一些处理帖子点赞的代码。将您的 `ArticlePreview.tsx` 更改为以下内容: + +```tsx title="pages/feed/ui/ArticlePreview.tsx" +import { Form, Link } from "@remix-run/react"; +import type { Article } from "shared/api"; + +interface ArticlePreviewProps { + article: Article; +} + +export function ArticlePreview({ article }: ArticlePreviewProps) { + return ( +
+
+ + + +
+ + {article.author.username} + + + {new Date(article.createdAt).toLocaleDateString(undefined, { + dateStyle: "long", + })} + +
+
+ +
+
+ +

{article.title}

+

{article.description}

+ Read more... +
    + {article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +
+ ); +} +``` + +此代码将向 `/article/:slug` 发送带有 `_action=favorite` 的 POST 请求以将文章标记为收藏。它还不会工作,但当我们开始处理文章阅读器时,我们也会实现这个功能。 + +这样我们就正式完成了 feed!太好了! + +### 文章阅读器 + +首先,我们需要数据。让我们创建一个 loader: + +```bash +npx fsd pages article-read -s api +``` + +```tsx title="pages/article-read/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import invariant from "tiny-invariant"; +import type { FetchResponse } from "openapi-fetch"; +import { promiseHash } from "remix-utils/promise"; + +import { GET, getUserFromSession } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + invariant(params.slug, "Expected a slug parameter"); + const currentUser = await getUserFromSession(request); + const authorization = currentUser + ? { Authorization: `Token ${currentUser.token}` } + : undefined; + + return json( + await promiseHash({ + article: throwAnyErrors( + GET("/articles/{slug}", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + comments: throwAnyErrors( + GET("/articles/{slug}/comments", { + params: { + path: { slug: params.slug }, + }, + headers: authorization, + }), + ), + }), + ); +}; +``` + +```tsx title="pages/article-read/index.ts" +export { loader } from "./api/loader"; +``` + +现在我们可以通过创建一个名为 `article.$slug.tsx` 的路由文件将其连接到路由 `/article/:slug`: + +```tsx title="app/routes/article.$slug.tsx" +export { loader } from "pages/article-read"; +``` + +页面本身由三个主要块组成 — 带有操作的文章头部(重复两次)、文章主体和评论部分。这是页面的标记,它并不特别有趣: + +```tsx title="pages/article-read/ui/ArticleReadPage.tsx" +import { useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { ArticleMeta } from "./ArticleMeta"; +import { Comments } from "./Comments"; + +export function ArticleReadPage() { + const { article } = useLoaderData(); + + return ( +
+
+
+

{article.article.title}

+ + +
+
+ +
+
+
+

{article.article.body}

+
    + {article.article.tagList.map((tag) => ( +
  • + {tag} +
  • + ))} +
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} +``` + +更有趣的是 `ArticleMeta` 和 `Comments`。它们包含写操作,如点赞文章、留下评论等。要让它们工作,我们首先需要实现后端部分。在页面的 `api` segment 中创建 `action.ts`: + +```tsx title="pages/article-read/api/action.ts" +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { namedAction } from "remix-utils/named-action"; +import { redirectBack } from "remix-utils/redirect-back"; +import invariant from "tiny-invariant"; + +import { DELETE, POST, requireUser } from "shared/api"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const currentUser = await requireUser(request); + + const authorization = { Authorization: `Token ${currentUser.token}` }; + + const formData = await request.formData(); + + return namedAction(formData, { + async delete() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirect("/"); + }, + async favorite() { + invariant(params.slug, "Expected a slug parameter"); + await POST("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfavorite() { + invariant(params.slug, "Expected a slug parameter"); + await DELETE("/articles/{slug}/favorite", { + params: { path: { slug: params.slug } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async createComment() { + invariant(params.slug, "Expected a slug parameter"); + const comment = formData.get("comment"); + invariant(typeof comment === "string", "Expected a comment parameter"); + await POST("/articles/{slug}/comments", { + params: { path: { slug: params.slug } }, + headers: { ...authorization, "Content-Type": "application/json" }, + body: { comment: { body: comment } }, + }); + return redirectBack(request, { fallback: "/" }); + }, + async deleteComment() { + invariant(params.slug, "Expected a slug parameter"); + const commentId = formData.get("id"); + invariant(typeof commentId === "string", "Expected an id parameter"); + const commentIdNumeric = parseInt(commentId, 10); + invariant( + !Number.isNaN(commentIdNumeric), + "Expected a numeric id parameter", + ); + await DELETE("/articles/{slug}/comments/{id}", { + params: { path: { slug: params.slug, id: commentIdNumeric } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async followAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await POST("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + async unfollowAuthor() { + const authorUsername = formData.get("username"); + invariant( + typeof authorUsername === "string", + "Expected a username parameter", + ); + await DELETE("/profiles/{username}/follow", { + params: { path: { username: authorUsername } }, + headers: authorization, + }); + return redirectBack(request, { fallback: "/" }); + }, + }); +}; +``` + +从 slice 中导出它,然后从路由中导出。趁着这个机会,让我们也连接页面本身: + +```tsx title="pages/article-read/index.ts" +export { ArticleReadPage } from "./ui/ArticleReadPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/article.$slug.tsx" +import { ArticleReadPage } from "pages/article-read"; + +export { loader, action } from "pages/article-read"; + +export default ArticleReadPage; +``` + +现在,尽管我们还没有在阅读器页面上实现点赞按钮,但 feed 中的点赞按钮将开始工作!这是因为它一直在向这个路由发送"点赞"请求。试试看吧。 + +`ArticleMeta` 和 `Comments` 又是一堆表单。我们之前已经做过这个,让我们获取它们的代码并继续: + +```tsx title="pages/article-read/ui/ArticleMeta.tsx" +import { Form, Link, useLoaderData } from "@remix-run/react"; +import { useContext } from "react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function ArticleMeta() { + const currentUser = useContext(CurrentUser); + const { article } = useLoaderData(); + + return ( +
+
+ + + + +
+ + {article.article.author.username} + + {article.article.createdAt} +
+ + {article.article.author.username == currentUser?.username ? ( + <> + + Edit Article + +    + + + ) : ( + <> + + +    + + + )} +
+
+ ); +} +``` + +```tsx title="pages/article-read/ui/Comments.tsx" +import { useContext } from "react"; +import { Form, Link, useLoaderData } from "@remix-run/react"; + +import { CurrentUser } from "shared/api"; +import type { loader } from "../api/loader"; + +export function Comments() { + const { comments } = useLoaderData(); + const currentUser = useContext(CurrentUser); + + return ( +
+ {currentUser !== null ? ( +
+
+ +
+
+ + +
+
+ ) : ( +
+
+

+ Sign in +   or   + Sign up +   to add comments on this article. +

+
+
+ )} + + {comments.comments.map((comment) => ( +
+
+

{comment.body}

+
+ +
+ + + +   + + {comment.author.username} + + {comment.createdAt} + {comment.author.username === currentUser?.username && ( + +
+ + +
+
+ )} +
+
+ ))} +
+ ); +} +``` + +这样我们的文章阅读器也完成了!关注作者、点赞帖子和留下评论的按钮现在应该能按预期工作。 + +
+ ![Article reader with functioning buttons to like and follow](../../../../../../static/img/tutorial/realworld-article-reader.jpg) + +
Article reader with functioning buttons to like and follow
+
+ +### 文章编辑器 + +这是我们将在本教程中涵盖的最后一个页面,这里最有趣的部分是我们将如何验证表单数据。 + +页面本身,`article-edit/ui/ArticleEditPage.tsx`,将非常简单,额外的复杂性被存储到其他两个组件中: + +```tsx title="pages/article-edit/ui/ArticleEditPage.tsx" +import { Form, useLoaderData } from "@remix-run/react"; + +import type { loader } from "../api/loader"; +import { TagsInput } from "./TagsInput"; +import { FormErrors } from "./FormErrors"; + +export function ArticleEditPage() { + const article = useLoaderData(); + + return ( +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+
+
+
+
+ ); +} +``` + +此页面获取当前文章(除非我们从头开始编写)并填写相应的表单字段。我们之前见过这个。有趣的部分是 `FormErrors`,因为它将接收验证结果并向用户显示。让我们看一下: + +```tsx title="pages/article-edit/ui/FormErrors.tsx" +import { useActionData } from "@remix-run/react"; +import type { action } from "../api/action"; + +export function FormErrors() { + const actionData = useActionData(); + + return actionData?.errors != null ? ( +
    + {actionData.errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null; +} +``` + +这里我们假设我们的 action 将返回 `errors` 字段,一个人类可读的错误消息数组。我们很快就会讲到 action。 + +另一个组件是标签输入。它只是一个普通的输入字段,附带所选标签的额外预览。这里没什么可看的: + +```tsx title="pages/article-edit/ui/TagsInput.tsx" +import { useEffect, useRef, useState } from "react"; + +export function TagsInput({ + name, + defaultValue, +}: { + name: string; + defaultValue?: Array; +}) { + const [tagListState, setTagListState] = useState(defaultValue ?? []); + + function removeTag(tag: string): void { + const newTagList = tagListState.filter((t) => t !== tag); + setTagListState(newTagList); + } + + const tagsInput = useRef(null); + useEffect(() => { + tagsInput.current && (tagsInput.current.value = tagListState.join(",")); + }, [tagListState]); + + return ( + <> + + setTagListState(e.target.value.split(",").filter(Boolean)) + } + /> +
+ {tagListState.map((tag) => ( + + + [" ", "Enter"].includes(e.key) && removeTag(tag) + } + onClick={() => removeTag(tag)} + >{" "} + {tag} + + ))} +
+ + ); +} +``` + +现在,API 部分。loader 应该查看 URL,如果它包含文章 slug,那意味着我们正在编辑现有文章,应该加载其数据。否则,返回空。让我们创建该 loader: + +```ts title="pages/article-edit/api/loader.ts" +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import type { FetchResponse } from "openapi-fetch"; + +import { GET, requireUser } from "shared/api"; + +async function throwAnyErrors( + responsePromise: Promise>, +) { + const { data, error, response } = await responsePromise; + + if (error !== undefined) { + throw json(error, { status: response.status }); + } + + return data as NonNullable; +} + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const currentUser = await requireUser(request); + + if (!params.slug) { + return { article: null }; + } + + return throwAnyErrors( + GET("/articles/{slug}", { + params: { path: { slug: params.slug } }, + headers: { Authorization: `Token ${currentUser.token}` }, + }), + ); +}; +``` + +action 将获取新的字段值,通过我们的数据模式运行它们,如果一切都正确,就将这些更改提交到后端,通过更新现有文章或创建新文章: + +```tsx title="pages/article-edit/api/action.ts" +import { json, redirect, type ActionFunctionArgs } from "@remix-run/node"; + +import { POST, PUT, requireUser } from "shared/api"; +import { parseAsArticle } from "../model/parseAsArticle"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + try { + const { body, description, title, tags } = parseAsArticle( + await request.formData(), + ); + const tagList = tags?.split(",") ?? []; + + const currentUser = await requireUser(request); + const payload = { + body: { + article: { + title, + description, + body, + tagList, + }, + }, + headers: { Authorization: `Token ${currentUser.token}` }, + }; + + const { data, error } = await (params.slug + ? PUT("/articles/{slug}", { + params: { path: { slug: params.slug } }, + ...payload, + }) + : POST("/articles", payload)); + + if (error) { + return json({ errors: error }, { status: 422 }); + } + + return redirect(`/article/${data.article.slug ?? ""}`); + } catch (errors) { + return json({ errors }, { status: 400 }); + } +}; +``` + +模式同时作为 `FormData` 的解析函数,这使我们可以方便地获取干净的字段或只是抛出错误在末尾处理。这里是该解析函数的样子: + +```tsx title="pages/article-edit/model/parseAsArticle.ts" +export function parseAsArticle(data: FormData) { + const errors = []; + + const title = data.get("title"); + if (typeof title !== "string" || title === "") { + errors.push("Give this article a title"); + } + + const description = data.get("description"); + if (typeof description !== "string" || description === "") { + errors.push("Describe what this article is about"); + } + + const body = data.get("body"); + if (typeof body !== "string" || body === "") { + errors.push("Write the article itself"); + } + + const tags = data.get("tags"); + if (typeof tags !== "string") { + errors.push("The tags must be a string"); + } + + if (errors.length > 0) { + throw errors; + } + + return { title, description, body, tags: data.get("tags") ?? "" } as { + title: string; + description: string; + body: string; + tags: string; + }; +} +``` + +可以说,它有点凗长和重复,但这是我们为人类可读错误付出的代价。这也可以是一个 Zod 模式,例如,但然后我们必须在前端渲染错误消息,这个表单不值得复杂化。 + +最后一步 — 将页面、loader 和 action 连接到路由。由于我们巧妙地支持创建和编辑,我们可以从 `editor._index.tsx` 和 `editor.$slug.tsx` 两者导出相同的东西: + +```tsx title="pages/article-edit/index.ts" +export { ArticleEditPage } from "./ui/ArticleEditPage"; +export { loader } from "./api/loader"; +export { action } from "./api/action"; +``` + +```tsx title="app/routes/editor._index.tsx, app/routes/editor.$slug.tsx (same content)" +import { ArticleEditPage } from "pages/article-edit"; + +export { loader, action } from "pages/article-edit"; + +export default ArticleEditPage; +``` + +我们现在完成了!登录并尝试创建一篇新文章。或者“忘记”编写文章并看到验证生效。 + +
+ ![The Conduit article editor, with the title field saying “New article” and the rest of the fields empty. Above the form there are two errors: “**Describe what this article is about” and “Write the article itself”.**](../../../../../../static/img/tutorial/realworld-article-editor.jpg) + +
The Conduit article editor, with the title field saying “New article” and the rest of the fields empty. Above the form there are two errors: **“Describe what this article is about”** and **“Write the article itself”**.
+
+ +资料和设置页面与文章阅读器和编辑器非常相似,它们留作读者的练习,这就是您 :) diff --git a/src/content/docs/zh/docs/guides/examples/api-requests.mdx b/src/content/docs/zh/docs/guides/examples/api-requests.mdx new file mode 100644 index 0000000000..916554af92 --- /dev/null +++ b/src/content/docs/zh/docs/guides/examples/api-requests.mdx @@ -0,0 +1,144 @@ +--- +title: 处理 API 请求 +sidebar: + order: 4 +--- + +import { FileTree, Tabs, TabItem, Aside } from '@astrojs/starlight/components'; + +## 共享 API 请求 \{#shared-api-requests} + +首先将通用的 API 请求逻辑放在 `shared/api` 目录中。这使得在应用程序中重用请求变得容易,并有助于更快的原型开发。对于许多项目来说,这就是 API 调用所需的全部内容。 + +典型的文件结构是: + + +- shared/ + - client.ts + - index.ts + - endpoints/ + - login.ts + + +`client.ts` 文件集中了您的 HTTP 请求设置。它包装您选择的方法(如 `fetch()` 或 `axios` 实例)并处理常见配置,例如: + +- 后端基础 URL。 +- 默认头部(例如,用于身份验证)。 +- 数据序列化。 + +以下是 `axios` 和 `fetch` 的示例: + + + + +```ts title="shared/api/client.ts" +// Example using axios +import axios from 'axios'; + +export const client = axios.create({ + baseURL: 'https://your-api-domain.com/api/', + timeout: 5000, + headers: { 'X-Custom-Header': 'my-custom-value' } +}); +``` + + + +```ts title="shared/api/client.ts" +export const client = { + async post(endpoint: string, body: any, options?: RequestInit) { + const response = await fetch(`https://your-api-domain.com/api${endpoint}`, { + method: 'POST', + body: JSON.stringify(body), + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'my-custom-value', + ...options?.headers, + }, + }); + return response.json(); + } + // ... other methods like put, delete, etc. +}; +``` + + + + +在 `shared/api/endpoints` 中组织您的单个 API 请求函数,按 API 端点分组。 + + + +```ts title="shared/api/endpoints/login.ts" +import { client } from '../client'; + +export interface LoginCredentials { + email: string; + password: string; +} + +export function login(credentials: LoginCredentials) { + return client.post('/login', credentials); +} +``` +在 `shared/api` 中使用 `index.ts` 文件来导出您的请求函数。 + +```ts title="shared/api/index.ts" +export { client } from './client'; // 如果您想导出客户端本身 +export { login } from './endpoints/login'; +export type { LoginCredentials } from './endpoints/login'; +``` + +## 特定 Slice 的 API 请求 \{#slice-specific-api-requests} + +如果 API 请求仅由特定 slice(如单个页面或功能)使用且不会被重用,请将其放在该 slice 的 api segment 中。这样可以保持特定 slice 的逻辑整齐地包含在内。 + + +- pages/ + - login/ + - index.ts + - api/ + - login.ts + - ui/ + - LoginPage.tsx + + +```ts title="pages/login/api/login.ts" +import { client } from 'shared/api'; + +interface LoginCredentials { + email: string; + password: string; +} + +export function login(credentials: LoginCredentials) { + return client.post('/login', credentials); +} +``` + +您不需要在页面的公共 API 中导出 `login()` 函数,因为应用程序中的其他地方不太可能需要这个请求。 + + + +## 使用客户端生成器 \{#client-generators} + +如果您的后端有 OpenAPI 规范,像 [orval](https://orval.dev/) 或 [openapi-typescript](https://openapi-ts.dev/) 这样的工具可以为您生成 API 类型和请求函数。将生成的代码放在,例如 `shared/api/openapi` 中。确保包含 `README.md` 来记录这些类型是什么,以及如何生成它们。 + +## 与服务器状态库集成 \{#server-state-libraries} + +当使用像 [TanStack Query (React Query)](https://tanstack.com/query/latest) 或 [Pinia Colada](https://pinia-colada.esm.dev/) 这样的服务器状态库时,您可能需要在 slices 之间共享类型或缓存键。将以下内容使用 `shared` 层: + +- API 数据类型 +- 缓存键 +- 通用查询/变更选项 + +有关如何使用服务器状态库的更多详细信息,请参阅 [React Query 文章](/docs/guides/tech/with-react-query) diff --git a/src/content/docs/zh/docs/guides/examples/auth.mdx b/src/content/docs/zh/docs/guides/examples/auth.mdx new file mode 100644 index 0000000000..5da31a0f90 --- /dev/null +++ b/src/content/docs/zh/docs/guides/examples/auth.mdx @@ -0,0 +1,178 @@ +--- +title: 身份验证 +sidebar: + order: 1 +--- + +import { Aside, FileTree } from '@astrojs/starlight/components'; + +广义上,身份验证包含以下步骤: + +1. 从用户获取凭据 +1. 将它们发送到后端 +1. 存储 token 以进行经过身份验证的请求 + +## 如何从用户获取凭据 + +我们假设您的应用程序负责获取凭据。如果您通过 OAuth 进行身份验证,您可以简单地创建一个登录页面,其中包含指向 OAuth 提供商登录页面的链接,然后跳转到[步骤 3](#how-to-store-the-token-for-authenticated-requests)。 + +### 专用登录页面 + +通常,网站有专用的登录页面,您在其中输入用户名和密码。这些页面相当简单,所以不需要分解。登录和注册表单在外观上相当相似,所以它们甚至可以被组合在一个页面中。在 Pages layer 上为您的登录/注册页面创建一个 slice: + + +- pages/ + - login/ + - ui/ + - LoginPage.tsx + - RegisterPage.tsx + - index.ts + - ... + + +在这里我们创建了两个组件并在 slice 的 index 文件中导出它们。这些组件将包含表单,负责为用户提供可理解的控件来获取他们的凭据。 + +### 登录对话框 + +如果您的应用程序有一个可以在任何页面上使用的登录对话框,请考虑将该对话框设为 widget。这样,您仍然可以避免过多的分解,但可以自由地在任何页面上重用此对话框。 + + +- widgets/ + - login-dialog/ + - ui/ + - LoginDialog.tsx + - index.ts + - ... + + +本指南的其余部分是为专用页面方法编写的,但相同的原则也适用于对话框 widget。 + +### 客户端验证 + +有时,特别是对于注册,执行客户端验证是有意义的,可以让用户快速知道他们犯了错误。验证可以在登录页面的 `model` segment 中进行。使用 schema 验证库,例如 JS/TS 的 [Zod][ext-zod],并将该 schema 暴露给 `ui` segment: + +```ts title="pages/login/model/registration-schema.ts" +import { z } from "zod"; + +export const registrationData = z.object({ + email: z.string().email(), + password: z.string().min(6), + confirmPassword: z.string(), +}).refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], +}); +``` + +然后,在 `ui` segment 中,您可以使用此 schema 来验证用户输入: + +```tsx title="pages/login/ui/RegisterPage.tsx" +import { registrationData } from "../model/registration-schema"; + +function validate(formData: FormData) { + const data = Object.fromEntries(formData.entries()); + try { + registrationData.parse(data); + } catch (error) { + // TODO: Show error message to the user + } +} + +export function RegisterPage() { + return ( +
validate(new FormData(e.target))}> + + + + + + + + +
+ ) +} +``` + +## 如何将凭据发送到后端 + +创建一个向后端登录端点发出请求的函数。此函数可以使用 mutation 库(例如 TanStack Query)直接在组件代码中调用,也可以作为状态管理器中的副作用调用。如 [API 请求指南][examples-api-requests] 中所述,您可以将请求放在 `shared/api` 中或登录页面的 `api` segment 中。 + +### 双因素认证 + +如果您的应用程序支持双因素认证(2FA),您可能需要重定向到另一个页面,用户可以在其中输入一次性密码。通常,您的 `POST /login` 请求会返回带有标志的用户对象,指示用户已启用 2FA。如果设置了该标志,请将用户重定向到 2FA 页面。 + +由于此页面与登录密切相关,您也可以将其保留在 Pages layer 上的同一个 slice `login` 中。 + +您还需要另一个请求函数,类似于我们上面创建的 `login()`。将它们放在一起,要么在 Shared 中,要么在 `login` 页面的 `api` segment 中。 + +## 如何存储 token 以进行经过身份验证的请求 \{#how-to-store-the-token-for-authenticated-requests} + +无论您使用哪种身份验证方案,无论是简单的登录和密码、OAuth 还是双因素认证,最终您都会收到一个 token。应该存储此 token,以便后续请求可以识别自己。 + +Web 应用程序的理想 token 存储是 **cookie** — 它不需要手动 token 存储或处理。因此,cookie 存储几乎不需要从前端架构方面考虑。如果您的前端框架有服务器端(例如 [Remix][ext-remix]),那么您应该将服务器端 cookie 基础设施存储在 `shared/api` 中。在[教程的身份验证部分][tutorial-authentication]中有一个如何使用 Remix 做到这一点的示例。 + +但是,有时 cookie 存储不是一个选项。在这种情况下,您将必须手动存储 token。除了存储 token 之外,您可能还需要设置在 token 过期时刷新 token 的逻辑。使用 FSD,有几个地方可以存储 token,以及几种方法可以使其对应用程序的其余部分可用。 + +### 在 Shared 中 + +这种方法与在 `shared/api` 中定义的 API 客户端配合得很好,因为 token 可以自由地用于其他需要身份验证才能成功的请求函数。您可以让 API 客户端保持状态,无论是使用响应式存储还是简单的模块级变量,并在您的 `login()`/`logout()` 函数中更新该状态。 + +自动 token 刷新可以作为 API 客户端中的中间件实现 — 每次您发出任何请求时都可以执行的东西。它可以这样工作: + +- 认证并存储访问 token 以及刷新 token +- 发出任何需要身份验证的请求 +- 如果请求失败并返回指示 token 过期的状态码,并且存储中有 token,则发出刷新请求,存储新的 token,并重试原始请求 + +这种方法的缺点之一是管理和刷新token的逻辑没有专门的位置。对于某些应用程序或团队来说,这可能是可以接受的,但如果token管理逻辑更复杂,最好将发出请求和管理token的职责分开。你可以通过将请求和API客户端保留在`shared/api`中,但将token存储和管理逻辑放在`shared/auth`中来实现这一点。 + +这种方法的另一个缺点是,如果你的后端返回当前用户信息的对象以及token,你必须将其存储在某处或丢弃该信息,并从诸如`/me`或`/users/current`之类的端点再次请求它。 + +### 在 Entities 中 + +FSD 项目通常有一个用户实体和/或当前用户实体。甚至可以是同一个实体。 + + + +要在用户实体中存储token,请在`model`段中创建一个响应式存储。该存储可以同时包含token和用户对象。 + +由于API客户端通常在`shared/api`中定义或分布在各个实体中,这种方法的主要挑战是在不违反[层级导入规则][import-rule-on-layers]的情况下使token对需要它的其他请求可用: + +> 切片中的模块(文件)只能在其他切片位于严格较低的层级时导入它们。 + +有几种解决这个挑战的方案: + +1. **每次发出请求时手动传递token** + 这是最简单的解决方案,但很快就会变得繁琐,如果你没有类型安全,很容易忘记。它也与Shared中API客户端的中间件模式不兼容。 +1. **通过上下文或像`localStorage`这样的全局存储将token暴露给整个应用程序** + 检索token的键将保存在`shared/api`中,以便API客户端可以访问它。token的响应式存储将从用户实体导出,上下文提供者(如果需要)将在App层设置。这为设计API客户端提供了更多自由,但是,这会对更高层级提供上下文创建隐式依赖。遵循这种方法时,如果上下文或`localStorage`没有正确设置,请考虑提供有用的错误消息。 +1. **每次token更改时将其注入API客户端** + 如果你的存储是响应式的,你可以创建一个订阅,每次实体中的存储更改时都会更新API客户端的token存储。这与前一个解决方案类似,因为它们都对更高层级创建隐式依赖,但这个更具命令性("推送"),而前一个更具声明性("拉取")。 + +一旦你克服了暴露存储在实体模型中的token的挑战,你就可以编码更多与token管理相关的业务逻辑。例如,`model`段可以包含在一定时间后使token失效的逻辑,或在token过期时刷新token的逻辑。要实际向后端发出请求,请使用用户实体的`api`段或`shared/api`。 + +### 在页面/小部件中(不推荐) + +不建议在页面或小部件中存储像访问token这样的应用程序范围状态。避免将token存储放在登录页面的`model`段中,而是从前两个解决方案中选择:Shared或Entities。 + +## 登出和 token 失效 + +通常,应用程序没有专门的登出页面,但登出功能仍然非常重要。它包括对后端的经过身份验证的请求和对 token 存储的更新。 + +如果您将所有请求存储在 `shared/api` 中,请将登出请求函数保留在那里,靠近登录函数。否则,请考虑将登出请求函数保留在触发它的按钮旁边。例如,如果您有一个出现在每个页面上并包含登出链接的头部 widget,请将该请求放在该 widget 的 `api` segment 中。 + +token 存储的更新必须从登出按钮的位置触发,比如头部 widget。您可以在该 widget 的 `model` segment 中组合请求和存储更新。 + +### 自动登出 + +不要忘记为登出请求失败或刷新登录 token 请求失败时构建故障保护。在这两种情况下,您都应该清除 token 存储。如果您将 token 保存在 Entities 中,此代码可以放在 `model` segment 中,因为它是纯业务逻辑。如果您将 token 保存在 Shared 中,将此逻辑放在 `shared/api` 中可能会使 segment 膨胀并稀释其目的。如果您注意到您的 API segment 包含几个不相关的东西,请考虑将 token 管理逻辑拆分到另一个 segment 中,例如 `shared/auth`。 + +[tutorial-authentication]: /docs/get-started/tutorial#authentication +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[examples-api-requests]: /docs/guides/examples/api-requests +[ext-remix]: https://remix.run +[ext-zod]: https://zod.dev diff --git a/src/content/docs/zh/docs/guides/examples/page-layout.mdx b/src/content/docs/zh/docs/guides/examples/page-layout.mdx new file mode 100644 index 0000000000..765d16964f --- /dev/null +++ b/src/content/docs/zh/docs/guides/examples/page-layout.mdx @@ -0,0 +1,104 @@ +--- +title: 页面布局 +sidebar: + order: 3 +--- + +import { Aside } from '@astrojs/starlight/components'; + +本指南探讨了_页面布局_的抽象 — 当多个页面共享相同的整体结构,仅在主要内容上有所不同时。 + + + +## 简单布局 + +最简单的布局可以在此页面上看到。它有一个带有站点导航的头部、两个侧边栏和一个带有外部链接的页脚。没有复杂的业务逻辑,唯一的动态部分是侧边栏和头部右侧的切换器。这样的布局可以完全放置在 `shared/ui` 或 `app/layouts` 中,通过 props 填充侧边栏的内容: + +```tsx title="shared/ui/layout/Layout.tsx" +import { Link, Outlet } from "react-router-dom"; +import { useThemeSwitcher } from "./useThemeSwitcher"; + +export function Layout({ siblingPages, headings }) { + const [theme, toggleTheme] = useThemeSwitcher(); + + return ( +
+
+ + +
+
+ + {/* 这里是主要内容的位置 */} + +
+
+
    +
  • GitHub
  • +
  • Twitter
  • +
+
+
+ ); +} +``` + +```ts title="shared/ui/layout/useThemeSwitcher.ts" +export function useThemeSwitcher() { + const [theme, setTheme] = useState("light"); + + function toggleTheme() { + setTheme(theme === "light" ? "dark" : "light"); + } + + useEffect(() => { + document.body.classList.remove("light", "dark"); + document.body.classList.add(theme); + }, [theme]); + + return [theme, toggleTheme] as const; +} +``` + +侧边栏的代码留给读者作为练习 😉。 + +## 在布局中使用 widgets + +有时您希望在布局中包含某些业务逻辑,特别是如果您使用像 [React Router][ext-react-router] 这样的路由器的深度嵌套路由。然后由于[层上的导入规则][import-rule-on-layers],您无法将布局存储在 Shared 或 Widgets 中: + +> slice 中的模块只能在其他 slices 位于严格较低的层时导入它们。 + +在我们讨论解决方案之前,我们需要讨论这是否首先是一个问题。您_真的需要_那个布局吗?如果需要,它_真的需要_成为一个 Widget 吗?如果所讨论的业务逻辑块在 2-3 个页面上重用,而布局只是该 widget 的一个小包装器,请考虑以下两个选项之一: + +1. **在 App 层内联编写布局,在那里配置路由** + 这对于支持嵌套的路由器来说很棒,因为您可以将某些路由分组并仅对它们应用布局。 + +2. **直接复制粘贴** + 抽象代码的冲动往往被过度高估。对于很少更改的布局来说尤其如此。在某个时候,如果其中一个页面需要更改,您可以简单地进行更改,而不会不必要地影响其他页面。如果您担心有人可能忘记更新其他页面,您总是可以留下描述页面之间关系的注释。 + +如果上述都不适用,有两种解决方案可以在布局中包含 widget: + +1. **使用 render props 或 slots** + 大多数框架允许您从外部传递一段 UI。在 React 中,这被称为 [render props][ext-render-props],在 Vue 中被称为 [slots][ext-vue-slots]。 +2. **将布局移动到 App 层** + 您也可以将布局存储在 App 层,例如在 `app/layouts` 中,并组合您想要的任何 widgets。 + +## 延伸阅读 + +- 在[教程][tutorial]中有一个如何使用 React 和 Remix(相当于 React Router)构建带有身份验证的布局的示例。 + +[tutorial]: /docs/get-started/tutorial +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-react-router]: https://reactrouter.com/ +[ext-render-props]: https://www.patterns.dev/react/render-props-pattern/ +[ext-vue-slots]: https://vuejs.org/guide/components/slots diff --git a/src/content/docs/zh/docs/guides/examples/types.mdx b/src/content/docs/zh/docs/guides/examples/types.mdx new file mode 100644 index 0000000000..40675768c6 --- /dev/null +++ b/src/content/docs/zh/docs/guides/examples/types.mdx @@ -0,0 +1,446 @@ +--- +title: 类型 +sidebar: + order: 2 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +本指南涉及来自类型化语言(如 TypeScript)的数据类型,并描述它们在 FSD 中的适用位置。 + + + +## 实用类型 + +实用类型是本身没有太多意义的类型,通常与其他类型一起使用。例如: + +
+ +```ts +type ArrayValues = T[number]; +``` + +
+ Source: https://github.com/sindresorhus/type-fest/blob/main/source/array-values.d.ts +
+ +
+ +要使实用类型在整个项目中可用,可以安装像 [`type-fest`][ext-type-fest] 这样的库,或者在 `shared/lib` 中创建您自己的库。确保清楚地指出哪些新类型_应该_添加到此库中,哪些类型_不属于_那里。例如,将其命名为 `shared/lib/utility-types` 并在其中添加一个 README,描述您团队中什么是实用类型。 + +不要高估实用类型的潜在可重用性。仅仅因为它可以被重用,并不意味着它会被重用,因此,并非每个实用类型都需要在 Shared 中。一些实用类型放在需要它们的地方就很好: + + +- pages/ + - home/ + - api/ + - ArrayValues.ts utility type + - getMemoryUsageMetrics.ts uses the utility type + + + + +## 业务实体及其交叉引用 + +应用程序中最重要的类型之一是业务实体的类型,即您的应用程序处理的现实世界的事物。例如,在音乐流媒体应用程序中,您可能有业务实体 _Song_、_Album_ 等。 + +业务实体通常来自后端,因此第一步是为后端响应添加类型。为每个端点创建一个请求函数,并为此函数的响应添加类型是很方便的。为了额外的类型安全,您可能希望通过像 [Zod][ext-zod] 这样的 schema 验证库来运行响应。 + +例如,如果您将所有请求保存在 Shared 中,您可以这样做: + +```ts title="shared/api/songs.ts" +import type { Artist } from "./artists"; + +interface Song { + id: number; + title: string; + artists: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +您可能会注意到 `Song` 类型引用了不同的实体 `Artist`。这是将请求存储在 Shared 中的好处 — 现实世界的类型通常是相互交织的。如果我们将此函数保存在 `entities/song/api` 中,我们将无法简单地从 `entities/artist` 导入 `Artist`,因为 FSD 通过[层上的导入规则][import-rule-on-layers]限制 slices 之间的交叉导入: + +> slice 中的模块只能在其他 slices 位于严格较低的层时导入它们。 + +有两种方法来处理这个问题: + +1. **参数化您的类型** + 您可以让您的类型接受类型参数作为与其他实体连接的插槽,甚至可以对这些插槽施加约束。例如: + + ```ts title="entities/song/model/song.ts" + interface Song { + id: number; + title: string; + artists: Array; + } + ``` + + 这对某些类型比其他类型效果更好。像 `Cart = { items: Array }` 这样的简单类型可以很容易地与任何类型的产品一起工作。更连接的类型,如 `Country` 和 `City`,可能不那么容易分离。 + +2. **交叉导入(但要正确地做)** + 要在 FSD 中的实体之间进行交叉导入,您可以为每个将要交叉导入的 slice 使用特殊的公共 API。例如,如果我们有实体 `song`、`artist` 和 `playlist`,后两者需要引用 `song`,我们可以在 `song` 实体中使用 `@x` 符号为它们创建两个特殊的公共 API: + + + - entities/ + - song/ + - @x/ + - artist.ts 供 `artist` 实体导入的公共 API + - playlist.ts 供 `playlist` 实体导入的公共 API + - index.ts 常规公共 API + + + 文件 `📄 entities/song/@x/artist.ts` 的内容类似于 `📄 entities/song/index.ts`: + + ```ts title="entities/song/@x/artist.ts" + export type { Song } from "../model/song.ts"; + ``` + + 然后 `📄 entities/artist/model/artist.ts` 可以像这样导入 `Song`: + + ```ts title="entities/artist/model/artist.ts" + import type { Song } from "entities/song/@x/artist"; + + export interface Artist { + name: string; + songs: Array; + } + ``` + + 通过在实体之间建立显式连接,我们掌握相互依赖关系并保持良好的域分离水平。 + +## 数据传输对象和映射器 \{#data-transfer-objects-and-mappers} + +数据传输对象,或 DTO,是一个描述来自后端的数据形状的术语。有时,DTO 可以直接使用,但有时对前端来说不太方便。这就是映射器发挥作用的地方 — 它们将 DTO 转换为更方便的形状。 + +### 在哪里放置 DTO + +如果您在单独的包中有后端类型(例如,如果您在前端和后端之间共享代码),那么只需从那里导入您的 DTO 就完成了!如果您不在后端和前端之间共享代码,那么您需要将 DTO 保存在前端代码库的某个地方,我们将在下面探讨这种情况。 + +如果您的请求函数在 `shared/api` 中,那么 DTO 应该放在那里,就在使用它们的函数旁边: + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + artist_ids: Array; +} + +export function listSongs() { + return fetch('/api/songs').then((res) => res.json() as Promise>); +} +``` + +如前一节所述,将请求和 DTO 存储在 Shared 中的好处是能够引用其他 DTO。 + +### 在哪里放置映射器 + +映射器是接受 DTO 进行转换的函数,因此,它们应该位于 DTO 定义附近。在实践中,这意味着如果您的请求和 DTO 在 `shared/api` 中定义,那么映射器也应该放在那里: + +```ts title="shared/api/songs.ts" +import type { ArtistDTO } from "./artists"; + +interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} + +interface Song { + id: string; + title: string; + /** The full title of the song, including the disc number. */ + fullTitle: string; + artistIds: Array; +} + +function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +如果您的请求和存储在实体 slices 中定义,那么所有这些代码都会放在那里,请记住 slices 之间交叉导入的限制: + +```ts title="entities/song/api/dto.ts" +import type { ArtistDTO } from "entities/artist/@x/song"; + +export interface SongDTO { + id: number; + title: string; + disc_no: number; + artist_ids: Array; +} +``` + +```ts title="entities/song/api/mapper.ts" +import type { SongDTO } from "./dto"; + +export interface Song { + id: string; + title: string; + /** The full title of the song, including the disc number. */ + fullTitle: string; + artistIds: Array; +} + +export function adaptSongDTO(dto: SongDTO): Song { + return { + id: String(dto.id), + title: dto.title, + fullTitle: `${dto.disc_no} / ${dto.title}`, + artistIds: dto.artist_ids.map(String), + }; +} +``` + +```ts title="entities/song/api/listSongs.ts" +import { adaptSongDTO } from "./mapper"; + +export function listSongs() { + return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO)); +} +``` + +```ts title="entities/song/model/songs.ts" +import { createSlice, createEntityAdapter } from "@reduxjs/toolkit"; + +import { listSongs } from "../api/listSongs"; + +export const fetchSongs = createAsyncThunk('songs/fetchSongs', listSongs); + +const songAdapter = createEntityAdapter(); +const songsSlice = createSlice({ + name: "songs", + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSongs.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload); + }) + }, +}); +``` + +### 如何处理嵌套 DTO + +最有问题的部分是当来自后端的响应包含多个实体时。例如,如果歌曲不仅包含作者的 ID,还包含整个作者对象。在这种情况下,实体不可能不相互了解(除非我们想要丢弃数据或与后端团队进行坚定的对话)。与其想出 slices 之间间接连接的解决方案(例如将操作分派到其他 slices 的通用中间件),不如使用 `@x` 符号进行显式交叉导入。以下是我们如何使用 Redux Toolkit 实现它: + +```ts title="entities/song/model/songs.ts" +import { + createSlice, + createEntityAdapter, + createAsyncThunk, + createSelector, +} from '@reduxjs/toolkit' +import { normalize, schema } from 'normalizr' + +import { getSong } from "../api/getSong"; + +// 定义 normalizr 实体 schemas +export const artistEntity = new schema.Entity('artists') +export const songEntity = new schema.Entity('songs', { + artists: [artistEntity], +}) + +const songAdapter = createEntityAdapter() + +export const fetchSong = createAsyncThunk( + 'songs/fetchSong', + async (id: string) => { + const data = await getSong(id) + // 规范化数据,以便 reducers 可以加载可预测的 payload,如: + // `action.payload = { songs: {}, artists: {} }` + const normalized = normalize(data, songEntity) + return normalized.entities + } +) + +export const slice = createSlice({ + name: 'songs', + initialState: songAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + songAdapter.upsertMany(state, action.payload.songs) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +```ts title="entities/song/@x/artist.ts" +export { fetchSong } from "../model/songs"; +``` + +```ts title="entities/artist/model/artists.ts" +import { createSlice, createEntityAdapter } from '@reduxjs/toolkit' + +import { fetchSong } from 'entities/song/@x/artist' + +const artistAdapter = createEntityAdapter() + +export const slice = createSlice({ + name: 'users', + initialState: artistAdapter.getInitialState(), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchSong.fulfilled, (state, action) => { + // 通过在这里插入艺术家来处理相同的获取结果 + artistAdapter.upsertMany(state, action.payload.artists) + }) + }, +}) + +const reducer = slice.reducer +export default reducer +``` + +这稍微限制了 slice 隔离的好处,但它准确地表示了我们无法控制的这两个实体之间的连接。如果这些实体要被重构,它们必须一起重构。 + +## 全局类型和 Redux + +全局类型是将在整个应用程序中使用的类型。根据它们需要了解的内容,有两种全局类型: +1. 没有任何应用程序特定内容的通用类型 +2. 需要了解整个应用程序的类型 + +第一种情况很容易解决 — 将您的类型放在 Shared 中的适当 segment 中。例如,如果您有一个用于分析的全局变量接口,您可以将其放在 `shared/analytics` 中。 + + + +第二种情况在没有 RTK 的 Redux 项目中很常见。您的最终存储类型只有在将所有 reducer 添加在一起后才可用,但此存储类型需要对您在应用程序中使用的选择器可用。例如,这是您的典型存储定义: + +```ts title="app/store/index.ts" +import { combineReducers, rootReducer } from "redux"; + +import { songReducer } from "entities/song"; +import { artistReducer } from "entities/artist"; + +const rootReducer = combineReducers(songReducer, artistReducer); + +const store = createStore(rootReducer); + +type RootState = ReturnType; +type AppDispatch = typeof store.dispatch; +``` + +在 `shared/store` 中拥有类型化的 Redux hooks `useAppDispatch` 和 `useAppSelector` 会很好,但由于[层上的导入规则][import-rule-on-layers],它们无法从 App 层导入 `RootState` 和 `AppDispatch`: + +> slice 中的模块只能在其他 slices 位于严格较低的层时导入它们。 + +在这种情况下,推荐的解决方案是在 Shared 和 App 层之间创建隐式依赖关系。这两种类型 `RootState` 和 `AppDispatch` 不太可能改变,Redux 开发者会熟悉它们,所以我们不必太担心它们。 + +在 TypeScript 中,您可以通过将类型声明为全局来做到这一点: + +```ts title="app/store/index.ts" +/* 与之前代码块中的内容相同… */ + +declare type RootState = ReturnType; +declare type AppDispatch = typeof store.dispatch; +``` + +```ts title="shared/store/index.ts" +import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux"; + +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector: TypedUseSelectorHook = useSelector; +``` + +## 枚举 + +枚举的一般规则是它们应该**尽可能接近使用位置**定义。当枚举表示特定于单个功能的值时,它应该在同一功能中定义。 + +segment 的选择也应该由使用位置决定。例如,如果您的枚举包含屏幕上 toast 的位置,它应该放在 `ui` segment 中。如果它表示后端操作的加载状态,它应该放在 `api` segment 中。 + +一些枚举在整个项目中确实是通用的,如一般的后端响应状态或设计系统令牌。在这种情况下,您可以将它们放在 Shared 中,并根据枚举所代表的内容选择 segment(响应状态用 `api`,设计令牌用 `ui` 等)。 + +## 类型验证 schemas 和 Zod + +如果您想验证您的数据符合某种形状或约束,您可以定义一个验证 schema。在 TypeScript 中,这项工作的流行库是 [Zod][ext-zod]。验证 schemas 也应该尽可能与使用它们的代码放在一起。 + +验证 schemas 类似于映射器(如[数据传输对象和映射器](#data-transfer-objects-and-mappers)部分所讨论的),它们接受数据传输对象并解析它,如果解析失败则产生错误。 + +验证最常见的情况之一是来自后端的数据。通常,当数据与 schema 不匹配时,您希望请求失败,因此将 schema 放在与请求函数相同的位置是有意义的,这通常是 `api` segment。 + +如果您的数据通过用户输入(如表单)传入,验证应该在输入数据时进行。您可以将 schema 放在 `ui` segment 中,紧挨着表单组件,或者如果 `ui` segment 太拥挤,可以放在 `model` segment 中。 + +## 组件 props 和 context 的类型定义 + +一般来说,最好将 props 或 context 接口保存在使用它们的组件或 context 的同一文件中。如果您有一个单文件组件的框架,如 Vue 或 Svelte,并且您无法在同一文件中定义 props 接口,或者您想在几个组件之间共享该接口,请在同一文件夹中创建一个单独的文件,通常是 `ui` segment。 + +以下是 JSX(React 或 Solid)的示例: + +```ts title="pages/home/ui/RecentActions.tsx" +interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} + +export function RecentActions({ actions }: RecentActionsProps) { + /* … */ +} +``` + +以下是将接口存储在 Vue 的单独文件中的示例: + +```ts title="pages/home/ui/RecentActionsProps.ts" +export interface RecentActionsProps { + actions: Array<{ id: string; text: string }>; +} +``` + +```html title="pages/home/ui/RecentActions.vue" + +``` + +## 环境声明文件 (`*.d.ts`) + +一些包,例如 [Vite][ext-vite] 或 [ts-reset][ext-ts-reset],需要环境声明文件才能在您的应用程序中工作。通常,它们不大也不复杂,所以它们通常不需要任何架构,只需将它们放在 `src/` 文件夹中即可。为了保持 `src` 更有组织,您可以将它们保存在 App 层的 `app/ambient/` 中。 + +其他包根本没有类型定义,您可能希望将它们声明为无类型或甚至为它们编写自己的类型定义。这些类型定义的好地方是 `shared/lib`,在像 `shared/lib/untyped-packages` 这样的文件夹中。在那里创建一个 `%LIBRARY_NAME%.d.ts` 文件并声明您需要的类型: + +```ts title="shared/lib/untyped-packages/use-react-screenshot.d.ts" +// 这个库没有类型定义,我们不想费心编写自己的。 +declare module "use-react-screenshot"; +``` + +## 类型的自动生成 + +从外部源生成类型是很常见的,例如,从 OpenAPI schema 生成后端类型。在这种情况下,为这些类型在您的代码库中创建一个专门的位置,如 `shared/api/openapi`。理想情况下,您还应该在该文件夹中包含一个 README,描述这些文件是什么、如何重新生成它们等。 + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-type-fest]: https://github.com/sindresorhus/type-fest +[ext-zod]: https://zod.dev +[ext-vite]: https://vitejs.dev +[ext-ts-reset]: https://www.totaltypescript.com/ts-reset diff --git a/src/content/docs/zh/docs/guides/migration/from-custom.mdx b/src/content/docs/zh/docs/guides/migration/from-custom.mdx new file mode 100644 index 0000000000..e67501f3d1 --- /dev/null +++ b/src/content/docs/zh/docs/guides/migration/from-custom.mdx @@ -0,0 +1,235 @@ +--- +title: 从自定义架构迁移 +sidebar: + order: 1 + label: 从自定义架构迁移 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +本指南描述了一种在从自定义自制架构迁移到 Feature-Sliced Design 时可能有用的方法。 + +这里是典型自定义架构的文件夹结构。我们将在本指南中将其作为示例使用。 +点击蓝色箭头打开文件夹。 + + +- src/ + - actions/ + - product/ + - order/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - routes/ + - products.jsx + - products.[id].jsx + - utils/ + - reducers/ + - selectors/ + - styles/ + - App.jsx + - index.jsx + + +## 在您开始之前 \{#before-you-start} + +在考虑切换到Feature-Sliced Design时,向团队提出的最重要问题是——_你真的需要它吗?_我们喜爱Feature-Sliced Design,但即使是我们也认识到一些项目没有它也完全可以。 + +以下是考虑进行切换的一些原因: + +1. 新团队成员抱怨很难达到高效水平 +2. 修改代码的一部分**经常**导致另一个不相关的部分出现问题 +3. 由于需要考虑的事情太多,添加新功能变得困难 + +**避免违背队友意愿切换到FSD**,即使你是负责人。 +首先,说服你的队友,让他们相信好处超过了迁移成本和学习新架构而不是既定架构的成本。 + +还要记住,任何类型的架构更改都不会立即被管理层观察到。在开始之前确保他们支持这种切换,并向他们解释为什么这可能对项目有益。 + + + +--- + +如果你决定开始迁移,那么你想要做的第一件事是为`📁 src`设置一个别名。稍后引用顶级文件夹时会很有帮助。在本指南的其余部分,我们将考虑`@`作为`./src`的别名。 + +## 步骤1. 按页面划分代码 \{#divide-code-by-pages} + +大多数自定义架构已经有按页面的划分,无论逻辑大小如何。如果你已经有`📁 pages`,可以跳过此步骤。 + +如果你只有`📁 routes`,创建`📁 pages`并尝试从`📁 routes`中移动尽可能多的组件代码。理想情况下,你会有一个小的路由和一个较大的页面。在移动代码时,为每个页面创建一个文件夹并添加一个索引文件: + + + +Route file: + +```js title="src/routes/products.[id].js" +export { ProductPage as default } from "@/pages/product" +``` + +Page index file: + +```js title="src/pages/product/index.js" +export { ProductPage } from "./ProductPage.jsx" +``` + +Page component file: + +```jsx title="src/pages/product/ProductPage.jsx" +export function ProductPage(props) { + return
; +} +``` + +## 步骤2. 将其他所有内容与页面分离 \{#separate-everything-else-from-pages} + +创建一个文件夹`📁 src/shared`,并将所有不从`📁 pages`或`📁 routes`导入的内容移动到那里。创建一个文件夹`📁 src/app`,并将所有导入页面或路由的内容移动到那里,包括路由本身。 + +记住Shared层没有切片,所以段之间相互导入是可以的。 + +You should end up with a file structure like this: + + +- src/ + - app/ + - routes/ + - products.jsx + - products.[id].jsx + - App.jsx + - index.js + - pages/ + - product/ + - index.js + - ui/ + - ProductPage.jsx + - catalog/ + - shared/ + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## 步骤3. 处理页面间的交叉导入 \{#tackle-cross-imports-between-pages} + +找到一个页面从另一个页面导入的所有实例,并执行以下两件事之一: + +1. 将导入的代码复制粘贴到依赖页面中以移除依赖关系 +2. 将代码移动到Shared中的适当段: + - 如果它是UI工具包的一部分,将其移动到`📁 shared/ui`; + - 如果它是配置常量,将其移动到`📁 shared/config`; + - 如果它是后端交互,将其移动到`📁 shared/api`。 + + + +## 步骤4. 拆解Shared层 \{#unpack-shared-layer} + +在这一步你可能在Shared层中有很多东西,你通常想要避免这种情况。原因是Shared层可能是代码库中任何其他层的依赖项,因此对该代码进行更改自动更容易产生意外后果。 + +找到所有只在一个页面上使用的对象,并将其移动到该页面的切片中。是的,_这也适用于actions、reducers和selectors_。将所有actions组合在一起没有好处,但将相关actions放置在接近其使用位置是有好处的。 + +You should end up with a file structure like this: + + +- src/ + - app/ + - ... + - pages/ + - product/ + - actions/ + - reducers/ + - selectors/ + - ui/ + - Component.jsx + - Container.jsx + - ProductPage.jsx + - index.js + - catalog/ + - shared/ only objects that are reused + - actions/ + - api/ + - components/ + - containers/ + - constants/ + - i18n/ + - modules/ + - helpers/ + - utils/ + - reducers/ + - selectors/ + - styles/ + + +## 步骤5. 按技术目的组织代码 \{#organize-by-technical-purpose} + +在FSD中,按技术目的划分是通过_段_来完成的。有几个常见的段: + +- `ui` — 与UI显示相关的一切:UI组件、日期格式化器、样式等。 +- `api` — 后端交互:请求函数、数据类型、映射器等。 +- `model` — 数据模型:模式、接口、存储和业务逻辑。 +- `lib` — 此切片上其他模块需要的库代码。 +- `config` — 配置文件和功能标志。 + +如果需要,你也可以创建自己的段。确保不要创建按代码是什么分组的段,如`components`、`actions`、`types`、`utils`。相反,按代码的用途分组。 + +重新组织你的页面以按段分离代码。你应该已经有一个`ui`段,现在是时候创建其他段了,如用于actions、reducers和selectors的`model`,或用于thunks和mutations的`api`。 + +还要重新组织Shared层以移除这些文件夹: +- `📁 components`、`📁 containers` — 其中大部分应该成为`📁 shared/ui`; +- `📁 helpers`、`📁 utils` — 如果还有一些重用的helpers,按功能将它们组合在一起,如日期或类型转换,并将这些组移动到`📁 shared/lib`; +- `📁 constants` — 再次,按功能分组并移动到`📁 shared/config`。 + +## 可选步骤 \{#optional-steps} + +### 步骤6. 从在多个页面使用的Redux切片形成实体/功能 \{#form-entities-features-from-redux} + +通常,这些重用的Redux切片将描述与业务相关的内容,例如产品或用户,因此这些可以移动到Entities层,每个文件夹一个实体。如果Redux切片与用户想要在你的应用中执行的操作相关,如评论,那么你可以将其移动到Features层。 + +实体和功能意味着彼此独立。如果你的业务域包含实体之间的固有连接,请参考[业务实体指南][business-entities-cross-relations]以获取如何组织这些连接的建议。 + +与这些切片相关的API函数可以保留在`📁 shared/api`中。 + +### 步骤7. 重构你的模块 \{#refactor-your-modules} + +`📁 modules`文件夹通常用于业务逻辑,因此它在本质上已经与FSD的Features层非常相似。一些模块也可能描述UI的大块,如应用头部。在这种情况下,你应该将它们迁移到Widgets层。 + +### 步骤8. 在`shared/ui`中形成干净的UI基础 \{#form-clean-ui-foundation} + +`📁 shared/ui`理想情况下应该包含一组没有编码任何业务逻辑的UI元素。它们也应该是高度可重用的。 + +重构曾经在`📁 components`和`📁 containers`中的UI组件以分离业务逻辑。将该业务逻辑移动到更高的层级。如果它没有在太多地方使用,你甚至可以考虑复制粘贴。 + +## 另请参阅 \{#see-also} + +- [(Talk in Russian) Ilya Klimov — Крысиные бега бесконечного рефакторинга: как не дать техническому долгу убить мотивацию и продукт](https://youtu.be/aOiJ3k2UvO4) + +[ext-steiger]: https://github.com/feature-sliced/steiger +[business-entities-cross-relations]: /docs/guides/examples/types#business-entities-and-their-cross-references diff --git a/src/content/docs/zh/docs/guides/migration/from-v1.mdx b/src/content/docs/zh/docs/guides/migration/from-v1.mdx new file mode 100644 index 0000000000..ebe39788cd --- /dev/null +++ b/src/content/docs/zh/docs/guides/migration/from-v1.mdx @@ -0,0 +1,173 @@ +--- +title: 从 v1 到 v2 的迁移 +sidebar: + order: 2 +--- + +import { Aside } from '@astrojs/starlight/components'; + +## 为什么是 v2? + +**feature-slices** 的原始概念于 2018 年[被宣布][ext-tg-spb]。 + +从那时起,该方法论发生了许多变化,但同时**[基本原则得到了保留][ext-v1]**: + +- 使用*标准化*的前端项目结构 +- 首先按照*业务逻辑*分割应用程序 +- 使用*隔离的 features* 来防止隐式副作用和循环依赖 +- 使用 *Public API* 并禁止深入模块的"内部" + +同时,在方法论的上一个版本中,仍然存在**薄弱环节**: + +- 有时会导致样板代码 +- 有时会导致代码库的过度复杂化和抽象之间不明显的规则 +- 有时会导致隐式的架构解决方案,这阻止了项目的提升和新人的入职 + +方法论的新版本([v2][ext-v2])旨在**消除这些缺点,同时保留该方法的现有优势**。 + +自 2018 年以来,[还开发了][ext-fdd-issues]另一种类似的方法论 - [**feature-driven**][ext-fdd],最初由 [Oleg Isonen][ext-kof] 宣布。 + +在合并两种方法后,我们**改进和完善了现有实践** - 朝着更大的灵活性、清晰度和应用效率的方向发展。 + +> 因此,这甚至影响了方法论的名称 - *"feature-slice**d**"* + +## 为什么将项目迁移到v2是有意义的? + +> `WIP:` 当前版本的方法论正在开发中,一些细节*可能会发生变化* + +#### 🔍 更透明和简单的架构 + +方法论(v2)提供了**更直观和更常见的抽象以及在开发者之间分离逻辑的方式。** + +所有这些对吸引新人、研究项目当前状态以及分配应用程序业务逻辑都有极其积极的影响。 + +#### 📦 更灵活和诚实的模块化 + +方法论(v2)允许**以更灵活的方式分配逻辑:** + +- 能够从头开始重构隔离的部分 +- 能够依赖相同的抽象,但没有不必要的依赖交织 +- 对新模块位置的更简单要求 *(层级 => 切片 => 段)* + +#### 🚀 更多规范、计划、社区 + +目前,`核心团队`正在积极开发方法论的最新(v2)版本 + +因此对于它: + +- 将有更多描述的案例/问题 +- 将有更多应用指南 +- 将有更多真实示例 +- 总的来说,将有更多文档用于新人入职和学习方法论概念 +- 工具包将在未来开发以符合架构概念和约定 + +> 当然,第一个版本也会有用户支持 - 但最新版本仍然是我们的优先级 +> +> 在未来,随着下一次重大更新,你仍然可以访问方法论的当前版本(v2),**对你的团队和项目没有风险** + +## Changelog + +### `BREAKING` 层级 + +现在方法论假设在顶层明确分配层级 + +- `/app` > `/processes` > **`/pages`** > **`/features`** > `/entities` > `/shared` +- *也就是说,现在不是所有东西都被视为功能/页面* +- 这种方法允许你[明确设置层级规则][ext-tg-v2-draft]: +- 模块所在的**层级越高**,它拥有的**上下文**就越多 + + *(换句话说 - 层级的每个模块 - 只能导入底层的模块,而不能导入更高层的)* + +- 模块所在的**层级越低**,对其进行更改的**危险性和责任**就越大 + + *(因为通常是底层被过度使用)* + +### `BREAKING` Shared + +基础设施抽象 `/ui`、`/lib`、`/api`,以前位于项目的src根目录中,现在由单独的目录 `/src/shared` 分离 + +- `shared/ui` - 仍然是应用程序的相同通用UI工具包(可选) + - *同时,没有人禁止像以前一样在这里使用`原子设计`* +- `shared/lib` - 用于实现逻辑的辅助库集合 + - *仍然 - 没有助手的转储* +- `shared/api` - 访问API的通用入口点 + - *也可以在每个功能/页面中本地注册 - 但不推荐* +- 和以前一样 - 在`shared`中不应该有对业务逻辑的显式绑定 + - *如有必要,你需要将这种关系提升到`entities`级别或更高* + +### `NEW` 实体、流程 + +在v2中**,添加了其他新的抽象**来消除逻辑复杂性和高耦合的问题。 + +- `/entities` - **业务实体**层,包含直接与业务模型相关的切片或仅在前端需要的合成实体 + - *示例:`user`、`i18n`、`order`、`blog`* +- `/processes` - **业务流程**层,贯穿应用程序 + - **该层是可选的**,通常建议在*逻辑增长并开始在多个页面中模糊*时使用 + - *示例:`payment`、`auth`、`quick-tour`* + +### `BREAKING` 抽象和命名 + +现在定义了具体的抽象和[明确的命名建议][refs-adaptability] + +[disc-process]: https://github.com/feature-sliced/documentation/discussions/20 +[disc-features]: https://github.com/feature-sliced/documentation/discussions/23 +[disc-entities]: https://github.com/feature-sliced/documentation/discussions/18#discussioncomment-422649 +[disc-shared]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453020 + +[disc-ui]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-453132 +[disc-model]: https://github.com/feature-sliced/documentation/discussions/31#discussioncomment-472645 +[disc-api]: https://github.com/feature-sliced/documentation/discussions/66 + +#### 层级 + +- `/app` — **应用程序初始化层** + - *以前的版本:`app`、`core`、`init`、`src/index`(这种情况也会发生)* +- `/processes` — [**业务流程层**][disc-process] + - *以前的版本:`processes`、`flows`、`workflows`* +- `/pages` — **应用程序页面层** + - *以前的版本:`pages`、`screens`、`views`、`layouts`、`components`、`containers`* +- `/features` — [**功能部分层**][disc-features] + - *以前的版本:`features`、`components`、`containers`* +- `/entities` — [**业务实体层**][disc-entities] + - *以前的版本:`entities`、`models`、`shared`* +- `/shared` — [**可重用基础设施代码层**][disc-shared] 🔥 + - *以前的版本:`shared`、`common`、`lib`* + +#### 段 + +- `/ui` — [**UI段**][disc-ui] 🔥 + - *以前的版本:`ui`、`components`、`view`* +- `/model` — [**业务逻辑段**][disc-model] 🔥 + - *以前的版本:`model`、`store`、`state`、`services`、`controller`* +- `/lib` — **辅助代码段** + - *以前的版本:`lib`、`libs`、`utils`、`helpers`* +- `/api` — [**API段**][disc-api] + - *以前的版本:`api`、`service`、`requests`、`queries`* +- `/config` — **应用程序配置段** + - *以前的版本:`config`、`env`、`get-env`* + +### `REFINED` 低耦合 + +现在由于新的层级,[遵循模块间低耦合原则][refs-low-coupling]变得更加容易。 + +*同时,仍然建议尽可能避免极难"解耦"模块的情况* + +## See also + +- [Notes from the report "React SPB Meetup #1"][ext-tg-spb] +- [React Berlin Talk - Oleg Isonen "Feature Driven Architecture"][ext-kof-fdd] +- [Comparison with v1 (community-chat)](https://t.me/feature_sliced/493) +- [New ideas v2 with explanations (atomicdesign-chat)][ext-tg-v2-draft] +- [Discussion of abstractions and naming for the new version of the methodology (v2)](https://github.com/feature-sliced/documentation/discussions/31) + +[refs-low-coupling]: /docs/reference/slices-segments#zero-coupling-high-cohesion +[refs-adaptability]: /docs/about/understanding/naming + +[ext-v1]: https://feature-sliced.github.io/featureslices.dev/v1.0.html +[ext-tg-spb]: https://t.me/feature_slices +[ext-fdd]: https://github.com/feature-sliced/documentation/tree/rc/feature-driven +[ext-fdd-issues]: https://github.com/kof/feature-driven-architecture/issues +[ext-v2]: https://github.com/feature-sliced/documentation +[ext-kof]: https://github.com/kof +[ext-kof-fdd]: https://www.youtube.com/watch?v=BWAeYuWFHhs +[ext-tg-v2-draft]: https://t.me/atomicdesign/18708 diff --git a/src/content/docs/zh/docs/guides/migration/from-v2-0.mdx b/src/content/docs/zh/docs/guides/migration/from-v2-0.mdx new file mode 100644 index 0000000000..6b2c8d31f8 --- /dev/null +++ b/src/content/docs/zh/docs/guides/migration/from-v2-0.mdx @@ -0,0 +1,45 @@ +--- +title: 从v2.0到v2.1的迁移 +sidebar: + order: 3 +--- + +v2.1的主要变化是分解界面的新思维模型——页面优先。 + +在v2.0中,FSD会建议识别界面中的实体和功能,甚至考虑实体表示和交互性的最小部分进行分解。然后你会从实体和功能构建小部件和页面。在这种分解模型中,大部分逻辑都在实体和功能中,页面只是组合层,本身没有太多意义。 + +在v2.1中,我们建议从页面开始,甚至可能就停在那里。大多数人已经知道如何将应用程序分离为单独的页面,页面也是在代码库中尝试定位组件时的常见起点。在这种新的分解模型中,你将大部分UI和逻辑保留在每个单独的页面中,在Shared中维护可重用的基础。如果需要在多个页面之间重用业务逻辑,你可以将其移动到下面的层级。 + +Feature-Sliced Design的另一个新增功能是使用`@x`标记法标准化实体之间的交叉导入。 + +## 如何迁移 \{#how-to-migrate} + +v2.1中没有破坏性更改,这意味着使用FSD v2.0编写的项目在FSD v2.1中也是有效的项目。但是,我们相信新的思维模型对团队更有益,特别是对新开发者的入职,所以我们建议对你的分解进行小的调整。 + +### 合并切片 + +一个简单的开始方式是在项目上运行我们的linter,[Steiger][steiger]。Steiger是基于新的思维模型构建的,最有用的规则将是: + +- [`insignificant-slice`][insignificant-slice] — 如果一个实体或功能只在一个页面中使用,此规则将建议将该实体或功能完全合并到页面中。 +- [`excessive-slicing`][excessive-slicing] — 如果一个层级有太多切片,这通常是分解过于细粒度的标志。此规则将建议合并或分组一些切片以帮助项目导航。 + +```bash +npx steiger src +``` + +这将帮助你识别哪些切片只使用一次,以便你可以重新考虑它们是否真的必要。在这种考虑中,请记住层级为其内部的所有切片形成某种全局命名空间。就像你不会用只使用一次的变量污染全局命名空间一样,你应该将层级命名空间中的位置视为有价值的,要谨慎使用。 + +### 标准化交叉导入 + +如果你的项目之前有交叉导入(我们不评判!),你现在可以利用Feature-Sliced Design中交叉导入的新标记法——`@x`标记法。它看起来像这样: + +```ts title="entities/B/some/file.ts" +import type { EntityA } from "entities/A/@x/B"; +``` + +更多详情,请查看参考中的[交叉导入的公共API][public-api-for-cross-imports]部分。 + +[insignificant-slice]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/insignificant-slice +[steiger]: https://github.com/feature-sliced/steiger +[excessive-slicing]: https://github.com/feature-sliced/steiger/tree/master/packages/steiger-plugin-fsd/src/excessive-slicing +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/zh/docs/guides/tech/with-electron.mdx b/src/content/docs/zh/docs/guides/tech/with-electron.mdx new file mode 100644 index 0000000000..2736946692 --- /dev/null +++ b/src/content/docs/zh/docs/guides/tech/with-electron.mdx @@ -0,0 +1,137 @@ +--- +title: 与 Electron 一起使用 +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +Electron 应用程序具有特殊的架构,由具有不同职责的多个进程组成。在这种情况下应用 FSD 需要将结构适应 Electron 的特性。 + + +- src + - app Common app layer + - main Main process + - index.ts Main process entry point + - preload Preload script and Context Bridge + - index.ts Preload entry point + - renderer Renderer process + - index.html Renderer process entry point + - main + - features + - user + - ipc + - get-user.ts + - send-user.ts + - entities + - shared + - renderer + - pages + - settings + - ipc + - get-user.ts + - save-user.ts + - ui + - user.tsx + - index.ts + - home + - ui + - home.tsx + - index.ts + - widgets + - features + - entities + - shared + - shared Common code between main and renderer + - ipc IPC description (event names, contracts) + + +## 公共 API 规则 +每个进程必须有自己的公共 API。例如,您不能将模块从 `main` 导入到 `renderer`。 +只有 `src/shared` 文件夹对两个进程都是公共的。 +描述进程交互的契约也是必要的。 + +## 额外更改标准结构 +建议使用新的 `ipc` 段,其中进程之间的交互发生。 +`pages` 和 `widgets` 层,基于其名称,不应出现在 `src/main` 中。您可以使用 `features`、`entities` 和 `shared`。 +`src/app` 层中的 `app` 层包含 `main` 和 `renderer` 的入口点,以及 IPC。 +不希望 `app` 层中的段有交集 + +## 交互示例 + +```typescript title="src/shared/ipc/channels.ts" +export const CHANNELS = { + GET_USER_DATA: 'GET_USER_DATA', + SAVE_USER: 'SAVE_USER', +} as const; + +export type TChannelKeys = keyof typeof CHANNELS; +``` + +```typescript title="src/shared/ipc/events.ts" +import { CHANNELS } from './channels'; + +export interface IEvents { + [CHANNELS.GET_USER_DATA]: { + args: void, + response?: { name: string; email: string; }; + }; + [CHANNELS.SAVE_USER]: { + args: { name: string; }; + response: void; + }; +} +``` + +```typescript title="src/shared/ipc/preload.ts" +import { CHANNELS } from './channels'; +import type { IEvents } from './events'; + +type TOptionalArgs = T extends void ? [] : [args: T]; + +export type TElectronAPI = { + [K in keyof typeof CHANNELS]: (...args: TOptionalArgs) => IEvents[typeof CHANNELS[K]]['response']; +}; +``` + +```typescript title="src/app/preload/index.ts" +import { contextBridge, ipcRenderer } from 'electron'; +import { CHANNELS, type TElectronAPI } from 'shared/ipc'; + +const API: TElectronAPI = { + [CHANNELS.GET_USER_DATA]: () => ipcRenderer.sendSync(CHANNELS.GET_USER_DATA), + [CHANNELS.SAVE_USER]: args => ipcRenderer.invoke(CHANNELS.SAVE_USER, args), +} as const; + +contextBridge.exposeInMainWorld('electron', API); +``` + +```typescript title="src/main/features/user/ipc/send-user.ts" +import { ipcMain } from 'electron'; +import { CHANNELS } from 'shared/ipc'; + +export const sendUser = () => { + ipcMain.on(CHANNELS.GET_USER_DATA, ev => { + ev.returnValue = { + name: 'John Doe', + email: 'john.doe@example.com', + }; + }); +}; +``` + +```typescript title="src/renderer/pages/user-settings/ipc/get-user.ts" +import { CHANNELS } from 'shared/ipc'; + +export const getUser = () => { + const user = window.electron[CHANNELS.GET_USER_DATA](); + + return user ?? { name: 'John Donte', email: 'john.donte@example.com' }; +}; +``` + +## 另请参阅 +- [Process Model Documentation](https://www.electronjs.org/docs/latest/tutorial/process-model) +- [Context Isolation Documentation](https://www.electronjs.org/docs/latest/tutorial/context-isolation) +- [Inter-Process Communication Documentation](https://www.electronjs.org/docs/latest/tutorial/ipc) +- [Example](https://github.com/feature-sliced/examples/tree/master/examples/electron) diff --git a/src/content/docs/zh/docs/guides/tech/with-nextjs.mdx b/src/content/docs/zh/docs/guides/tech/with-nextjs.mdx new file mode 100644 index 0000000000..e88d546183 --- /dev/null +++ b/src/content/docs/zh/docs/guides/tech/with-nextjs.mdx @@ -0,0 +1,196 @@ +--- +title: 与 Next.js 一起使用 +sidebar: + order: 1 +--- + +import { Tabs, TabItem, FileTree } from '@astrojs/starlight/components'; + +如果您解决了主要冲突——`app` 和 `pages` 文件夹,FSD 与 Next.js 的 App Router 版本和 Pages Router 版本都兼容。 + +## App Router \{#app-router} + +### FSD 和 Next.js 在 `app` 层中的冲突 \{#conflict-between-fsd-and-nextjs-in-the-app-layer} + +Next.js 建议使用 `app` 文件夹来定义应用程序路由。它期望 `app` 文件夹中的文件对应于路径名。这种路由机制**与 FSD 概念不一致**,因为无法维护扁平的 slice 结构。 + +解决方案是将 Next.js 的 `app` 文件夹移动到项目根目录,并将 FSD 页面从 `src`(FSD 层所在的位置)导入到 Next.js 的 `app` 文件夹中。 + +您还需要在项目根目录中添加一个 `pages` 文件夹,否则即使您使用 App Router,Next.js 也会尝试将 `src/pages` 用作 Pages Router,这会破坏构建。在这个根 `pages` 文件夹中放置一个 `README.md` 文件来描述为什么它是必要的也是一个好主意,即使它是空的。 + + +- app App folder (Next.js) + - api + - get-example + - route.ts + - example + - page.tsx +- pages Empty pages folder (Next.js) + - README.md +- src + - app + - api-routes API routes + - pages + - example + - index.ts + - ui + - example.tsx + - widgets/ + - features/ + - entities/ + - shared/ + + +在 Next.js `app` 中从 `src/pages` 重新导出页面的示例: + +```tsx title="app/example/page.tsx" +export { ExamplePage as default, metadata } from '@/pages/example'; +``` + +### 中间件 \{#middleware} + +如果您在项目中使用中间件,它必须位于项目根目录中,与 Next.js 的 `app` 和 `pages` 文件夹并列。 + +### 检测 \{#instrumentation} + +`instrumentation.js` 文件允许您监控应用程序的性能和行为。如果您使用它,它必须位于项目根目录中,类似于 `middleware.js`。 + +## Pages Router \{#pages-router} + +### FSD 和 Next.js 在 `pages` 层中的冲突 \{#conflict-between-fsd-and-nextjs-in-the-pages-layer} + +路由应该放在项目根目录的 `pages` 文件夹中,类似于 App Router 的 `app` 文件夹。`src` 内部层文件夹所在的结构保持不变。 + + +- pages Pages folder (Next.js) + - _app.tsx + - api + - example.ts API route re-export + - example + - index.tsx +- src + - app + - custom-app + - custom-app.tsx Custom App component + - api-routes + - get-example-data.ts API route + - pages + - example + - index.ts + - ui + - example.tsx + - widgets/ + - features/ + - entities/ + - shared/ + + +在 Next.js `pages` 中从 `src/pages` 重新导出页面的示例: + +```tsx title="pages/example/index.tsx" +export { Example as default } from '@/pages/example'; +``` + +### 自定义 `_app` 组件 \{#custom-_app-component} + +您可以将自定义 App 组件放在 `src/app/_app` 或 `src/app/custom-app` 中: + +```tsx title="src/app/custom-app/custom-app.tsx" +import type { AppProps } from 'next/app'; + +export const MyApp = ({ Component, pageProps }: AppProps) => { + return ( + <> +

My Custom App component

+ + + ); +}; +``` + +```tsx title="pages/_app.tsx" +export { App as default } from '@/app/custom-app'; +``` + +## 路由处理程序(API 路由) \{#route-handlers-api-routes} + +使用 `app` 层中的 `api-routes` segment 来处理路由处理程序。 + +在 FSD 结构中编写后端代码时要谨慎——FSD 主要用于前端,这意味着人们会期望找到前端代码。 +如果您需要很多端点,请考虑将它们分离到 monorepo 中的不同包中。 + + + + + +```tsx title="src/app/api-routes/get-example-data.ts" +import { getExamplesList } from '@/shared/db'; + +export const getExampleData = () => { + try { + const examplesList = getExamplesList(); + + return Response.json({ examplesList }); + } catch { + return Response.json(null, { + status: 500, + statusText: 'Ouch, something went wrong', + }); + } +}; +``` + +```tsx title="app/api/example/route.ts" +export { getExampleData as GET } from '@/app/api-routes'; +``` + + + + + +```tsx title="src/app/api-routes/get-example-data.ts" +import type { NextApiRequest, NextApiResponse } from 'next'; + +const config = { + api: { + bodyParser: { + sizeLimit: '1mb', + }, + }, + maxDuration: 5, +}; + +const handler = (req: NextApiRequest, res: NextApiResponse) => { + res.status(200).json({ message: 'Hello from FSD' }); +}; + +export const getExampleData = { config, handler } as const; +``` + +```tsx title="src/app/api-routes/index.ts" +export { getExampleData } from './get-example-data'; +``` + +```tsx title="app/api/example.ts" +import { getExampleData } from '@/app/api-routes'; + +export const config = getExampleData.config; +export default getExampleData.handler; +``` + + + + + +## Additional recommendations \{#additional-recommendations} + +- Use the `db` segment in the `shared` layer to describe database queries and their further use in higher layers. +- Caching and revalidating queries logic is better kept in the same place as the queries themselves. + +## See also \{#see-also} + +- [Next.js Project Structure](https://nextjs.org/docs/app/getting-started/project-structure) +- [Next.js Page Layouts](https://nextjs.org/docs/app/getting-started/layouts-and-pages) + +[project-knowledge]: /docs/about/understanding/knowledge-types +[ext-app-router-stackblitz]: https://stackblitz.com/edit/stackblitz-starters-aiez55?file=README.md diff --git a/src/content/docs/zh/docs/guides/tech/with-nuxtjs.mdx b/src/content/docs/zh/docs/guides/tech/with-nuxtjs.mdx new file mode 100644 index 0000000000..feabfb8cde --- /dev/null +++ b/src/content/docs/zh/docs/guides/tech/with-nuxtjs.mdx @@ -0,0 +1,180 @@ +--- +title: 与 NuxtJS 一起使用 +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +可以在 NuxtJS 项目中实现 FSD,但由于 NuxtJS 项目结构要求与 FSD 原则之间的差异,会产生冲突: + +- 最初,NuxtJS 提供的项目文件结构没有 `src` 文件夹,即在项目的根目录中。 +- 文件路由在 `pages` 文件夹中,而在 FSD 中,此文件夹保留用于扁平 slice 结构。 + + +## 为 `src` 目录添加别名 + +将 `alias` 对象添加到您的配置中: +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Not FSD related, enabled at project startup + alias: { + "@": '../src' + }, +}) +``` +## 选择如何配置路由器 + +在 NuxtJS 中,有两种自定义路由的方法 - 使用配置和使用文件结构。 +在基于文件的路由情况下,您将在 app/routes 目录内的文件夹中创建 index.vue 文件,在配置情况下,您将在 `router.options.ts` 文件中配置路由器。 + + +### 使用配置进行路由 + +在 `app` 层中,创建一个 `router.options.ts` 文件,并从中导出配置对象: +```ts title="app/router.options.ts" +import type { RouterConfig } from '@nuxt/schema'; + +export default { + routes: (_routes) => [], +}; + +``` + +要向项目添加 `Home` 页面,您需要执行以下步骤: +- 在 `pages` 层内添加页面 slice +- 将适当的路由添加到 `app/router.config.ts` 配置中 + + +要创建页面 slice,让我们使用 [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +在 ui segment 内创建一个 `home-page.vue` 文件,使用 Public API 访问它 + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +因此,文件结构将如下所示: + +- src + - app + - router.config.ts + - pages + - home + - ui + - home-page.vue + - index.ts + +最后,让我们向配置添加一个路由: + +```ts title="app/router.config.ts" +import type { RouterConfig } from '@nuxt/schema' + +export default { + routes: (_routes) => [ + { + name: 'home', + path: '/', + component: () => import('@/pages/home.vue').then(r => r.default || r) + } + ], +} +``` + +### 文件路由 + +首先,在项目根目录中创建一个`src`目录,并在此目录内创建app和pages层,以及在app层内创建一个routes文件夹。 +因此,你的文件结构应该如下所示: + + +- src + - app + - routes + - pages Pages folder, related to FSD + + +为了让 NuxtJS 使用 `app` 层内的 routes 文件夹进行文件路由,您需要按如下方式修改 `nuxt.config.ts`: +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Not FSD related, enabled at project startup + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes' + } +}) +``` + +现在,你可以在`app`内为页面创建路由,并将`pages`中的页面连接到它们。 + +例如,要向项目添加 `Home` 页面,您需要执行以下步骤: +- 在 `pages` 层内添加页面 slice +- 在 `app` 层内添加相应的路由 +- 将 slice 中的页面与路由连接 + +要创建页面 slice,让我们使用 [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +在 ui segment 内创建一个 `home-page.vue` 文件,使用 Public API 访问它 + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page'; +``` + +在 `app` 层内为此页面创建路由: + + +- src + - app + - routes + - index.vue + - pages + - home + - ui + - home-page.vue + - index.ts + + +在 `index.vue` 文件内添加您的页面组件: + +```html title="src/app/routes/index.vue" + + + +``` + +## `layouts` 怎么办? + +您可以将布局放在 `app` 层内,为此您需要按如下方式修改配置: + +```ts title="nuxt.config.ts" +export default defineNuxtConfig({ + devtools: { enabled: true }, // Not related to FSD, enabled at project startup + alias: { + "@": '../src' + }, + dir: { + pages: './src/app/routes', + layouts: './src/app/layouts' + } +}) +``` + + +## 另请参阅 + +- [NuxtJS 中更改目录配置的文档](https://nuxt.com/docs/api/nuxt-config#dir) +- [NuxtJS 中更改路由器配置的文档](https://nuxt.com/docs/guide/recipes/custom-routing#router-config) +- [NuxtJS 中更改别名的文档](https://nuxt.com/docs/api/nuxt-config#alias) diff --git a/src/content/docs/zh/docs/guides/tech/with-react-query.mdx b/src/content/docs/zh/docs/guides/tech/with-react-query.mdx new file mode 100644 index 0000000000..f82d1788ce --- /dev/null +++ b/src/content/docs/zh/docs/guides/tech/with-react-query.mdx @@ -0,0 +1,440 @@ +--- +title: 与 React Query 一起使用 +sidebar: + order: 10 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +## "键放在哪里"的问题 + +### 解决方案——按实体分解 + +如果项目已经有实体划分,并且每个请求对应单个实体, +最纯粹的划分将按实体进行。在这种情况下,我们建议使用以下结构: + +- src/ + - app/ + - ... + - pages/ + - ... + - entities/ + - \{entity\}/ + - ... + - api/ + - `{entity}.query` Query-factory where are the keys and functions + - `get-{entity}` Entity getter function + - `create-{entity}` Entity creation function + - `update-{entity}` Entity update function + - `delete-{entity}` Entity delete function + - ... + - features/ + - ... + - widgets/ + - ... + - shared/ + - ... + + +如果实体之间有连接(例如,Country 实体有一个 City 实体的列表字段), +则可以使用 [公共 API 跨导入][public-api-for-cross-imports] 或考虑以下替代方案。 + +### 替代方案——保持共享 + +在实体分离不合适的情况下,可以考虑以下结构: + + +- src/ + - ... + - shared/ + - api/ + - ... + - queries Query-factories + - document.ts + - background-jobs.ts + - ... + - index.ts + + +然后在 `@/shared/api/index.ts` 中: + +```ts title="@/shared/api/index.ts" +export { documentQueries } from "./queries/document"; +``` + +## "在哪里插入突变?"的问题 + +不建议将突变与查询混合。有两种选择: + +### 1. 在 `api` 段附近定义一个自定义钩子 + +```tsx title="@/features/update-post/api/use-update-title.ts" +export const useUpdateTitle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, newTitle }) => + apiClient + .patch(`/posts/${id}`, { title: newTitle }) + .then((data) => console.log(data)), + + onSuccess: (newPost) => { + queryClient.setQueryData(postsQueries.ids(id), newPost); + }, + }); +}; +``` + +### 2. 在其他地方(共享或实体)定义突变函数,并在组件中直接使用 `useMutation` + +```tsx +const { mutateAsync, isPending } = useMutation({ + mutationFn: postApi.createPost, +}); +``` + +```tsx title="@/pages/post-create/ui/post-create-page.tsx" +export const CreatePost = () => { + const { classes } = useStyles(); + const [title, setTitle] = useState(""); + + const { mutate, isPending } = useMutation({ + mutationFn: postApi.createPost, + }); + + const handleChange = (e: ChangeEvent) => + setTitle(e.target.value); + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutate({ title, userId: DEFAULT_USER_ID }); + }; + + return ( +
+ + + Create + + + ); +}; +``` + +## 请求组织 + +### 查询工厂 + +查询工厂是一个对象,其中键值是返回查询键列表的函数。以下是如何使用它: + +```ts +const keyFactory = { + all: () => ["entity"], + lists: () => [...postQueries.all(), "list"], +}; +``` + + + +### 1. 创建查询工厂 + +```tsx title="@/entities/post/api/post.queries.ts" +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; +import { getDetailPost } from "./get-detail-post"; +import { PostDetailQuery } from "./query/post.query"; + +export const postQueries = { + all: () => ["posts"], + + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), + + details: () => [...postQueries.all(), "detail"], + detail: (query?: PostDetailQuery) => + queryOptions({ + queryKey: [...postQueries.details(), query?.id], + queryFn: () => getDetailPost({ id: query?.id }), + staleTime: 5000, + }), +}; +``` + +### 2. 在应用程序代码中使用查询工厂 +```tsx +import { useParams } from "react-router-dom"; +import { postApi } from "@/entities/post"; +import { useQuery } from "@tanstack/react-query"; + +type Params = { + postId: string; +}; + +export const PostPage = () => { + const { postId } = useParams(); + const id = parseInt(postId || ""); + const { + data: post, + error, + isLoading, + isError, + } = useQuery(postApi.postQueries.detail({ id })); + + if (isLoading) { + return
Loading...
; + } + + if (isError || !post) { + return <>{error?.message}; + } + + return ( +
+

Post id: {post.id}

+
+

{post.title}

+
+

{post.body}

+
+
+
Owner: {post.userId}
+
+ ); +}; +``` + +### 使用查询工厂的好处 +- **请求结构化:** 工厂允许您在一个地方组织所有 API 请求,使代码更易于阅读和维护。 +- **方便访问查询和键:** 工厂提供方便的方法来访问不同类型的查询及其键。 +- **查询刷新能力:** 工厂允许轻松刷新,无需在应用程序的不同部分更改查询键。 + +## 分页 + +在本节中,我们将查看 `getPosts` 函数的示例,该函数通过分页 API 请求检索帖子实体。 + +### 1. 创建 `getPosts` 函数 +`getPosts` 函数位于 `get-posts.ts` 文件中,位于 `api` 段 + +```tsx title="@/pages/post-feed/api/get-posts.ts" +import { apiClient } from "@/shared/api/base"; + +import { PostWithPaginationDto } from "./dto/post-with-pagination.dto"; +import { PostQuery } from "./query/post.query"; +import { mapPost } from "./mapper/map-post"; +import { PostWithPagination } from "../model/post-with-pagination"; + +const calculatePostPage = (totalCount: number, limit: number) => + Math.floor(totalCount / limit); + +export const getPosts = async ( + page: number, + limit: number, +): Promise => { + const skip = page * limit; + const query: PostQuery = { skip, limit }; + const result = await apiClient.get("/posts", query); + + return { + posts: result.posts.map((post) => mapPost(post)), + limit: result.limit, + skip: result.skip, + total: result.total, + totalPages: calculatePostPage(result.total, limit), + }; +}; +``` + +### 2. 分页查询工厂 +`postQueries` 查询工厂定义了各种查询选项,用于处理帖子, +包括请求特定页面和限制的帖子列表。 + +```tsx +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { getPosts } from "./get-posts"; + +export const postQueries = { + all: () => ["posts"], + lists: () => [...postQueries.all(), "list"], + list: (page: number, limit: number) => + queryOptions({ + queryKey: [...postQueries.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: keepPreviousData, + }), +}; +``` + + +### 3. 在应用程序代码中使用 + +```tsx title="@/pages/home/ui/index.tsx" +export const HomePage = () => { + const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN; + const [page, setPage] = usePageParam(DEFAULT_PAGE); + const { data, isFetching, isLoading } = useQuery( + postApi.postQueries.list(page, itemsOnScreen), + ); + return ( + <> + setPage(page)} + page={page} + count={data?.totalPages} + variant="outlined" + color="primary" + /> + + + ); +}; +``` + + +## `QueryProvider` 用于管理查询 +在本指南中,我们将查看如何组织 `QueryProvider`。 + +### 1. 创建 `QueryProvider` +文件 `query-provider.tsx` 位于路径 `@/app/providers/query-provider.tsx`。 + +```tsx title="@/app/providers/query-provider.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; + client: QueryClient; +}; + +export const QueryProvider = ({ client, children }: Props) => { + return ( + + {children} + + + ); +}; +``` + +### 2. 创建 `QueryClient` +`QueryClient` 是一个用于管理 API 请求的实例。 +文件 `query-client.ts` 位于 `@/shared/api/query-client.ts`。 +`QueryClient` 使用某些设置进行查询缓存。 + +```tsx title="@/shared/api/query-client.ts" +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }, + }, +}); +``` + +## 代码生成 + +有一些工具可以为您生成 API 代码,但它们比手动方法描述的更不灵活。 +如果您的 Swagger 文件结构良好, +并且您使用其中之一,生成 `@/shared/api` 目录中的所有代码可能是有意义的。 + + +## 额外的组织建议 +### API 客户端 + +在共享层使用自定义 API 客户端类, +您可以标准化配置并处理项目中的 API。 +这使您可以管理日志, +从一处管理头和数据交换格式(如 JSON 或 XML)。 +这种方法使项目更容易维护和开发,因为它简化了更改和更新与 API 的交互。 + +```tsx title="@/shared/api/api-client.ts" +import { API_URL } from "@/shared/config"; + +export class ApiClient { + private baseUrl: string; + + constructor(url: string) { + this.baseUrl = url; + } + + async handleResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + try { + return await response.json(); + } catch (error) { + throw new Error("Error parsing JSON response"); + } + } + + public async get( + endpoint: string, + queryParams?: Record, + ): Promise { + const url = new URL(endpoint, this.baseUrl); + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.append(key, value.toString()); + }); + } + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + return this.handleResponse(response); + } + + public async post>( + endpoint: string, + body: TData, + ): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return this.handleResponse(response); + } +} + +export const apiClient = new ApiClient(API_URL); +``` + +## 另请参阅 \{#see-also} + +- [(GitHub) Sample Project](https://github.com/ruslan4432013/fsd-react-query-example) +- [(CodeSandbox) Sample Project](https://codesandbox.io/p/github/ruslan4432013/fsd-react-query-example/main) +- [About the query factory](https://tkdodo.eu/blog/the-query-options-api) + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports diff --git a/src/content/docs/zh/docs/guides/tech/with-sveltekit.mdx b/src/content/docs/zh/docs/guides/tech/with-sveltekit.mdx new file mode 100644 index 0000000000..807b78d093 --- /dev/null +++ b/src/content/docs/zh/docs/guides/tech/with-sveltekit.mdx @@ -0,0 +1,100 @@ +--- +title: SvelteKit 一起使用 +sidebar: + order: 10 +--- + +import { FileTree } from '@astrojs/starlight/components'; + +可以在 SvelteKit 项目中实现 FSD,但由于 SvelteKit 项目的结构要求与 FSD 原则之间的差异,会产生冲突: + +- 最初,SvelteKit 在 `src/routes` 文件夹内提供文件结构,而在 FSD 中,路由必须是 `app` 层的一部分。 +- SvelteKit 建议将与路由无关的所有内容放在 `src/lib` 文件夹中。 + + +## 让我们设置配置 + +```ts title="svelte.config.ts" +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config}*/ +const config = { + preprocess: [vitePreprocess()], + kit: { + adapter: adapter(), + files: { + routes: 'src/app/routes', // move routing inside the app layer + lib: 'src', + appTemplate: 'src/app/index.html', // Move the application entry point inside the app layer + assets: 'public' + }, + alias: { + '@/*': 'src/*' // Create an alias for the src directory + } + } +}; +export default config; +``` + +## 将文件路由移动到 `src/app`。 + +让我们创建一个 app 层,将应用程序的入口点 `index.html` 移动到其中,并创建一个 routes 文件夹。 +因此,您的文件结构应该如下所示: + + +- src + - app + - index.html + - routes + - pages FSD Pages folder + + +现在,您可以在 `app` 内为页面创建路由,并将 `pages` 中的页面连接到它们。 + +例如,要向项目添加主页,您需要执行以下步骤: +- 在 `pages` 层内添加页面 slice +- 从 `app` 层向 `routes` 文件夹添加相应的路由 +- 将 slice 中的页面与路由对齐 + +要创建页面 slice,让我们使用 [CLI](https://github.com/feature-sliced/cli): + +```shell +fsd pages home +``` + +在 ui segment 内创建 ``home-page.svelte`` 文件,使用公共 API 访问它 + +```ts title="src/pages/home/index.ts" +export { default as HomePage } from './ui/home-page.svelte'; +``` + +在 `app` 层内为此页面创建路由: + + +- src + - app + - routes + - +page.svelte + - index.html + - pages + - home + - ui + - home-page.svelte + - index.ts + + +在 `+page.svelte` 文件中添加您的页面组件: + +```html title="src/app/routes/+page.svelte" + + + + +``` + +## 另请参阅 + +- [SvelteKit 中更改目录配置的文档](https://kit.svelte.dev/docs/configuration#files) diff --git a/src/content/docs/zh/docs/llms.mdx b/src/content/docs/zh/docs/llms.mdx new file mode 100644 index 0000000000..702989f96b --- /dev/null +++ b/src/content/docs/zh/docs/llms.mdx @@ -0,0 +1,18 @@ +--- +title: 大语言模型文档 +--- + +本页面为大语言模型(LLM)爬虫提供链接和指导。 + +- 规范:https://llmstxt.org/ + +### 文件 + +- llms.txt +- llms-small.txt +- llms-full.txt + +### 说明 + +- 文件从站点根目录提供服务,与当前页面路径无关。 +- 在具有非根基础 URL(例如 `/documentation/`)的部署中,上述链接会自动添加前缀。 diff --git a/src/content/docs/zh/docs/reference/layers.mdx b/src/content/docs/zh/docs/reference/layers.mdx new file mode 100644 index 0000000000..ae3ebe4d72 --- /dev/null +++ b/src/content/docs/zh/docs/reference/layers.mdx @@ -0,0 +1,153 @@ +--- +title: 层 +sidebar: + order: 1 +--- + +import { Aside } from '@astrojs/starlight/components'; + +层是 Feature-Sliced Design 中组织层次结构的第一级。它们的目的是根据代码需要的责任程度以及它依赖应用程序中其他模块的程度来分离代码。每一层都承载着特殊的语义意义,帮助您确定应该为您的代码分配多少责任。 + +总共有 **7 个 layers**,按从最高责任和 依赖到最低排列: + +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. +A file system tree, with a single root folder called src and then seven subfolders: app, processes, pages, widgets, features, entities, shared. The processes folder is slightly faded out. + +1. App +2. Processes (deprecated) +3. Pages +4. Widgets +5. Features +6. Entities +7. Shared + +您不必在项目中使用每一层 — 只有当您认为它们为您的项目带来价值时才添加它们。通常,大多数前端项目至少会有 Shared、Pages 和 App 层。 + +在实践中,层是具有小写名称的文件夹(例如,`📁 shared`、`📁 pages`、`📁 app`)。_不建议_添加新层,因为它们的语义是标准化的。 + +## 层上的导入规则 + +层由 _slices_ 组成 — 高度内聚的模块组。slices 之间的依赖关系由**层上的导入规则**调节: + +> _slice 中的模块(文件)只能在其他 slices 位于严格较低的层时导入它们。_ + +例如,文件夹 `📁 ~/features/aaa` 是一个名为"aaa"的 slice。其中的文件 `~/features/aaa/api/request.ts` 不能从 `📁 ~/features/bbb` 中的任何文件导入代码,但可以从 `📁 ~/entities` 和 `📁 ~/shared` 导入代码,以及从 `📁 ~/features/aaa` 导入任何同级代码,例如 `~/features/aaa/lib/cache.ts`。 + +App 和 Shared 层是此规则的**例外** — 它们既是层又是 slice。Slices 按业务域划分代码,这两层是例外,因为 Shared 没有业务域,而 App 结合了所有业务域。 + +在实践中,这意味着 App 和 Shared 层由 segments 组成,segments 可以自由地相互导入。 + +## 层定义 + +本节描述每一层的语义含义,以便直观地了解什么样的代码属于那里。 + +### Shared + +这一层为应用程序的其余部分奠定了基础。这是与外部世界建立连接的地方,例如后端、第三方库、环境。这也是定义您自己的高度封装库的地方。 + +这一层,像 App 层一样,_不包含 slices_。Slices 旨在将层划分为业务域,但业务域在 Shared 中不存在。这意味着 Shared 中的所有文件都可以相互引用和导入。 + +Here are the segments that you can typically find in this layer: + +- `📁 api` — the API client and potentially also functions to make requests to specific backend endpoints. +- `📁 ui` — the application's UI kit. + Components on this layer should not contain business logic, but it's okay for them to be business-themed. For example, you can put the company logo and page layout here. Components with UI logic are also allowed (for example, autocomplete or a search bar). +- `📁 lib` — a collection of internal libraries. + This folder should not be treated as helpers or utilities ([read here why these folders often turn into a dump][ext-sova-utility-dump]). Instead, every library in this folder should have one area of focus, for example, dates, colors, text manipulation, etc. That area of focus should be documented in a README file. The developers in your team should know what can and cannot be added to these libraries. +- `📁 config` — environment variables, global feature flags and other global configuration for your app. +- `📁 routes` — route constants or patterns for matching routes. +- `📁 i18n` — setup code for translations, global translation strings. + +你可以自由添加更多段,但要确保这些段的名称描述内容的目的,而不是其本质。例如,`components`、`hooks`和`types`是不好的段名称,因为它们在你寻找代码时没有太大帮助。 + +### Entities + +这一层的切片代表项目正在处理的现实世界概念。通常,它们是业务用来描述产品的术语。例如,社交网络可能会处理用户(User)、帖子(Post)和群组(Group)等业务实体。 + +实体切片可能包含数据存储(`📁 model`)、数据验证模式(`📁 model`)、与实体相关的API请求函数(`📁 api`),以及该实体在界面中的视觉表示(`📁 ui`)。视觉表示不必产生完整的UI块 — 它主要是为了在应用程序的多个页面中重用相同的外观,不同的业务逻辑可以通过props或slots附加到它上面。 + +#### 实体关系 + +FSD中的实体是切片,默认情况下,切片不能相互了解。然而,在现实生活中,实体经常相互交互,有时一个实体拥有或包含其他实体。因此,这些交互的业务逻辑最好保存在更高的层级中,如功能(Features)或页面(Pages)。 + +当一个实体的数据对象包含其他数据对象时,通常最好明确实体之间的连接,并通过使用`@x`标记法创建交叉引用API来绕过切片隔离。原因是连接的实体需要一起重构,所以最好让连接不可能被忽略。 + +For example: + +```ts title="entities/artist/model/artist.ts" +import type { Song } from "entities/song/@x/artist"; + +export interface Artist { + name: string; + songs: Array; +} +``` + +```ts title="entities/song/@x/artist.ts" +export type { Song } from "../model/song.ts"; +``` + +在[交叉导入的公共API][public-api-for-cross-imports]部分了解更多关于`@x`标记法的信息。 + +### Features + +这一层用于应用程序中的主要交互,即用户关心要做的事情。这些交互通常涉及业务实体,因为这就是应用程序的核心内容。 + +有效使用功能层的一个关键原则是:**不是所有东西都需要成为功能**。某个东西需要成为功能的一个好指标是它在多个页面上被重用。 + +例如,如果应用程序有多个编辑器,并且所有编辑器都有评论功能,那么评论就是一个可重用的功能。记住,切片是快速查找代码的机制,如果功能太多,重要的功能就会被淹没。 + +理想情况下,当你进入一个新项目时,你会通过查看页面和功能来发现其功能性。在决定什么应该成为功能时,要为项目新人的体验进行优化,让他们能够快速发现重要的大型代码区域。 + +功能切片可能包含执行交互的UI(如表单)(`📁 ui`)、执行操作所需的API调用(`📁 api`)、验证和内部状态(`📁 model`)、功能标志(`📁 config`)。 + +### Widgets + +小部件层用于大型自给自足的UI块。小部件在跨多个页面重用时最有用,或者当它们所属的页面有多个大型独立块,而这是其中之一时。 + +如果一个UI块构成了页面上大部分有趣的内容,并且从不被重用,它**不应该是小部件**,而应该直接放在该页面内。 + + + +### Pages + +页面是构成网站和应用程序的内容(也称为屏幕或活动)。一个页面通常对应一个切片,但是,如果有几个非常相似的页面,它们可以组合成一个切片,例如注册和登录表单。 + +只要你的团队仍然觉得容易导航,你可以在页面切片中放置任意数量的代码。如果页面上的UI块不被重用,将其保留在页面切片内是完全可以的。 + +在页面切片中,你通常可以找到页面的UI以及加载状态和错误边界(`📁 ui`)和数据获取和变更请求(`📁 api`)。页面拥有专用数据模型并不常见,少量状态可以保存在组件本身中。 + +### Processes + + + +流程是多页面交互的逃生舱。 + +这一层故意保持未定义。大多数应用程序不应该使用这一层,应该将路由级和服务器级逻辑保留在App层。只有当App层变得足够大以至于无法维护并需要卸载时,才考虑使用这一层。 + +### App + +各种应用程序范围的事务,包括技术意义上的(例如,上下文提供者)和业务意义上的(例如,分析)。 + +这一层通常不包含切片,与Shared层一样,而是直接包含段。 + +以下是你通常可以在这一层找到的段: + +- `📁 routes` — 路由器配置 +- `📁 store` — 全局存储配置 +- `📁 styles` — 全局样式 +- `📁 entrypoint` — 应用程序代码的入口点,特定于框架 + +[public-api-for-cross-imports]: /docs/reference/public-api#public-api-for-cross-imports +[ext-remix]: https://remix.run +[ext-sova-utility-dump]: https://dev.to/sergeysova/why-utils-helpers-is-a-dump-45fo diff --git a/src/content/docs/zh/docs/reference/public-api.mdx b/src/content/docs/zh/docs/reference/public-api.mdx new file mode 100644 index 0000000000..841f081a9c --- /dev/null +++ b/src/content/docs/zh/docs/reference/public-api.mdx @@ -0,0 +1,157 @@ +--- +title: Public API +sidebar: + order: 3 +--- + +import { FileTree, Aside } from '@astrojs/starlight/components'; + +Public API 是一组模块(如 slice)与使用它的代码之间的_契约_。它也充当网关,只允许访问某些对象,并且只能通过该 public API 访问。 + +在实践中,它通常作为具有重新导出的 index 文件实现: + +```js title="pages/auth/index.js" +export { LoginPage } from "./ui/LoginPage"; +export { RegisterPage } from "./ui/RegisterPage"; +``` + +## 什么构成了好的 public API? + +好的 public API 使得使用和集成到其他代码中的 slice 方便可靠。这可以通过设定这三个目标来实现: + +1. 应用程序的其余部分必须受到保护,免受 slice 结构变化(如重构)的影响 +1. slice 行为的重大变化(破坏了之前的期望)应该导致 public API 的变化 +1. 只应该暴露 slice 的必要部分 + +最后一个目标有一些重要的实际含义。创建所有内容的通配符重新导出可能很诱人,特别是在 slice 的早期开发中,因为您从文件中导出的任何新对象也会自动从 slice 导出: + +```js title="Bad practice, features/comments/index.js" +// ❌ BAD CODE BELOW, DON'T DO THIS +export * from "./ui/Comment"; // 👎 don't try this at home +export * from "./model/comments"; // 💩 this is bad practice +``` + +这会损害 slice 的可发现性,因为您无法轻易地说出这个 slice 的接口是什么。不知道接口意味着您必须深入挖掘 slice 的代码才能理解如何集成它。另一个问题是您可能意外地暴露模块内部,如果有人开始依赖它们,这将使重构变得困难。 + +## 用于交叉导入的 Public API \{#public-api-for-cross-imports} + +交叉导入是指同一 layer 上的一个 slice 从另一个 slice 导入的情况。通常这被 [layers 上的导入规则][import-rule-on-layers] 禁止,但经常有合理的交叉导入理由。例如,业务 entities 在现实世界中经常相互引用,最好在代码中反映这些关系而不是绕过它们。 + +为此,有一种特殊的 public API,也称为 `@x` 记号法。如果您有 entities A 和 B,并且 entity B 需要从 entity A 导入,那么 entity A 可以为 entity B 声明一个单独的 public API。 + + +- entities + - A + - @x + - B.ts 仅用于 `entities/B/` 内部代码的特殊 public API + - index.ts 常规 public API + + +然后 `entities/B/` 内部的代码可以从 `entities/A/@x/B` 导入: + +```ts +import type { EntityA } from "entities/A/@x/B"; +``` + +记号法 `A/@x/B` 旨在读作 "A crossed with B"。 + + + +## index 文件的问题 + +像 `index.js` 这样的 index 文件(也称为 barrel 文件)是定义 public API 的最常见方式。它们容易制作,但众所周知会在某些打包器和框架中引起问题。 + +### 循环导入 + +循环导入是指两个或多个文件在一个循环中相互导入。 + +
+ Three files importing each other in a circle + Three files importing each other in a circle +
+ Pictured above: three files, `fileA.js`, `fileB.js`, and `fileC.js`, importing each other in a circle. +
+
+ +这些情况对于打包器来说通常难以处理,在某些情况下,它们甚至可能导致难以调试的运行时错误。 + +循环导入可以在没有 index 文件的情况下发生,但拥有 index 文件提供了意外创建循环导入的明显机会。当您在 slice 的 public API 中有两个暴露的对象时,这经常发生,例如 `HomePage` 和 `loadUserStatistics`,并且 `HomePage` 需要访问 `loadUserStatistics`,但它像这样做: + +```jsx title="pages/home/ui/HomePage.jsx" +import { loadUserStatistics } from "../"; // importing from pages/home/index.js + +export function HomePage() { /* … */ } +``` + +```js title="pages/home/index.js" +export { HomePage } from "./ui/HomePage"; +export { loadUserStatistics } from "./api/loadUserStatistics"; +``` + +这种情况创建了循环导入,因为 `index.js` 导入 `ui/HomePage.jsx`,但 `ui/HomePage.jsx` 导入 `index.js`。 + +为了防止这个问题,考虑这两个原则。如果您有两个文件,其中一个从另一个导入: +- 当它们在同一个 slice 中时,始终使用_相对_导入并编写完整的导入路径 +- 当它们在不同的 slices 中时,始终使用_绝对_导入,例如使用别名 + +### Shared 中的大型包和损坏的 tree-shaking \{#large-bundles} + +当您有一个重新导出所有内容的 index 文件时,某些打包器可能在 tree-shaking(移除未导入的代码)方面遇到困难。 + +通常这对于 public APIs 来说不是问题,因为模块的内容通常关系非常密切,所以您很少需要导入一个东西并 tree-shake 掉另一个。然而,当 FSD 中的正常 public API 规则可能导致问题时,有两个非常常见的情况 — `shared/ui` 和 `shared/lib`。 + +这两个文件夹都是不相关事物的集合,通常不是在一个地方都需要的。例如,`shared/ui` 可能为 UI 库中的每个组件都有模块: + + +- shared/ + - ui/ + - button/ + - text-field/ + - carousel/ + - accordion/ + + +当其中一个模块有重度依赖时,这个问题会变得更加严重,比如语法突出显示器或拖放库。您不希望将这些引入到使用 `shared/ui` 中某些内容的每个页面中,例如按钮。 + +如果您的包由于 `shared/ui` 或 `shared/lib` 中的单个 public API 而不必要地增长,建议改为为每个组件或库单独有一个 index 文件: + + +- shared/ + - ui/ + - button/ + - index.js + - text-field/ + - index.js + + +然后这些组件的使用者可以像这样直接导入它们: + +```js title="pages/sign-in/ui/SignInPage.jsx" +import { Button } from '@/shared/ui/button'; +import { TextField } from '@/shared/ui/text-field'; +``` + +### 对绝过 public API 没有真正的保护 + +当您为 slice 创建 index 文件时,您实际上并没有禁止任何人不使用它而直接导入。这对于自动导入来说尤其是一个问题,因为有几个位置可以导入对象,所以 IDE 必须为您做决定。有时它可能选择直接导入,破坏 slices 上的 public API 规则。 + +为了自动捕获这些问题,我们建议使用 [Steiger][ext-steiger],一个具有 Feature-Sliced Design 规则集的架构 linter。 + +### 大型项目中打包器的较差性能 + +在项目中具有大量 index 文件可能会减慢开发服务器,正如 TkDodo 在[他的文章“请停止使用 Barrel 文件”][ext-please-stop-using-barrel-files]中所指出的。 + +您可以做几件事来解决这个问题: +1. 与[“Shared 中的大型包和损坏的 tree-shaking”问题](#large-bundles)相同的建议 — 在 `shared/ui` 和 `shared/lib` 中为每个组件/库单独有 index 文件,而不是一个大的 +2. 避免在有 slices 的 layers 上的 segments 中有 index 文件。 + 例如,如果您有一个用于 feature “comments” 的 index,`📄 features/comments/index.js`,则没有理由为该 feature 的 `ui` segment 有另一个 index,`📄 features/comments/ui/index.js`。 +3. 如果您有一个非常大的项目,很可能您的应用程序可以分割成几个大块。 + 例如,Google Docs 在文档编辑器和文件浏览器方面有非常不同的责任。您可以创建一个 monorepo 设置,其中每个包都是一个单独的 FSD 根,具有自己的 layers 集。某些包可能只有 Shared 和 Entities layers,其他包可能只有 Pages 和 App,还有一些包可能包含它们自己的小 Shared,但仍然使用另一个包中的大 Shared。 + +[import-rule-on-layers]: /docs/reference/layers#import-rule-on-layers +[ext-steiger]: https://github.com/feature-sliced/steiger +[ext-please-stop-using-barrel-files]: https://tkdodo.eu/blog/please-stop-using-barrel-files diff --git a/src/content/docs/zh/docs/reference/slices-segments.mdx b/src/content/docs/zh/docs/reference/slices-segments.mdx new file mode 100644 index 0000000000..168f6184ae --- /dev/null +++ b/src/content/docs/zh/docs/reference/slices-segments.mdx @@ -0,0 +1,71 @@ +--- +title: Slices 和 segments +sidebar: + order: 2 +--- + +# Slices 和 segments + +## Slices + +Slices 是 Feature-Sliced Design 组织层次结构中的第二级。它们的主要目的是按其对产品、业务或应用程序的意义对代码进行分组。 + +Slices 的名称没有标准化,因为它们直接由您应用程序的业务领域决定。例如,照片库可能有 slices `photo`、`effects`、`gallery-page`。社交网络将需要不同的 slices,例如 `post`、`comments`、`news-feed`。 + +Layers Shared 和 App 不包含 slices。这是因为 Shared 应该不包含任何业务逻辑,因此对产品没有意义,而 App 应该只包含涉及整个应用程序的代码,所以不需要分割。 + +### 零耦合和高聚合 \{#zero-coupling-high-cohesion} + +Slices 旨在成为独立且高度聚合的代码文件组。下面的图形可能有助于可视化_聚合性_和_耦合性_这些复杂的概念: + +
+ + +
+ Image inspired by https://enterprisecraftsmanship.com/posts/cohesion-coupling-difference/ +
+
+ +理想的 slice 独立于其 layer 上的其他 slices(零耦合)并包含与其主要目标相关的大部分代码(高聚合)。 + +切片的独立性由[层级导入规则][layers--import-rule]强制执行: + +> _切片中的模块(文件)只能在其他切片位于严格较低的层级时导入它们。_ + +### 切片的公共API规则 + +在切片内部,代码可以按你想要的任何方式组织。只要切片为其他切片提供良好的公共API来使用它,这就不会造成任何问题。这通过**切片的公共API规则**来强制执行: + +> _每个切片(以及没有切片的层级上的段)都必须包含公共API定义。_ +> +> _此切片/段之外的模块只能引用公共API,而不能引用切片/段的内部文件结构。_ + +在[公共API参考][ref-public-api]中阅读更多关于公共API的基本原理和创建最佳实践的信息。 + +### 切片组 + +密切相关的切片可以在文件夹中进行结构化分组,但它们应该遵循与其他切片相同的隔离规则 — 该文件夹中应该**没有代码共享**。 + +![Features "compose", "like" and "delete" grouped in a folder "post". In that folder there is also a file "some-shared-code.ts" that is crossed out to imply that it's not allowed.](/img/graphic-nested-slices.svg) + +## Segments + +段是组织层次结构中的第三级也是最后一级,其目的是按技术性质对代码进行分组。 + +有几个标准化的段名称: + +- `ui` — 与UI显示相关的一切:UI组件、日期格式化器、样式等。 +- `api` — 后端交互:请求函数、数据类型、映射器等。 +- `model` — 数据模型:模式、接口、存储和业务逻辑。 +- `lib` — 此切片上其他模块需要的库代码。 +- `config` — 配置文件和功能标志。 + +查看[层级页面][layers--layer-definitions]了解这些段在不同层级上可能用于什么的示例。 + +你也可以创建自定义段。自定义段最常见的地方是App层和Shared层,在这些层中切片没有意义。 + +确保这些段的名称描述内容的目的,而不是其本质。例如,`components`、`hooks`和`types`是不好的段名称,因为它们在你寻找代码时没有太大帮助。 + +[layers--layer-definitions]: /docs/reference/layers#layer-definitions +[layers--import-rule]: /docs/reference/layers#import-rule-on-layers +[ref-public-api]: /docs/reference/public-api diff --git a/src/styles/custom.css b/src/styles/custom.css new file mode 100644 index 0000000000..5a7d8de612 --- /dev/null +++ b/src/styles/custom.css @@ -0,0 +1,4 @@ +:root[data-theme="dark"] [src$="\#light-mode-only"], +:root[data-theme="light"] [src$="\#dark-mode-only"] { + display: none; +} diff --git a/tsconfig.astro.json b/tsconfig.astro.json new file mode 100644 index 0000000000..a55df4abe0 --- /dev/null +++ b/tsconfig.astro.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "src/content.config.ts"], + "exclude": ["dist", "build"] +} diff --git a/tsconfig.docusaurus.json b/tsconfig.docusaurus.json new file mode 100644 index 0000000000..c56af5536c --- /dev/null +++ b/tsconfig.docusaurus.json @@ -0,0 +1,14 @@ +{ + "extends": "@docusaurus/tsconfig", + "compilerOptions": { + "baseUrl": ".", + "lib": ["DOM", "ESNext"] + }, + "exclude": [ + ".astro/types.d.ts", + "src/content.config.ts", + "src/content/**", + "dist", + "build" + ] +} diff --git a/tsconfig.json b/tsconfig.json index fd4988854b..1e9293a519 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { - "extends": "@docusaurus/tsconfig", - "compilerOptions": { - "baseUrl": ".", - "lib": ["DOM", "ESNext"] - } + "files": [], + "references": [ + { + "path": "./tsconfig.astro.json" + }, + { + "path": "./tsconfig.docusaurus.json" + } + ] }