pasika 0.1.0 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -25
- package/claude/hooks/.vulyk +3 -0
- package/claude/hooks/AGENTS.md +3 -0
- package/claude/hooks/claude-hooks.md +30 -0
- package/claude/{.claude/hooks → hooks}/notification.sh +0 -0
- package/claude/{.claude/hooks → hooks}/protect-files.sh +0 -0
- package/claude/scripts/render-settings.ts +25 -9
- package/dist/claude/scripts/render-settings.js +22 -9
- package/dist/eslint/pasika/index.d.ts +2 -0
- package/dist/eslint/pasika/index.js +14 -0
- package/dist/eslint/pasika/rules/organization-imports.d.ts +2 -0
- package/dist/eslint/pasika/rules/organization-imports.js +124 -0
- package/docs/agent-conventions.md +21 -0
- package/docs/code-organization-guide/code-organization-guide.md +65 -0
- package/docs/code-organization-guide/references/application-architecture-reference.md +107 -0
- package/docs/code-organization-guide/rules/component-placement-rule.md +135 -0
- package/docs/code-organization-guide/rules/configuration-rule.md +42 -0
- package/docs/code-organization-guide/rules/constants-rule.md +77 -0
- package/docs/code-organization-guide/rules/exports-and-imports-rule.md +81 -0
- package/docs/code-organization-guide/rules/folder-nesting-rule.md +83 -0
- package/docs/code-organization-guide/rules/hook-extraction-rule.md +142 -0
- package/docs/code-organization-guide/rules/interactive-component-rule.md +100 -0
- package/docs/code-organization-guide/rules/jsx-hygiene-rule.md +68 -0
- package/docs/code-organization-guide/rules/locales-rule.md +55 -0
- package/docs/code-organization-guide/rules/nameable-visual-concept-rule.md +66 -0
- package/docs/code-organization-guide/rules/native-prop-forwarding-rule.md +38 -0
- package/docs/code-organization-guide/rules/no-mixed-concerns-rule.md +64 -0
- package/docs/code-organization-guide/rules/repeated-structure-rule.md +93 -0
- package/docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md +116 -0
- package/docs/code-organization-guide/rules/sole-state-owner-rule.md +102 -0
- package/docs/code-organization-guide/rules/types-and-schemas-rule.md +138 -0
- package/docs/code-organization-guide/rules/utilities-rule.md +87 -0
- package/docs/documentation-guide/_templates/guide.md +19 -0
- package/docs/documentation-guide/_templates/reference.md +17 -0
- package/docs/documentation-guide/_templates/rule.md +21 -0
- package/docs/documentation-guide/documentation-guide.md +13 -0
- package/docs/documentation-guide/references/documentation-types-reference.md +9 -0
- package/docs/documentation-guide/rules/guide-creation-rule.md +112 -0
- package/docs/documentation-guide/rules/reference-creation-rule.md +132 -0
- package/docs/documentation-guide/rules/rule-creation-rule.md +81 -0
- package/docs/documentation-guide/rules/template-usage-rule.md +48 -0
- package/docs/shadcn-theme.md +121 -0
- package/docs/styling-guide/rules/class-composition-rule.md +32 -0
- package/docs/styling-guide/rules/color-role-naming-rule.md +115 -0
- package/docs/styling-guide/rules/component-state-rule.md +26 -0
- package/docs/styling-guide/rules/component-variant-rule.md +128 -0
- package/docs/styling-guide/rules/global-style-system-rule.md +63 -0
- package/docs/styling-guide/rules/style-placement-rule.md +49 -0
- package/docs/styling-guide/rules/tailwind-utility-rule.md +48 -0
- package/docs/styling-guide/rules/theme-and-utility-definition-rule.md +129 -0
- package/docs/styling-guide/rules/theme-token-rule.md +32 -0
- package/docs/styling-guide/styling-guide.md +17 -0
- package/package.json +24 -10
- package/AGENTS.md +0 -15
- package/docs/common/merge-behavior.md +0 -17
- package/docs/common/overview.md +0 -20
- /package/{CLAUDE.md → claude/hooks/CLAUDE.md} +0 -0
- /package/claude/{.claude/hooks → hooks}/status-line/index.js +0 -0
- /package/claude/{.claude/settings.base.json → settings.base.json} +0 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Component Placement Rule
|
|
2
|
+
|
|
3
|
+
Without clear placement, it is hard to tell where a component belongs and reuse can create tangled dependencies. This rule gives each component a specific place in the application structure.
|
|
4
|
+
|
|
5
|
+
- A routing UI file is a Next.js App Router UI file, excluding request handlers and metadata files. A Next.js `layout.tsx` routing UI file MAY render the layout required by its route segment and `children`. Any other routing UI file MAY import root support modules and directly render one feature component, one composition, or one shared component, but MUST NOT own state, callbacks, effects, or page layout.
|
|
6
|
+
- A routing UI file other than `layout.tsx` that needs state, callbacks, effects, or page layout MUST delegate that work to a component in `src/compositions/`.
|
|
7
|
+
- Any other component that imports from two or more feature folders MUST live in `src/compositions/` and is, by definition, a composition.
|
|
8
|
+
- A component whose only consumers are routing files and that does not import from two or more feature folders MUST live in the feature folder whose behavior it implements. A component implements a feature when it reads that feature's data or exposes that feature's actions. If it implements no feature and only one routing file imports it, it MUST introduce a new feature folder for that route; if routing files for two or more routes import it, it MUST live in `src/shared/`. This rule does not apply to Next.js routing files themselves.
|
|
9
|
+
- A component with at least one consumer other than a routing file MUST live in the closest common folder (CCF) of its non-routing consumers.
|
|
10
|
+
- A new component with no consumers and fewer than two feature-folder imports MUST start in the existing feature folder it belongs to, or in a new feature folder when it introduces a new feature. A new component with no feature-specific behavior or data MAY start in `src/shared/` only when it imports no feature folders; otherwise it starts in the feature it imports from.
|
|
11
|
+
- Imports from routing files in `src/app/` MUST NOT count as component consumers or affect CCF calculation.
|
|
12
|
+
- Imports from configuration modules MUST NOT count as component consumers or affect CCF calculation.
|
|
13
|
+
- When a component is imported both inside and outside `src/compositions/`, its CCF MUST be calculated only from imports outside `src/compositions/`.
|
|
14
|
+
- When a component is imported only in `src/compositions/`, its CCF MUST be calculated from those imports.
|
|
15
|
+
- When a component’s CCF is `src/features/`, it MUST live in `src/shared/`.
|
|
16
|
+
- When a component’s CCF is `src/`, it MUST live in `src/shared/`.
|
|
17
|
+
- When a component’s CCF is a nested component directory, it MUST start flat in that directory unless the Folder Nesting Rule requires nesting.
|
|
18
|
+
- When a component’s CCF is a feature folder, `src/compositions/`, or `src/shared/`, it MUST start flat in that folder unless the Folder Nesting Rule requires nesting.
|
|
19
|
+
- `src/app/` MUST contain [Next.js App Router framework-convention files and assets](https://nextjs.org/docs/app/getting-started/project-structure#routing-files), plus styles required by routing files, but MUST NOT contain ordinary components or support folders.
|
|
20
|
+
- A JavaScript or TypeScript module under `src/app/` MUST NOT import modules under `src/` except from `src/compositions/`, `src/features/`, `src/shared/`, and root support folders.
|
|
21
|
+
- A JavaScript or TypeScript module under `src/compositions/` MUST NOT import modules under `src/` except from `src/compositions/`, feature folders, `src/shared/`, and root support folders.
|
|
22
|
+
- A JavaScript or TypeScript module in a feature folder MUST NOT import modules under `src/` except from the same feature folder, `src/shared/`, and root support folders.
|
|
23
|
+
- A JavaScript or TypeScript module under `src/shared/` MUST NOT import modules under `src/` except from `src/shared/` and root support folders.
|
|
24
|
+
- A JavaScript or TypeScript module in a root support folder MUST NOT import modules under `src/` except from root support folders. Any module under `src/config/<config-name>/` MAY also import files under that same configuration-object directory.
|
|
25
|
+
- ESLint MUST enforce these import restrictions for JavaScript and TypeScript modules under `src/`.
|
|
26
|
+
|
|
27
|
+
## Incorrect — Cross-Feature Component Duplicated
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
src/features/
|
|
31
|
+
├── donation/
|
|
32
|
+
│ ├── DonationCard.tsx
|
|
33
|
+
│ └── status-badge.tsx
|
|
34
|
+
└── stream/
|
|
35
|
+
├── StreamCard.tsx
|
|
36
|
+
└── status-badge.tsx
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Why: two feature folders contain duplicate copies because neither feature may import from the other.
|
|
40
|
+
|
|
41
|
+
## Correct — Cross-Feature Component in `src/shared/`
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
src/
|
|
45
|
+
├── features/
|
|
46
|
+
│ ├── donation/
|
|
47
|
+
│ │ └── DonationCard.tsx
|
|
48
|
+
│ └── stream/
|
|
49
|
+
│ └── StreamCard.tsx
|
|
50
|
+
└── shared/
|
|
51
|
+
└── status-badge.tsx
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Why: the component's CCF is `src/features/`, so it lives in `src/shared/`.
|
|
55
|
+
|
|
56
|
+
## Incorrect — Multi-Feature Composition Inside One Feature
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
// src/features/billing/billing-aggregate-view.tsx
|
|
60
|
+
import { BillingPanel } from "./BillingPanel";
|
|
61
|
+
import { HomeBanner } from "@/features/home/HomeBanner";
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Why: the component imports from two feature folders, so it cannot live in either feature folder.
|
|
65
|
+
|
|
66
|
+
## Correct — Multi-Feature Component in `src/compositions/`
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
// src/compositions/billing-aggregate-view.tsx
|
|
70
|
+
import { BillingPanel } from "@/features/billing/BillingPanel";
|
|
71
|
+
import { HomeBanner } from "@/features/home/HomeBanner";
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Why: the component imports from two feature folders, so it lives in `src/compositions/`.
|
|
75
|
+
|
|
76
|
+
## Incorrect — Ordinary Component Under `src/app/`
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
src/app/contact/
|
|
80
|
+
├── _components/
|
|
81
|
+
│ └── contact-page-content.tsx
|
|
82
|
+
└── page.tsx
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Why: the route folder contains ordinary component structure even though `src/app/` is reserved for framework routing files.
|
|
86
|
+
|
|
87
|
+
## Correct — Thin Route Imports a Page Composition
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
src/
|
|
91
|
+
├── app/contact/
|
|
92
|
+
│ └── page.tsx
|
|
93
|
+
├── compositions/
|
|
94
|
+
│ └── contact-page-content.tsx
|
|
95
|
+
└── features/
|
|
96
|
+
├── contact/
|
|
97
|
+
│ └── contact-information.tsx
|
|
98
|
+
└── location/
|
|
99
|
+
└── office-location.tsx
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
// src/compositions/contact-page-content.tsx
|
|
104
|
+
import { ContactInformation } from "@/features/contact/contact-information";
|
|
105
|
+
import { OfficeLocation } from "@/features/location/office-location";
|
|
106
|
+
import { locales } from "@/locales";
|
|
107
|
+
|
|
108
|
+
export function ContactPageContent(): React.JSX.Element {
|
|
109
|
+
return (
|
|
110
|
+
<main data-testid="contact-page-content">
|
|
111
|
+
<header>
|
|
112
|
+
<h1>{locales.contactPageTitle}</h1>
|
|
113
|
+
<p>{locales.contactPageDescription}</p>
|
|
114
|
+
</header>
|
|
115
|
+
<section>
|
|
116
|
+
<h2>{locales.contactInformationHeading}</h2>
|
|
117
|
+
<ContactInformation />
|
|
118
|
+
</section>
|
|
119
|
+
<section>
|
|
120
|
+
<h2>{locales.officeLocationHeading}</h2>
|
|
121
|
+
<OfficeLocation />
|
|
122
|
+
</section>
|
|
123
|
+
</main>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/app/contact/page.tsx
|
|
128
|
+
import { ContactPageContent } from "@/compositions/contact-page-content";
|
|
129
|
+
|
|
130
|
+
export default function Page(): React.JSX.Element {
|
|
131
|
+
return <ContactPageContent />;
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Why: the routing file remains thin while the page composition provides the contact page layout and combines components from two feature folders.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Configuration Rule
|
|
2
|
+
|
|
3
|
+
Configuration objects centralize values that control application behavior. This rule keeps each object and the files that only support it together in `src/config/`.
|
|
4
|
+
|
|
5
|
+
- A configuration object is an app-wide object that selects or parameterizes application behavior. Feature options and domain values are not configuration objects.
|
|
6
|
+
- All configuration objects MUST live in `src/config/`.
|
|
7
|
+
- A configuration object MUST use `src/config/<config-name>/index.ts`.
|
|
8
|
+
- A configuration support file is a hook, type, schema, constant, or utility used only to implement one configuration object; config-only extraction applies even with one consumer.
|
|
9
|
+
- A configuration support file MUST live in its matching dedicated folder under `src/config/<config-name>/`.
|
|
10
|
+
- A configuration support file remains in that configuration-object directory while every consumer is in that directory; it MUST move to its matching root support folder at the root CCF when any consumer is outside it.
|
|
11
|
+
|
|
12
|
+
## Incorrect — Supporting Schema Outside Its Config Folder
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
src/config/
|
|
16
|
+
├── home-feed/
|
|
17
|
+
│ └── index.ts
|
|
18
|
+
└── home-feed-schema.ts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Why: the schema sits outside the `home-feed/` configuration folder.
|
|
22
|
+
|
|
23
|
+
## Correct — Configuration Object with Its Supporting Schema
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
src/config/
|
|
27
|
+
└── home-feed/
|
|
28
|
+
├── index.ts
|
|
29
|
+
└── schemas/
|
|
30
|
+
└── index.ts
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// src/config/home-feed/index.ts
|
|
35
|
+
import { homeFeedConfigSchema } from "./schemas";
|
|
36
|
+
|
|
37
|
+
export const homeFeedConfig = homeFeedConfigSchema.parse({
|
|
38
|
+
freshVideoMaxAgeDays: 7,
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Why: the configuration object and its supporting schema are grouped in the same configuration folder.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Constants Rule
|
|
2
|
+
|
|
3
|
+
Duplicated constants are hard to keep in sync, while extracting every single-use value creates unnecessary files. This rule keeps reused constants in one module and leaves single-use values close to their consumer.
|
|
4
|
+
|
|
5
|
+
- A constant is an exported or reused fixed domain value; local literals and implementation values are not constants for this rule. A configuration object is not a constant for this rule.
|
|
6
|
+
- Except for a constant used only to implement one configuration object, a constant MUST stay in the file that uses it until another file imports it.
|
|
7
|
+
- A constant used only to implement one configuration object MUST live in that object's `constants/` folder.
|
|
8
|
+
- Extracted constants MUST live in a `constants/` folder at the closest common folder (CCF) of their consumers.
|
|
9
|
+
- A `constants/index.ts` MAY define its exports directly.
|
|
10
|
+
- Consumers MUST import extracted constants through the `constants/index.ts` at their scope.
|
|
11
|
+
- Constants that are used together MAY be grouped in a file with a kebab-case name and named-re-exported from `constants/index.ts`.
|
|
12
|
+
- When a `constants/` folder uses grouped files, its `index.ts` MUST only named-re-export those files.
|
|
13
|
+
- When a constant's CCF is `src/features/`, it MUST move to `src/constants/`.
|
|
14
|
+
|
|
15
|
+
## Incorrect — Constant Imported Without `constants/index.ts`
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// src/features/billing/constants/max-retries.ts
|
|
19
|
+
export const maxRetries = 3;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// src/features/billing/hooks/use-retry-payment.ts
|
|
24
|
+
import { maxRetries } from "../constants/max-retries";
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Why: the consumer bypasses the feature's `constants/index.ts`.
|
|
28
|
+
|
|
29
|
+
## Correct — Grouped Constants Re-Exported from `constants/index.ts`
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// src/features/billing/constants/retry.ts
|
|
33
|
+
export const maxRetries = 3;
|
|
34
|
+
export const retryDelayMs = 1_000;
|
|
35
|
+
|
|
36
|
+
// src/features/billing/constants/index.ts
|
|
37
|
+
export { maxRetries, retryDelayMs } from "./retry";
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// src/features/billing/hooks/use-retry-payment.ts
|
|
42
|
+
import { maxRetries } from "../constants";
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Why: the constants are available through the feature's `constants/index.ts`.
|
|
46
|
+
|
|
47
|
+
## Incorrect — Feature and Composition Constant Kept in a Feature
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// src/features/billing/constants/index.ts
|
|
51
|
+
export const paymentRetryDelayMs = 1_000;
|
|
52
|
+
|
|
53
|
+
// src/features/billing/hooks/use-retry-payment.ts
|
|
54
|
+
import { paymentRetryDelayMs } from "../constants";
|
|
55
|
+
|
|
56
|
+
// src/compositions/BillingDashboard.tsx
|
|
57
|
+
import { paymentRetryDelayMs } from "@/features/billing/constants";
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Why: the feature and composition have `src/` as their CCF, but the constant remains in the billing feature.
|
|
61
|
+
|
|
62
|
+
## Correct — Feature and Composition Constant in `src/constants/`
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// src/constants/index.ts
|
|
66
|
+
export const paymentRetryDelayMs = 1_000;
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
// src/features/billing/hooks/use-retry-payment.ts
|
|
71
|
+
import { paymentRetryDelayMs } from "@/constants";
|
|
72
|
+
|
|
73
|
+
// src/compositions/BillingDashboard.tsx
|
|
74
|
+
import { paymentRetryDelayMs } from "@/constants";
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Why: the feature and composition have `src/` as their CCF, so the constant lives in `src/constants/`.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Exports and Imports Rule
|
|
2
|
+
|
|
3
|
+
Without consistent export and import styles, it is harder to tell what a file contains and how other files should import from it. This rule gives each file type a predictable export style and keeps import paths consistent.
|
|
4
|
+
|
|
5
|
+
- Static JavaScript and TypeScript modules under `src/` MUST use named exports unless a framework or third-party package requires a different export style for a specific file.
|
|
6
|
+
- Static imports under `src/` MUST use relative paths for the same folder, a direct subfolder, or one folder up.
|
|
7
|
+
- Static imports under `src/` MUST use the `@/*` alias for anything beyond one folder up.
|
|
8
|
+
- ESLint MUST enforce this rule's export and static import restrictions under `src/`.
|
|
9
|
+
|
|
10
|
+
## Incorrect — Single-Export Utility Uses a Default Export
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// src/utils/format-duration.ts
|
|
14
|
+
export default function formatDuration(seconds: number): string {
|
|
15
|
+
const minutes = Math.floor(seconds / 60);
|
|
16
|
+
const remainder = seconds % 60;
|
|
17
|
+
return `${minutes}:${String(remainder).padStart(2, "0")}`;
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Why: files use named exports even when they expose one item.
|
|
22
|
+
|
|
23
|
+
## Correct — Single-Export Utility Uses a Named Export
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// src/utils/format-duration.ts
|
|
27
|
+
export function formatDuration(seconds: number): string {
|
|
28
|
+
const minutes = Math.floor(seconds / 60);
|
|
29
|
+
const remainder = seconds % 60;
|
|
30
|
+
return `${minutes}:${String(remainder).padStart(2, "0")}`;
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Why: the utility uses the same named-export form as every other file that does not have a package-required export contract.
|
|
35
|
+
|
|
36
|
+
## Incorrect — Deep Relative Path
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// src/features/stream/StreamBoard/schedule.ts
|
|
40
|
+
import { debounce } from "../../../utils/debounce";
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Why: reaching more than one folder up hides the cross-scope dependency in relative path traversal.
|
|
44
|
+
|
|
45
|
+
## Correct — Alias Across Scopes and Relative Paths Nearby
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// src/features/stream/StreamBoard/schedule.ts
|
|
49
|
+
import { debounce } from "@/utils/debounce";
|
|
50
|
+
import { formatSlot } from "./utils/format-slot";
|
|
51
|
+
import { type StreamSlot } from "./types";
|
|
52
|
+
import { buildSchedule } from "../schedule-builder";
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Why: the alias identifies the distant dependency while nearby files keep short relative paths.
|
|
56
|
+
|
|
57
|
+
## Incorrect — Next.js Page Uses a Named Export
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// src/app/contact/page.tsx
|
|
61
|
+
import { locales } from "@/locales";
|
|
62
|
+
|
|
63
|
+
export function Page(): React.JSX.Element {
|
|
64
|
+
return <main>{locales.contactUs}</main>;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Why: Next.js requires a `page.tsx` file to default-export its page component.
|
|
69
|
+
|
|
70
|
+
## Correct — Next.js Page Uses a Default Export
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
// src/app/contact/page.tsx
|
|
74
|
+
import { locales } from "@/locales";
|
|
75
|
+
|
|
76
|
+
export default function Page(): React.JSX.Element {
|
|
77
|
+
return <main>{locales.contactUs}</main>;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Why: the page follows the export contract Next.js requires.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Folder Nesting Rule
|
|
2
|
+
|
|
3
|
+
Without nesting, exclusive children can look reusable and their relationship to the parent is easy to miss in review. This rule groups them with their parent and keeps them out of the folder's public API.
|
|
4
|
+
|
|
5
|
+
- An exclusive child component is imported only by its parent component or descendants in the parent's folder. A flat component MUST be nested in a folder with the same name when it gains one or more exclusive child components.
|
|
6
|
+
- A child that gains a consumer outside its parent's folder MUST move according to the Component Placement Rule, including that rule's CCF consumer exclusions.
|
|
7
|
+
- A component MUST NOT be nested only because it has support files.
|
|
8
|
+
- A nested component's support files MUST live in its folder.
|
|
9
|
+
- The nested folder's `index.ts` MUST named-re-export the nested component and MUST NOT re-export its exclusive children.
|
|
10
|
+
|
|
11
|
+
## Incorrect — Exclusive Children Kept Flat
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
src/features/blog/
|
|
15
|
+
BlogPage.tsx # owns exclusive children
|
|
16
|
+
blog-header.tsx # exclusive child — no sibling needs it
|
|
17
|
+
blog-footer.tsx # exclusive child — no sibling needs it
|
|
18
|
+
hooks/
|
|
19
|
+
use-blog-filter.ts # used only by BlogPage
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Why: `BlogPage` owns children that no sibling uses, but all three files sit as flat siblings. Nothing in the tree shows that `blog-header.tsx` and `blog-footer.tsx` belong to `BlogPage` rather than to any other component in the folder.
|
|
23
|
+
|
|
24
|
+
## Correct — Exclusive Children Nested
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
src/features/blog/
|
|
28
|
+
BlogPage/
|
|
29
|
+
index.ts # re-exports only BlogPage.tsx
|
|
30
|
+
BlogPage.tsx
|
|
31
|
+
blog-header.tsx # not re-exported from index.ts
|
|
32
|
+
blog-footer.tsx # not re-exported from index.ts
|
|
33
|
+
hooks/
|
|
34
|
+
use-blog-filter.ts
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Why: nesting `BlogPage` into `BlogPage/` makes the parent–child relationship visible in the filesystem, lets the children stay scoped to their concrete consumer, and gives the folder a barrel that re-exports only the nested component.
|
|
38
|
+
|
|
39
|
+
## Incorrect — Support Files Cause Unnecessary Nesting
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
src/features/blog/
|
|
43
|
+
BlogPage/
|
|
44
|
+
index.ts
|
|
45
|
+
BlogPage.tsx
|
|
46
|
+
hooks/
|
|
47
|
+
use-blog-filter.ts
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Why: support files alone do not make `BlogPage` a nested component.
|
|
51
|
+
|
|
52
|
+
## Correct — Support Files Keep a Component Flat
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
src/features/blog/
|
|
56
|
+
BlogPage.tsx
|
|
57
|
+
hooks/
|
|
58
|
+
use-blog-filter.ts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Why: without exclusive children, `BlogPage` stays flat and its support files remain at the feature scope.
|
|
62
|
+
|
|
63
|
+
## Incorrect — Exclusive Child Re-Exported
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// src/features/blog/BlogPage/index.ts
|
|
67
|
+
export { BlogPage } from "./BlogPage";
|
|
68
|
+
export { BlogHeader } from "./blog-header";
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Why: the barrel re-exports the child as well as the nested component, so outside consumers can import `blog-header.tsx` through `index.ts` and the child stops being exclusive to `BlogPage`.
|
|
72
|
+
|
|
73
|
+
## Correct — Only the Nested Component Re-Exported
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
// src/features/blog/BlogPage/index.ts
|
|
77
|
+
export { BlogPage } from "./BlogPage";
|
|
78
|
+
|
|
79
|
+
// src/features/blog/BlogPage/BlogPage.tsx
|
|
80
|
+
import { BlogHeader } from "./blog-header";
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Why: the barrel exposes only the nested component, so it does not expose the child.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Hook Extraction Rule
|
|
2
|
+
|
|
3
|
+
Keeping every hook inline makes components bloated, while extracting every hook adds indirection without benefit. This rule defines concrete reuse and imperative-complexity triggers for extraction.
|
|
4
|
+
|
|
5
|
+
- A custom hook MUST be extracted to its own file when two or more consumers use it.
|
|
6
|
+
- A custom hook used only to implement one configuration object MUST live in that object's `hooks/` folder.
|
|
7
|
+
- A custom hook with exactly one consumer MUST be extracted when it contains two or more imperative categories and can be described as one coherent behavior.
|
|
8
|
+
- An extracted custom hook MUST live in a `hooks/` folder at the closest common folder (CCF) of its consumers.
|
|
9
|
+
- When a custom hook's CCF is `src/features/`, it MUST move to `src/hooks/`.
|
|
10
|
+
- The imperative categories MUST be subscriptions, external I/O and persistence, DOM manipulation, or resource lifecycle.
|
|
11
|
+
- One operation MAY count in only one imperative category; use its primary purpose when categories overlap.
|
|
12
|
+
- Subscriptions MUST include event listeners and registration or cleanup APIs such as `on()` and `off()`.
|
|
13
|
+
- External I/O and persistence MUST include network requests, asynchronous reads or writes, and browser storage.
|
|
14
|
+
- DOM manipulation MUST include imperative APIs such as `focus()`, `classList`, observers, or imperative rendering.
|
|
15
|
+
- Resource lifecycle MUST include setup and teardown APIs such as `load()`, `destroy()`, or `dispose()`.
|
|
16
|
+
- A custom hook with one consumer that does not meet the two-category threshold MUST stay inline in its consumer module, unless it is used only to implement one configuration object.
|
|
17
|
+
|
|
18
|
+
## Incorrect — Two Imperative Categories Left Inline
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
// src/features/player/Player.tsx
|
|
22
|
+
export function Player({ src }: PlayerProps): React.JSX.Element {
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
player.on("play", handlePlay);
|
|
25
|
+
player.on("pause", handlePause);
|
|
26
|
+
player.load(src);
|
|
27
|
+
|
|
28
|
+
return () => {
|
|
29
|
+
player.off("play", handlePlay);
|
|
30
|
+
player.off("pause", handlePause);
|
|
31
|
+
player.destroy();
|
|
32
|
+
};
|
|
33
|
+
}, [src]);
|
|
34
|
+
|
|
35
|
+
return <PlayerView />;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Why: one coherent player-setup behavior combines subscriptions with resource lifecycle, so leaving it inline crosses the two-category threshold.
|
|
40
|
+
|
|
41
|
+
## Correct — Complex Single-Use Hook Extracted
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// src/features/player/hooks/use-player-setup.ts
|
|
45
|
+
export function usePlayerSetup(src: string): void {
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
player.on("play", handlePlay);
|
|
48
|
+
player.on("pause", handlePause);
|
|
49
|
+
player.load(src);
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
player.off("play", handlePlay);
|
|
53
|
+
player.off("pause", handlePause);
|
|
54
|
+
player.destroy();
|
|
55
|
+
};
|
|
56
|
+
}, [src]);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
// src/features/player/Player.tsx
|
|
62
|
+
import { usePlayerSetup } from "./hooks/use-player-setup";
|
|
63
|
+
|
|
64
|
+
export function Player({ src }: PlayerProps): React.JSX.Element {
|
|
65
|
+
usePlayerSetup(src);
|
|
66
|
+
return <PlayerView />;
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Why: the named hook owns the subscription and resource lifecycle for one coherent behavior, keeping the component focused on rendering.
|
|
71
|
+
|
|
72
|
+
## Incorrect — Reused Hook Kept Inline
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
// src/features/billing/invoice.tsx
|
|
76
|
+
const useInvoiceSort = (invoices: Invoice[]): Invoice[] => {
|
|
77
|
+
return useMemo(() => invoices.toSorted(byDate), [invoices]);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// src/features/billing/invoice-summary.tsx
|
|
81
|
+
const useInvoiceSort = (invoices: Invoice[]): Invoice[] => {
|
|
82
|
+
return useMemo(() => invoices.toSorted(byDate), [invoices]);
|
|
83
|
+
};
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Why: two components use the same hook behavior, so keeping it inline duplicates it.
|
|
87
|
+
|
|
88
|
+
## Correct — Reused Hook Extracted
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// src/features/billing/hooks/use-invoice-sort.ts
|
|
92
|
+
export function useInvoiceSort(invoices: Invoice[]): Invoice[] {
|
|
93
|
+
return useMemo(() => invoices.toSorted(byDate), [invoices]);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// src/features/billing/invoice.tsx
|
|
99
|
+
import { useInvoiceSort } from "./hooks/use-invoice-sort";
|
|
100
|
+
|
|
101
|
+
// src/features/billing/invoice-summary.tsx
|
|
102
|
+
import { useInvoiceSort } from "./hooks/use-invoice-sort";
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Why: the hook has two consumers, so it has its own file.
|
|
106
|
+
|
|
107
|
+
## Incorrect — Simple Single-Use Hook Extracted
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// src/features/billing/hooks/use-invoice-sort.ts
|
|
111
|
+
export function useInvoiceSort(invoices: Invoice[]): Invoice[] {
|
|
112
|
+
return useMemo(() => invoices.toSorted(byDate), [invoices]);
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
// src/features/billing/invoice.tsx
|
|
118
|
+
import { useInvoiceSort } from "./hooks/use-invoice-sort";
|
|
119
|
+
|
|
120
|
+
export function Invoice({ invoices }: InvoiceProps): React.JSX.Element {
|
|
121
|
+
const sortedInvoices = useInvoiceSort(invoices);
|
|
122
|
+
return <InvoiceList invoices={sortedInvoices} />;
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Why: the hook has one consumer and no imperative category, so its separate file adds indirection before an extraction trigger exists.
|
|
127
|
+
|
|
128
|
+
## Correct — Simple Single-Use Hook Inline
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
// src/features/billing/invoice.tsx
|
|
132
|
+
const useInvoiceSort = (invoices: Invoice[]): Invoice[] => {
|
|
133
|
+
return useMemo(() => invoices.toSorted(byDate), [invoices]);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function Invoice({ invoices }: InvoiceProps): React.JSX.Element {
|
|
137
|
+
const sortedInvoices = useInvoiceSort(invoices);
|
|
138
|
+
return <InvoiceList invoices={sortedInvoices} />;
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Why: the hook stays beside its sole consumer until reuse or imperative complexity provides a mechanical extraction trigger.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Interactive Component Rule
|
|
2
|
+
|
|
3
|
+
Large component files need a clear way to decide what to extract first. This rule treats interactive elements as meaningful component boundaries instead of extracting arbitrary layout elements.
|
|
4
|
+
|
|
5
|
+
- An [interactive HTML element](https://html.spec.whatwg.org/multipage/dom.html#interactive-content) MUST be extracted to a component with a descriptive name unless it is the top-level native element returned by the component that renders it in every rendered result.
|
|
6
|
+
|
|
7
|
+
## Incorrect — Interactive Element Kept Inline
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
// src/features/layout/header-section.tsx
|
|
11
|
+
import { locales } from "@/locales";
|
|
12
|
+
|
|
13
|
+
export function HeaderSection({
|
|
14
|
+
onMenuClick,
|
|
15
|
+
searchPlaceholder,
|
|
16
|
+
}: {
|
|
17
|
+
onMenuClick: () => void;
|
|
18
|
+
searchPlaceholder: string;
|
|
19
|
+
}): React.JSX.Element {
|
|
20
|
+
return (
|
|
21
|
+
<header className="flex items-center justify-between px-6 py-4">
|
|
22
|
+
<h1 className="text-2xl">{locales.layout.headerTitle}</h1>
|
|
23
|
+
|
|
24
|
+
<button onClick={onMenuClick} aria-label={locales.layout.openMenu}>
|
|
25
|
+
<Icon name="menu" />
|
|
26
|
+
</button>
|
|
27
|
+
|
|
28
|
+
<input type="search" placeholder={searchPlaceholder} />
|
|
29
|
+
</header>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Why: the interactive elements remain mixed into `HeaderSection` instead of having their own components.
|
|
35
|
+
|
|
36
|
+
## Correct — Interactive Element Extracted
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
src/features/layout/
|
|
40
|
+
header-section/
|
|
41
|
+
index.ts # re-exports only header-section.tsx
|
|
42
|
+
header-section.tsx
|
|
43
|
+
menu-button.tsx # exclusive child — imported directly
|
|
44
|
+
search-field.tsx # exclusive child — imported directly
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
// src/features/layout/header-section/menu-button.tsx
|
|
49
|
+
type MenuButtonProps = React.ComponentProps<"button">;
|
|
50
|
+
|
|
51
|
+
export function MenuButton({ onClick, ...props }: MenuButtonProps): React.JSX.Element {
|
|
52
|
+
return (
|
|
53
|
+
<button {...props} onClick={onClick}>
|
|
54
|
+
<Icon name="menu" />
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
// src/features/layout/header-section/search-field.tsx
|
|
62
|
+
type SearchFieldProps = Omit<React.ComponentProps<"input">, "type">;
|
|
63
|
+
|
|
64
|
+
export function SearchField(props: SearchFieldProps): React.JSX.Element {
|
|
65
|
+
return <input {...props} type="search" />;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
// src/features/layout/header-section/header-section.tsx — now imports both interactive units instead of inline JSX
|
|
71
|
+
|
|
72
|
+
import { MenuButton } from "./menu-button";
|
|
73
|
+
import { SearchField } from "./search-field";
|
|
74
|
+
import { cn } from "@/utils/cn";
|
|
75
|
+
import { locales } from "@/locales";
|
|
76
|
+
|
|
77
|
+
type HeaderSectionProps = React.ComponentProps<"header"> & {
|
|
78
|
+
onMenuClick: () => void;
|
|
79
|
+
searchPlaceholder: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function HeaderSection({
|
|
83
|
+
className,
|
|
84
|
+
onMenuClick,
|
|
85
|
+
searchPlaceholder,
|
|
86
|
+
...props
|
|
87
|
+
}: HeaderSectionProps): React.JSX.Element {
|
|
88
|
+
return (
|
|
89
|
+
<header {...props} className={cn("flex items-center justify-between px-6 py-4", className)}>
|
|
90
|
+
<h1 className="text-2xl">{locales.layout.headerTitle}</h1>
|
|
91
|
+
|
|
92
|
+
<MenuButton onClick={onMenuClick} aria-label={locales.layout.openMenu} />
|
|
93
|
+
|
|
94
|
+
<SearchField placeholder={searchPlaceholder} />
|
|
95
|
+
</header>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Why: each interactive element now has its own descriptive component and forwards native props, leaving `HeaderSection` to compose them.
|