├── examples └── todo │ ├── index.ts │ ├── src │ ├── pages │ │ ├── test-page │ │ │ └── page.tsx │ │ └── todo-list-page │ │ │ └── page.tsx │ ├── app │ │ ├── vite-env.d.ts │ │ ├── main.tsx │ │ ├── App.tsx │ │ └── index.css │ ├── features │ │ └── todos │ │ │ ├── index.ts │ │ │ ├── model │ │ │ ├── domain.ts │ │ │ ├── useSort.ts │ │ │ ├── useFilter.ts │ │ │ └── useTodos.ts │ │ │ ├── vm │ │ │ └── useAddTodoForm.ts │ │ │ └── ui │ │ │ ├── AddTodoForm.tsx │ │ │ ├── TodoListLayout.tsx │ │ │ ├── SearchTodos.tsx │ │ │ ├── TodoList.tsx │ │ │ ├── SortTodos.tsx │ │ │ └── TodoItem.tsx │ └── shared │ │ ├── lib │ │ └── css.ts │ │ └── ui │ │ └── shadcn │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── checkbox.tsx │ │ ├── button.tsx │ │ └── dropdown-menu.tsx │ ├── .eslintrc.cjs │ ├── postcss.config.js │ ├── tsconfig.json │ ├── vite.config.ts │ ├── .gitignore │ ├── tsconfig.node.json │ ├── index.html │ ├── components.json │ ├── tsconfig.app.json │ ├── README.md │ ├── public │ └── vite.svg │ ├── package.json │ ├── evo.config.ts │ └── tailwind.config.js ├── packages ├── linter │ ├── src │ │ ├── index.ts │ │ ├── version.ts │ │ ├── lint │ │ │ ├── index.ts │ │ │ ├── lint.ts │ │ │ ├── run-rules.ts │ │ │ └── auto-fix.ts │ │ ├── pretty-reporter │ │ │ ├── types.ts │ │ │ ├── pluralization.ts │ │ │ ├── format-single-diagnostic.ts │ │ │ └── index.ts │ │ └── cli │ │ │ ├── commands │ │ │ ├── init.ts │ │ │ ├── index.ts │ │ │ └── lint.ts │ │ │ └── index.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── package.json └── core │ ├── tsconfig.json │ ├── src │ ├── dependencies-map │ │ ├── types.ts │ │ └── parse-dependencies-map.ts │ ├── vfs │ │ ├── create-root.ts │ │ ├── types.ts │ │ ├── get-flatten-files.ts │ │ ├── copy-node.ts │ │ ├── get-nodes-record.ts │ │ ├── remove-node.ts │ │ ├── add-file.ts │ │ ├── add-directory.ts │ │ ├── watch-fs.ts │ │ └── vfs.test.ts │ ├── abstraction-instance │ │ ├── get-abstraction-instance-label.ts │ │ ├── types.ts │ │ ├── parse-abstraction-instance.ts │ │ └── parse-abstraction-instance.test.ts │ ├── config │ │ ├── define-config.ts │ │ ├── errors.ts │ │ ├── schema.ts │ │ └── load.ts │ ├── rule │ │ ├── rule.ts │ │ └── types.ts │ ├── index.ts │ ├── abstraction.ts │ └── rules │ │ └── index.ts │ ├── tsup.config.ts │ └── package.json ├── apps └── docs │ ├── tsconfig.json │ ├── src │ ├── content │ │ ├── docs │ │ │ ├── deep-dive │ │ │ │ ├── maintainability │ │ │ │ │ ├── index.md │ │ │ │ │ ├── readability.md │ │ │ │ │ ├── reliability.md │ │ │ │ │ ├── abstractness.md │ │ │ │ │ └── reusability.md │ │ │ │ └── index.mdx │ │ │ ├── terms │ │ │ │ ├── abstraction.md │ │ │ │ └── index.md │ │ │ ├── en │ │ │ │ ├── guide │ │ │ │ │ ├── config.md │ │ │ │ │ ├── index.md │ │ │ │ │ └── examples │ │ │ │ │ │ └── index.md │ │ │ │ ├── why │ │ │ │ │ ├── index.md │ │ │ │ │ └── resolve.md │ │ │ │ ├── core-architectural-concepts │ │ │ │ │ └── index.md │ │ │ │ └── index.mdx │ │ │ ├── index.mdx │ │ │ ├── getting-started │ │ │ │ └── index.md │ │ │ └── guide │ │ │ │ ├── ed-small.mdx │ │ │ │ └── index.mdx │ │ └── config.ts │ ├── env.d.ts │ ├── assets │ │ ├── logo.png │ │ ├── houston.webp │ │ ├── green-book.png │ │ ├── medium-schema.png │ │ ├── small-schema.png │ │ └── evolution-meme.jpg │ └── styles │ │ └── custom.css │ ├── .vscode │ ├── extensions.json │ └── launch.json │ ├── Dockerfile │ ├── .gitignore │ ├── package.json │ ├── nginx │ └── nginx.conf │ ├── public │ └── favicon.svg │ ├── astro.config.mjs │ └── README.md ├── .vscode └── settings.json ├── .changeset ├── common-plums-share.md ├── config.json └── README.md ├── kit ├── kit │ ├── src │ │ ├── index.ts │ │ ├── memoize.ts │ │ ├── shallow-equal.ts │ │ └── resolve-import.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── package.json └── tsconfig │ ├── package.json │ └── base.json ├── .npmrc ├── .gitignore ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── turbo.json ├── README.md ├── package.json ├── scripts └── check-packages.js └── PUBLISHING.md /examples/todo/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/todo/src/pages/test-page/page.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/todo/src/pages/todo-list-page/page.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/linter/src/index.ts: -------------------------------------------------------------------------------- 1 | export { lint } from "./lint"; 2 | -------------------------------------------------------------------------------- /packages/linter/src/version.ts: -------------------------------------------------------------------------------- 1 | export const version = "0.0.9"; 2 | -------------------------------------------------------------------------------- /apps/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } 4 | -------------------------------------------------------------------------------- /examples/todo/src/app/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/index.ts: -------------------------------------------------------------------------------- 1 | export { TodoList } from "./ui/TodoList"; 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Disable the default formatter, use eslint instead 3 | } 4 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/deep-dive/maintainability/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Поддерживаемый код 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/deep-dive/maintainability/readability.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Читаемость 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/deep-dive/maintainability/reliability.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Надёжность 3 | --- 4 | -------------------------------------------------------------------------------- /examples/todo/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | import antfu from "@antfu/eslint-config"; 2 | 3 | export default antfu(); 4 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/deep-dive/maintainability/abstractness.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Абстрактность 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/deep-dive/maintainability/reusability.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Переиспользование 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/terms/abstraction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Абстракция 3 | description: Абстракция 4 | --- 5 | -------------------------------------------------------------------------------- /.changeset/common-plums-share.md: -------------------------------------------------------------------------------- 1 | --- 2 | "edlint": patch 3 | "@evod/core": patch 4 | --- 5 | 6 | New structure 7 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/en/guide/config.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration 3 | description: Configuration 4 | --- 5 | -------------------------------------------------------------------------------- /apps/docs/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /apps/docs/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evo-community/evolution-design/HEAD/apps/docs/src/assets/logo.png -------------------------------------------------------------------------------- /apps/docs/src/assets/houston.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evo-community/evolution-design/HEAD/apps/docs/src/assets/houston.webp -------------------------------------------------------------------------------- /apps/docs/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /apps/docs/src/assets/green-book.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evo-community/evolution-design/HEAD/apps/docs/src/assets/green-book.png -------------------------------------------------------------------------------- /examples/todo/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/docs/src/assets/medium-schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evo-community/evolution-design/HEAD/apps/docs/src/assets/medium-schema.png -------------------------------------------------------------------------------- /apps/docs/src/assets/small-schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evo-community/evolution-design/HEAD/apps/docs/src/assets/small-schema.png -------------------------------------------------------------------------------- /apps/docs/src/assets/evolution-meme.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evo-community/evolution-design/HEAD/apps/docs/src/assets/evolution-meme.jpg -------------------------------------------------------------------------------- /apps/docs/src/content/docs/en/why/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Problem 3 | description: Problem 4 | --- 5 | 6 | ### Problem 7 | 8 | Coming soon! 9 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/en/why/resolve.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Problem resolve 3 | description: Problem resolve 4 | --- 5 | 6 | ### Problem resolve 7 | 8 | Скоро! 9 | -------------------------------------------------------------------------------- /kit/kit/src/index.ts: -------------------------------------------------------------------------------- 1 | export { memoize } from "./memoize"; 2 | export { resolveImport } from "./resolve-import"; 3 | export { shallowEqual } from "./shallow-equal"; 4 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/model/domain.ts: -------------------------------------------------------------------------------- 1 | export interface Todo { 2 | id: number; 3 | completed: boolean; 4 | text: string; 5 | createdAt: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/linter/src/lint/index.ts: -------------------------------------------------------------------------------- 1 | export { applyAutofixes } from "./auto-fix"; 2 | export { lint } from "./lint"; 3 | export { formatPretty, reportPretty } from "../pretty-reporter"; 4 | -------------------------------------------------------------------------------- /packages/linter/src/pretty-reporter/types.ts: -------------------------------------------------------------------------------- 1 | import type { Diagnostic, Rule } from "@evod/core"; 2 | 3 | export interface AugmentedDiagnostic extends Diagnostic { 4 | rule: Rule; 5 | } 6 | -------------------------------------------------------------------------------- /kit/kit/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@evod/tsconfig/base.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Настройки для публикации пакетов 2 | @evod:registry=https://registry.npmjs.org/ 3 | registry=https://registry.npmjs.org/ 4 | access=public 5 | 6 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} 7 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@evod/tsconfig/base.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/linter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@evod/tsconfig/base.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/en/guide/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting started 3 | description: Getting started 4 | --- 5 | 6 | ### Installation 7 | 8 | ### Configuration file 9 | 10 | ### Simple example 11 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/en/guide/examples/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Example 3 | description: Example 4 | --- 5 | 6 | ### FSD 7 | 8 | Coming soon! 9 | 10 | ### Module architecture 11 | 12 | Coming soon! 13 | -------------------------------------------------------------------------------- /examples/todo/src/shared/lib/css.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /packages/linter/src/cli/commands/init.ts: -------------------------------------------------------------------------------- 1 | import { defineCommand } from "citty"; 2 | 3 | export default defineCommand({ 4 | meta: { 5 | name: "init", 6 | description: "Initialize a fresh project", 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/core/src/dependencies-map/types.ts: -------------------------------------------------------------------------------- 1 | import type { Path } from "../vfs/types"; 2 | 3 | export interface DependenciesMap { 4 | dependencies: Record>; 5 | dependencyFor: Record>; 6 | } 7 | -------------------------------------------------------------------------------- /apps/docs/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { docsSchema } from "@astrojs/starlight/schema"; 2 | import { defineCollection } from "astro:content"; 3 | 4 | export const collections = { 5 | docs: defineCollection({ schema: docsSchema() }), 6 | }; 7 | -------------------------------------------------------------------------------- /kit/kit/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | format: ["esm", "cjs"], 6 | dts: true, 7 | clean: true, 8 | external: ["typescript"], 9 | }); 10 | -------------------------------------------------------------------------------- /packages/core/src/vfs/create-root.ts: -------------------------------------------------------------------------------- 1 | import type { Path, VfsFolder } from "./types"; 2 | 3 | export function createVfsRoot(path: Path): VfsFolder { 4 | return { 5 | type: "folder", 6 | path, 7 | children: [], 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /kit/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@evod/tsconfig", 3 | "private": true, 4 | "version": "0.0.9", 5 | "description": "Shared TypeScript configuration for Evolution Design packages", 6 | "license": "MIT", 7 | "files": [ 8 | "*.json" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /apps/docs/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /packages/linter/src/pretty-reporter/pluralization.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns 's' if the amount is not 1. 3 | * 4 | * @example 5 | * `apple${s(1)}` // 'apple' 6 | * `apple${s(2)}` // 'apples' 7 | */ 8 | export function s(amount: number) { 9 | return amount === 1 ? "" : "s"; 10 | } 11 | -------------------------------------------------------------------------------- /apps/docs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts AS build 2 | WORKDIR /app 3 | COPY package*.json ./ 4 | RUN npm install 5 | COPY . . 6 | RUN npm run build 7 | 8 | FROM nginx:alpine AS runtime 9 | COPY ./nginx/nginx.conf /etc/nginx/nginx.conf 10 | COPY --from=build /app/dist /usr/share/nginx/html 11 | EXPOSE 8080 -------------------------------------------------------------------------------- /examples/todo/src/app/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /packages/linter/src/cli/commands/index.ts: -------------------------------------------------------------------------------- 1 | import type { CommandDef } from "citty"; 2 | 3 | const _rDefault = (r: any) => (r.default || r) as Promise; 4 | 5 | export const commands = { 6 | init: () => import("./init").then(_rDefault), 7 | lint: () => import("./lint").then(_rDefault), 8 | } as const; 9 | -------------------------------------------------------------------------------- /examples/todo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | } 7 | }, 8 | "references": [ 9 | { 10 | "path": "./tsconfig.app.json" 11 | }, 12 | { 13 | "path": "./tsconfig.node.json" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /examples/todo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import react from "@vitejs/plugin-react"; 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | "@": path.resolve(__dirname, "./src"), 10 | }, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /apps/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "privatePackages": false, 11 | "ignore": [] 12 | } 13 | -------------------------------------------------------------------------------- /kit/kit/src/memoize.ts: -------------------------------------------------------------------------------- 1 | export function memoize any>(fn: T): T { 2 | const cache = new WeakMap(); 3 | return function (this: any, arg: any) { 4 | if (cache.has(arg)) { 5 | return cache.get(arg); 6 | } 7 | const result = fn.call(this, arg); 8 | 9 | cache.set(arg, result); 10 | return result; 11 | } as T; 12 | } 13 | -------------------------------------------------------------------------------- /examples/todo/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/en/core-architectural-concepts/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Glossary 3 | description: Glossary 4 | --- 5 | 6 | ### Information 7 | 8 | A collection of knowledge about an object. 9 | 10 | ### Data 11 | 12 | Encoded information. 13 | 14 | ### State 15 | 16 | Data stored in the system. 17 | 18 | ### Cache 19 | 20 | A duplicate of the state to save computational resources. 21 | -------------------------------------------------------------------------------- /packages/linter/src/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { defineCommand, runMain } from "citty"; 3 | import { version } from "../version"; 4 | import { commands } from "./commands"; 5 | 6 | const main = defineCommand({ 7 | meta: { 8 | name: "edlint", 9 | version, 10 | description: "Evolution-design CLI", 11 | }, 12 | subCommands: commands, 13 | }); 14 | 15 | runMain(main); 16 | -------------------------------------------------------------------------------- /examples/todo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "strict": true, 8 | "noEmit": true, 9 | "allowSyntheticDefaultImports": true, 10 | "skipLibCheck": true 11 | }, 12 | "include": ["vite.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /examples/todo/src/app/App.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TodoList } from "@/features/todos"; 4 | 5 | export default function Component() { 6 | return ( 7 |
8 |
9 |

Todo List

10 |
11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /examples/todo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/core/src/abstraction-instance/get-abstraction-instance-label.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractionInstance } from "./types"; 2 | 3 | import { parse } from "node:path"; 4 | 5 | export function getAbstractionInstanceLabel(instance: AbstractionInstance) { 6 | const { name } = parse(instance.path); 7 | 8 | if (name === instance.abstraction.name) { 9 | return name; 10 | } 11 | 12 | return `${name} (${instance.abstraction.name})`; 13 | } 14 | -------------------------------------------------------------------------------- /packages/core/src/vfs/types.ts: -------------------------------------------------------------------------------- 1 | export type Path = string; 2 | 3 | export type VfsNode = VfsFile | VfsFolder; 4 | 5 | export interface VfsFile { 6 | type: "file"; 7 | path: Path; 8 | } 9 | 10 | export interface VfsFolder { 11 | type: "folder"; 12 | path: Path; 13 | children: Array; 14 | } 15 | 16 | export interface VfsEvents { 17 | type: "change" | "add" | "unlink" | "ready" | "unlinkDir" | "addDir"; 18 | vfs: VfsNode; 19 | } 20 | -------------------------------------------------------------------------------- /packages/core/src/config/define-config.ts: -------------------------------------------------------------------------------- 1 | import type { Abstraction } from "../abstraction"; 2 | 3 | export interface EvolutionConfig { 4 | /** Root abstraction */ 5 | root: Abstraction; 6 | /** Base url */ 7 | baseUrl?: string; 8 | /** Globs of files to check */ 9 | files?: Array; 10 | /** Globs of files to ignore */ 11 | ignores?: Array; 12 | } 13 | 14 | export function defineConfig(config: EvolutionConfig) { 15 | return config; 16 | } 17 | -------------------------------------------------------------------------------- /packages/core/src/vfs/get-flatten-files.ts: -------------------------------------------------------------------------------- 1 | import type { VfsFile, VfsNode } from "./types"; 2 | import { memoize } from "@evod/kit"; 3 | 4 | export const getFlattenFiles = memoize((node: VfsNode): VfsFile[] => { 5 | if (node.type === "file") { 6 | return [node]; 7 | } 8 | return node.children.reduce((acc, child) => { 9 | if (child.type === "file") { 10 | return [...acc, child]; 11 | } 12 | 13 | return [...acc, ...getFlattenFiles(child)]; 14 | }, [] as VfsFile[]); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/core/src/abstraction-instance/types.ts: -------------------------------------------------------------------------------- 1 | import type { Abstraction } from "../abstraction"; 2 | import type { Path } from "../vfs/types"; 3 | 4 | export interface AbstractionInstance { 5 | abstraction: Abstraction; 6 | children: AbstractionInstance[]; 7 | path: Path; 8 | childNodes: Path[]; 9 | } 10 | 11 | export interface AbstractionInstancePathSection { 12 | instance: AbstractionInstance; 13 | path: Path; 14 | } 15 | export type AbstractionInstancesPath = AbstractionInstancePathSection[]; 16 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/en/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Evolution Design 3 | description: Build your own front-end architecture! 4 | template: splash 5 | hero: 6 | tagline: Build your own frontend architecture! 7 | image: 8 | file: "../../../assets/green-book.png" 9 | actions: 10 | - text: Get Started 11 | link: guide 12 | icon: right-arrow 13 | - text: View on GitHub 14 | link: https://github.com/evo-community/evolution-design 15 | icon: external 16 | variant: minimal 17 | --- 18 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Evolution Design 3 | description: Построй свою фронтенд-архитектуру! 4 | template: splash 5 | hero: 6 | tagline: Создай свою фронтенд-архитектуру! 7 | image: 8 | file: "../../assets/green-book.png" 9 | actions: 10 | - text: Начало работы 11 | link: getting-started 12 | icon: right-arrow 13 | - text: Мы на GitHub 14 | link: https://github.com/evo-community/evolution-design 15 | icon: external 16 | variant: minimal 17 | --- 18 | -------------------------------------------------------------------------------- /apps/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@evod/docs", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.1", 6 | "scripts": { 7 | "dev": "astro dev", 8 | "start": "astro dev", 9 | "build": "astro check && astro build", 10 | "preview": "astro preview", 11 | "astro": "astro" 12 | }, 13 | "dependencies": { 14 | "@astrojs/check": "^0.9.4", 15 | "@astrojs/starlight": "^0.32.2", 16 | "astro": "^5.5.2", 17 | "sharp": "^0.32.5", 18 | "typescript": "^5.6.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /packages/core/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | format: ["esm", "cjs"], 6 | noExternal: ["@evod/kit"], 7 | 8 | dts: true, 9 | clean: true, 10 | sourcemap: true, 11 | external: [ 12 | "c12", 13 | "chokidar", 14 | "globby", 15 | "immer", 16 | "is-glob", 17 | "minimatch", 18 | "precinct", 19 | "rxjs", 20 | "tsconfck", 21 | "typescript", 22 | "zod", 23 | "zod-validation-error", 24 | ], 25 | }); 26 | -------------------------------------------------------------------------------- /examples/todo/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/shared/ui", 15 | "utils": "@/shared/lib/css", 16 | "ui": "@/shared/ui/shadcn", 17 | "lib": "@/shared/lib", 18 | "hooks": "@/shared/lib/react" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/src/vfs/copy-node.ts: -------------------------------------------------------------------------------- 1 | import type { VfsNode } from "./types"; 2 | 3 | export function copyNode( 4 | fsEntity: T, 5 | deep: boolean = false, 6 | ) { 7 | if (fsEntity.type === "folder") { 8 | const newChildren: Array = deep 9 | ? fsEntity.children.map((child) => 10 | child.type === "folder" ? copyNode(child, true) : child, 11 | ) 12 | : []; 13 | 14 | return { 15 | ...fsEntity, 16 | children: newChildren, 17 | }; 18 | } 19 | 20 | return { ...fsEntity }; 21 | } 22 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/vm/useAddTodoForm.ts: -------------------------------------------------------------------------------- 1 | import { useTodos } from "../model/useTodos"; 2 | 3 | export function useAddTodoForm() { 4 | const addTodo = useTodos((state) => state.addTodo); 5 | const handleAddTodo: React.KeyboardEventHandler = (e) => { 6 | if (e.target instanceof HTMLInputElement) { 7 | const trimmed = e.target.value.trim(); 8 | if (e.key === "Enter" && trimmed !== "") { 9 | addTodo(trimmed); 10 | e.target.value = ""; 11 | } 12 | } 13 | }; 14 | return { handleAddTodo }; 15 | } 16 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/model/useSort.ts: -------------------------------------------------------------------------------- 1 | import type { Todo } from "./domain"; 2 | import { useState } from "react"; 3 | 4 | export function useSort(todos: Todo[]) { 5 | const [sortBy, setSortBy] = useState("date"); 6 | 7 | const sortedTodos = todos.sort((a, b) => { 8 | if (sortBy === "date") { 9 | return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); 10 | } else { 11 | return a.text.localeCompare(b.text); 12 | } 13 | }); 14 | 15 | return { 16 | sortBy, 17 | setSortBy, 18 | sortedTodos, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/ui/AddTodoForm.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@/shared/ui/shadcn/input"; 2 | import { useAddTodoForm } from "../vm/useAddTodoForm"; 3 | 4 | export function AddTodoForm() { 5 | const { handleAddTodo } = useAddTodoForm(); 6 | return ( 7 |
8 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/linter/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: { 5 | index: "src/index.ts", 6 | cli: "src/cli/index.ts", 7 | }, 8 | format: ["esm", "cjs"], 9 | noExternal: ["@evod/kit"], 10 | dts: true, 11 | clean: true, 12 | 13 | external: [ 14 | "chalk", 15 | "citty", 16 | "figures", 17 | "prexit", 18 | "rxjs", 19 | "terminal-link", 20 | "zod", 21 | "zod-validation-error", 22 | ], 23 | esbuildOptions(options) { 24 | // Ensure Node.js built-ins are not bundled 25 | options.packages = "external"; 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | target 30 | 31 | 32 | # Debug 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | 37 | # Misc 38 | .DS_Store 39 | *.pem 40 | .idea 41 | # Local Netlify folder 42 | .netlify 43 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/ui/TodoListLayout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | export function TodoListLayout({ 4 | searchSlot, 5 | todoListSlot, 6 | sortSlot, 7 | addFormSlot, 8 | }: { 9 | searchSlot: ReactNode; 10 | sortSlot: ReactNode; 11 | todoListSlot: ReactNode; 12 | addFormSlot: ReactNode; 13 | }) { 14 | return ( 15 |
16 | {addFormSlot} 17 |
18 | {searchSlot} 19 | {sortSlot} 20 |
21 |
{todoListSlot}
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/core/src/vfs/get-nodes-record.ts: -------------------------------------------------------------------------------- 1 | import type { Path, VfsNode } from "./types"; 2 | import { memoize } from "@evod/kit"; 3 | 4 | export const getNodesRecord = memoize( 5 | (node: VfsNode): Record => { 6 | if (node.type === "file") { 7 | return { [node.path]: node }; 8 | } 9 | 10 | return node.children.reduce( 11 | (acc, child) => { 12 | if (child.type === "file") { 13 | return { ...acc, [child.path]: child }; 14 | } 15 | 16 | return { ...acc, [child.path]: child, ...getNodesRecord(child) }; 17 | }, 18 | {} as Record 19 | ); 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/model/useFilter.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | type FilterStrings> = { 4 | [KK in keyof T]: T[KK] extends string ? T[KK] : never; 5 | }; 6 | 7 | export function useFilter< 8 | T extends Record, 9 | K extends keyof FilterStrings, 10 | >(items: T[], key: NoInfer) { 11 | const [searchText, setSearchText] = useState(""); 12 | 13 | const filteredTodos = items.filter((item) => 14 | (item[key] as string).toLowerCase().includes(searchText.toLowerCase()), 15 | ); 16 | 17 | return { 18 | searchText, 19 | setSearchText, 20 | filteredTodos, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /kit/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "moduleDetection": "force", 5 | "module": "preserve", 6 | "moduleResolution": "bundler", 7 | "resolveJsonModule": true, 8 | "allowJs": true, 9 | "strict": true, 10 | "noImplicitOverride": true, 11 | "noEmit": true, 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": false, 14 | "forceConsistentCasingInFileNames": true, 15 | "isolatedModules": true, 16 | "verbatimModuleSyntax": true, 17 | "skipLibCheck": true, 18 | "declaration": true, 19 | "declarationMap": true, 20 | "sourceMap": true 21 | }, 22 | "exclude": ["dist", "node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/core/src/config/errors.ts: -------------------------------------------------------------------------------- 1 | import type { ZodError } from "zod"; 2 | import { relative } from "node:path"; 3 | import * as process from "node:process"; 4 | import { fromError } from "zod-validation-error"; 5 | 6 | export class ConfigurationNotFoundError extends Error { 7 | constructor() { 8 | super(`Configuration not found in ${process.cwd()}`); 9 | } 10 | } 11 | 12 | export class ConfigurationInvalidError extends Error { 13 | constructor( 14 | public error: ZodError, 15 | filepath: string, 16 | ) { 17 | super( 18 | fromError(error, { 19 | prefix: `Invalid configuration in ${relative(process.cwd(), filepath)}`, 20 | }).toString(), 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/core/src/rule/rule.ts: -------------------------------------------------------------------------------- 1 | import type { Rule, RuleName, RuleOptions } from "./types"; 2 | 3 | export function rule(name: RuleName): Rule; 4 | export function rule(options: RuleOptions): Rule; 5 | export function rule(config: RuleOptions | RuleName): Rule { 6 | const defaultCheck = () => ({ diagnostics: [] }); 7 | 8 | if (typeof config === "string") { 9 | return { 10 | name: config, 11 | severity: "error", 12 | descriptionUrl: undefined, 13 | check: defaultCheck, 14 | }; 15 | } 16 | 17 | return { 18 | name: config.name, 19 | severity: config.severity ?? "error", 20 | descriptionUrl: config.descriptionUrl, 21 | check: config.check ?? defaultCheck, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout Repo 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Node.js 22 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 22 21 | cache: 'npm' 22 | 23 | - name: Install Dependencies 24 | run: npm ci 25 | 26 | - name: Type Check 27 | run: npm run type-check 28 | 29 | - name: Lint 30 | run: npm run lint 31 | 32 | - name: Build 33 | run: npm run build 34 | 35 | - name: Test 36 | run: npm run test 37 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/ui/SearchTodos.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@/shared/ui/shadcn/input"; 2 | 3 | export function SearchTodos({ 4 | searchText, 5 | setSearchText, 6 | }: { 7 | searchText: string; 8 | setSearchText: (text: string) => void; 9 | }) { 10 | return ( 11 |
12 |
13 | setSearchText(e.target.value)} 18 | className="w-full rounded-md border border-input bg-background pl-10 pr-4 py-2 text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-primary" 19 | /> 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/docs/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | server { 9 | listen 8080; 10 | server_name _; 11 | 12 | root /usr/share/nginx/html; 13 | index index.html index.htm; 14 | include /etc/nginx/mime.types; 15 | 16 | gzip on; 17 | gzip_min_length 1000; 18 | gzip_proxied expired no-cache no-store private auth; 19 | gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; 20 | 21 | error_page 404 /404.html; 22 | location = /404.html { 23 | root /usr/share/nginx/html; 24 | internal; 25 | } 26 | 27 | location / { 28 | try_files $uri $uri/index.html =404; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "inputs": ["src/**", "package.json", "tsconfig.json", "tsup.config.ts"], 7 | "outputs": ["dist/**"] 8 | }, 9 | "dev": { 10 | "cache": false, 11 | "persistent": true 12 | }, 13 | "lint": { 14 | "dependsOn": ["^build"] 15 | }, 16 | "test": { 17 | "dependsOn": ["^build"], 18 | "inputs": ["src/**", "test/**", "package.json", "tsconfig.json"] 19 | }, 20 | "type-check": { 21 | "dependsOn": ["^build"], 22 | "inputs": ["src/**", "package.json", "tsconfig.json"] 23 | }, 24 | "clean": { 25 | "cache": false 26 | }, 27 | "publish": { 28 | "dependsOn": ["^build"], 29 | "cache": false 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/todo/src/shared/ui/shadcn/label.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib/css"; 2 | import * as LabelPrimitive from "@radix-ui/react-label"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import * as React from "react"; 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /kit/kit/src/shallow-equal.ts: -------------------------------------------------------------------------------- 1 | const hasOwn = Object.prototype.hasOwnProperty; 2 | const is = Object.is; 3 | 4 | export function shallowEqual(objectA: any, objectB: any): boolean { 5 | if (objectA === objectB) { 6 | return true; 7 | } 8 | if (typeof objectA !== "object" || objectA === null) { 9 | return false; 10 | } 11 | if (typeof objectB !== "object" || objectB === null) { 12 | return false; 13 | } 14 | 15 | const keysA = Object.keys(objectA); 16 | const keysB = Object.keys(objectB); 17 | 18 | if (keysA.length !== keysB.length) { 19 | return false; 20 | } 21 | 22 | const isEqual = is; 23 | 24 | for (let i = 0; i < keysA.length; i++) { 25 | const key = keysA[i]; 26 | if (!hasOwn.call(objectB, key) || !isEqual(objectA[key], objectB[key])) { 27 | return false; 28 | } 29 | } 30 | 31 | return true; 32 | } 33 | -------------------------------------------------------------------------------- /kit/kit/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@evod/kit", 3 | "private": true, 4 | "version": "0.0.9", 5 | "type": "module", 6 | "description": "Utility kit for Evolution Design", 7 | "license": "MIT", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/index.d.ts", 11 | "import": "./dist/index.js", 12 | "require": "./dist/index.cjs" 13 | } 14 | }, 15 | "main": "./dist/index.cjs", 16 | "module": "./dist/index.js", 17 | "types": "./dist/index.d.ts", 18 | "files": [ 19 | "dist" 20 | ], 21 | "scripts": { 22 | "build": "tsup", 23 | "dev": "tsup --watch", 24 | "clean": "rimraf dist", 25 | "type-check": "tsc --noEmit" 26 | }, 27 | "devDependencies": { 28 | "typescript": ">=5", 29 | "tsup": "latest", 30 | "rimraf": "latest" 31 | }, 32 | "peerDependencies": { 33 | "typescript": ">=5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Evolution Design 2 | 3 | Architecture linter for TypeScript projects. 4 | 5 | ## Quick Start 6 | 7 | ```bash 8 | npm run build 9 | 10 | cd examples/todo 11 | npm i 12 | npm run lint:architect 13 | ``` 14 | 15 | ## Development 16 | 17 | - 📚 [Publishing Guide](./PUBLISHING.md) - Detailed publishing documentation 18 | - 🚀 [Release Guide](./RELEASE.md) - Quick release instructions (automatic publishing!) 19 | - 🤖 **Automatic publishing** - Just create changeset and merge PR! 20 | 21 | ## Packages 22 | 23 | - `@evod/core` - Core functionality for architecture linting 24 | - `edlint` - CLI tool for running the linter 25 | - `@evod/kit` - Utility functions (private) 26 | 27 | ## Terms 28 | 29 | `vfs` - virtual file system. Used to speed up validation of files. 30 | 31 | ## Acknowledgments 32 | 33 | Many modules of this package are inspired by [steiger](https://github.com/feature-sliced/steiger) 34 | -------------------------------------------------------------------------------- /examples/todo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "jsx": "react-jsx", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "moduleDetection": "force", 9 | "useDefineForClassFields": true, 10 | "baseUrl": ".", 11 | "module": "ESNext", 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "paths": { 15 | "@/*": ["./src/*"], 16 | "evolution-design": ["../../src/core"] 17 | }, 18 | "resolveJsonModule": true, 19 | "allowImportingTsExtensions": true, 20 | /* Linting */ 21 | "strict": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noEmit": true, 26 | "isolatedModules": true, 27 | "skipLibCheck": true 28 | }, 29 | "include": ["src"] 30 | } 31 | -------------------------------------------------------------------------------- /examples/todo/src/shared/ui/shadcn/input.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib/css"; 2 | 3 | import * as React from "react"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | }, 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /packages/core/src/config/schema.ts: -------------------------------------------------------------------------------- 1 | import type { Abstraction } from "../abstraction"; 2 | import type { Rule } from "../rule/types"; 3 | import type { EvolutionConfig } from "./define-config"; 4 | import { z, type ZodType } from "zod"; 5 | 6 | type TypeToZod = ZodType; 7 | 8 | const RuleSchema: TypeToZod = z.object({ 9 | name: z.string(), 10 | check: z.custom(), 11 | severity: z.enum(["off", "warn", "error"]), 12 | descriptionUrl: z.string().optional(), 13 | }); 14 | 15 | const AbstractionSchema: TypeToZod = z.object({ 16 | name: z.string(), 17 | children: z.record(z.lazy(() => AbstractionSchema)), 18 | rules: z.array(RuleSchema), 19 | fractal: z.string().optional(), 20 | fileTemplate: z.custom(), 21 | }); 22 | 23 | export const EvolutionConfigSchema: TypeToZod = z.object({ 24 | root: AbstractionSchema, 25 | baseUrl: z.string().optional(), 26 | files: z.array(z.string()).optional(), 27 | ignores: z.array(z.string()).optional(), 28 | }); 29 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { abstraction, type Abstraction } from "./abstraction"; 2 | 3 | export { getAbstractionInstanceLabel } from "./abstraction-instance/get-abstraction-instance-label"; 4 | export { parseAbstractionInstance } from "./abstraction-instance/parse-abstraction-instance"; 5 | export type { AbstractionInstance } from "./abstraction-instance/types"; 6 | 7 | export { defineConfig, type EvolutionConfig } from "./config/define-config"; 8 | export { type ConfigResult, watchConfig } from "./config/load"; 9 | 10 | export { parseDependenciesMap } from "./dependencies-map/parse-dependencies-map"; 11 | export type { DependenciesMap } from "./dependencies-map/types"; 12 | 13 | export { rule } from "./rule/rule"; 14 | export type { Diagnostic, Rule, RuleContext } from "./rule/types"; 15 | 16 | export { getFlattenFiles } from "./vfs/get-flatten-files"; 17 | export { getNodesRecord } from "./vfs/get-nodes-record"; 18 | export type { Path, VfsEvents, VfsFile, VfsFolder, VfsNode } from "./vfs/types"; 19 | export { watchFs } from "./vfs/watch-fs"; 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Create Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node.js 22 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 22 22 | cache: 'npm' 23 | 24 | - name: Install Dependencies 25 | run: npm ci 26 | 27 | - name: Build packages 28 | run: npm run build 29 | 30 | - name: Create Release Pull Request or Publish to npm 31 | id: changesets 32 | uses: changesets/action@v1 33 | with: 34 | publish: npm run release 35 | title: "Release: Version Packages" 36 | commit: "chore: release packages" 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | -------------------------------------------------------------------------------- /apps/docs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /packages/linter/src/lint/lint.ts: -------------------------------------------------------------------------------- 1 | import type { AugmentedDiagnostic } from "../pretty-reporter"; 2 | import { dirname, resolve } from "node:path"; 3 | import { 4 | type EvolutionConfig, 5 | parseAbstractionInstance, 6 | parseDependenciesMap, 7 | watchFs, 8 | } from "@evod/core"; 9 | import { debounceTime, type Observable, switchMap } from "rxjs"; 10 | import { runRules } from "./run-rules"; 11 | 12 | export interface LinterConfig { 13 | watch?: boolean; 14 | config: EvolutionConfig; 15 | configPath: string; 16 | } 17 | 18 | export function lint({ 19 | watch, 20 | config, 21 | configPath, 22 | }: LinterConfig): Observable { 23 | const rootPath = resolve(dirname(configPath), config.baseUrl ?? "./"); 24 | 25 | const parseNode = parseAbstractionInstance(config.root); 26 | return watchFs(rootPath, { onlyReady: !watch }).pipe( 27 | debounceTime(500), 28 | switchMap(async ({ vfs }) => ({ 29 | root: vfs, 30 | instance: parseNode(vfs), 31 | dependenciesMap: await parseDependenciesMap(vfs), 32 | })), 33 | switchMap(runRules) 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /packages/linter/src/lint/run-rules.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractionInstance, Rule, RuleContext } from "@evod/core"; 2 | import type { AugmentedDiagnostic } from "../pretty-reporter"; 3 | 4 | export async function runRules({ 5 | root, 6 | instance, 7 | dependenciesMap, 8 | }: RuleContext) { 9 | const ruleDiagnostics = 10 | (currentInstance: AbstractionInstance) => async (rule: Rule) => { 11 | if (rule.severity === "off") { 12 | return []; 13 | } 14 | const { diagnostics } = await rule.check({ 15 | root, 16 | instance: currentInstance, 17 | dependenciesMap, 18 | }); 19 | return diagnostics.map((d) => ({ ...d, rule })); 20 | }; 21 | 22 | const runAbstractionRules = ( 23 | currentInstance: AbstractionInstance 24 | ): Promise[] => { 25 | return currentInstance.abstraction.rules 26 | .map(ruleDiagnostics(currentInstance)) 27 | .concat(...currentInstance.children.flatMap(runAbstractionRules)); 28 | }; 29 | 30 | return await Promise.all(runAbstractionRules(instance)).then((r) => r.flat()); 31 | } 32 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/ui/TodoList.tsx: -------------------------------------------------------------------------------- 1 | import { useFilter } from "../model/useFilter.ts"; 2 | import { useSort } from "../model/useSort.ts"; 3 | import { useTodos } from "../model/useTodos.ts"; 4 | import { TodoItem } from "../ui/TodoItem.tsx"; 5 | import { AddTodoForm } from "./AddTodoForm.tsx"; 6 | import { SearchTodos } from "./SearchTodos.tsx"; 7 | import { SortTodos } from "./SortTodos.tsx"; 8 | import { TodoListLayout } from "./TodoListLayout.tsx"; 9 | 10 | export function TodoList() { 11 | const { todos } = useTodos(); 12 | const { sortedTodos, sortBy, setSortBy } = useSort(todos); 13 | const { filteredTodos, searchText, setSearchText } = useFilter( 14 | sortedTodos, 15 | "text", 16 | ); 17 | 18 | return ( 19 | 22 | } 23 | sortSlot={} 24 | addFormSlot={} 25 | todoListSlot={filteredTodos.map((todo) => ( 26 | 27 | ))} 28 | /> 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/core/src/vfs/remove-node.ts: -------------------------------------------------------------------------------- 1 | import type { Path, VfsFolder } from "./types"; 2 | import { basename, relative, sep } from "node:path"; 3 | import { produce } from "immer"; 4 | 5 | export function removeNode(tree: VfsFolder, removedNodePath: Path) { 6 | const rootPath = tree.path; 7 | return produce(tree, (draft) => { 8 | const pathSegments = relative(rootPath, removedNodePath).split(sep); 9 | let currentFolder = draft; 10 | 11 | for (const pathSegment of pathSegments.slice(0, -1)) { 12 | const existingChild = currentFolder.children.find( 13 | (child) => 14 | child.type === "folder" && basename(child.path) === pathSegment, 15 | ) as VfsFolder | undefined; 16 | 17 | if (existingChild === undefined) { 18 | return tree; 19 | } else { 20 | currentFolder = existingChild; 21 | } 22 | } 23 | 24 | const removedNodeIndex = currentFolder.children.findIndex( 25 | (child) => child.path === removedNodePath, 26 | ); 27 | 28 | if (removedNodeIndex === -1) { 29 | return tree; 30 | } 31 | 32 | currentFolder.children.splice(removedNodeIndex, 1); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /examples/todo/src/shared/ui/shadcn/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib/css"; 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 3 | import { CheckIcon } from "@radix-ui/react-icons"; 4 | 5 | import * as React from "react"; 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )); 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 27 | 28 | export { Checkbox }; 29 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/ui/SortTodos.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/shared/ui/shadcn/button"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuRadioGroup, 6 | DropdownMenuRadioItem, 7 | DropdownMenuTrigger, 8 | } from "@/shared/ui/shadcn/dropdown-menu"; 9 | import { ArrowUpDownIcon } from "lucide-react"; 10 | 11 | export function SortTodos({ 12 | sortBy, 13 | setSortBy, 14 | }: { 15 | sortBy: string; 16 | setSortBy: (sort: string) => void; 17 | }) { 18 | return ( 19 | 20 | 21 | 25 | 26 | 27 | 28 | Date Added 29 | 30 | Alphabetical 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /packages/core/src/vfs/add-file.ts: -------------------------------------------------------------------------------- 1 | import type { Path, VfsFolder } from "./types"; 2 | import { basename, join, relative, sep } from "node:path"; 3 | import { produce } from "immer"; 4 | 5 | export function addFile(tree: VfsFolder, newFilePath: Path) { 6 | const rootPath = tree.path; 7 | return produce(tree, (draft) => { 8 | const pathSegments = relative(rootPath, newFilePath).split(sep); 9 | let currentFolder = draft; 10 | 11 | for (const pathSegment of pathSegments.slice(0, -1)) { 12 | const existingChild = currentFolder.children.find( 13 | (child) => 14 | child.type === "folder" && basename(child.path) === pathSegment, 15 | ) as VfsFolder | undefined; 16 | 17 | if (existingChild === undefined) { 18 | currentFolder.children.push({ 19 | type: "folder", 20 | path: join(currentFolder.path, pathSegment), 21 | children: [], 22 | }); 23 | currentFolder = currentFolder.children[ 24 | currentFolder.children.length - 1 25 | ] as VfsFolder; 26 | } else { 27 | currentFolder = existingChild; 28 | } 29 | } 30 | 31 | currentFolder.children.push({ type: "file", path: newFilePath }); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /packages/core/src/vfs/add-directory.ts: -------------------------------------------------------------------------------- 1 | import type { Path, VfsFolder } from "./types"; 2 | import { basename, join, relative, sep } from "node:path"; 3 | import { produce } from "immer"; 4 | 5 | export function addDirectory(tree: VfsFolder, newDirectoryPath: Path) { 6 | const rootPath = tree.path; 7 | return produce(tree, (draft) => { 8 | const pathSegments = relative(rootPath, newDirectoryPath).split(sep); 9 | let currentFolder = draft; 10 | 11 | for (const pathSegment of pathSegments.slice(0, -1)) { 12 | const existingChild = currentFolder.children.find( 13 | (child) => 14 | child.type === "folder" && basename(child.path) === pathSegment, 15 | ) as VfsFolder | undefined; 16 | 17 | if (existingChild === undefined) { 18 | currentFolder.children.push({ 19 | type: "folder", 20 | path: join(currentFolder.path, pathSegment), 21 | children: [], 22 | }); 23 | currentFolder = currentFolder.children[ 24 | currentFolder.children.length - 1 25 | ] as VfsFolder; 26 | } else { 27 | currentFolder = existingChild; 28 | } 29 | } 30 | 31 | currentFolder.children.push({ 32 | type: "folder", 33 | path: newDirectoryPath, 34 | children: [], 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /apps/docs/src/styles/custom.css: -------------------------------------------------------------------------------- 1 | /* Dark mode colors. */ 2 | :root { 3 | --sl-color-accent-low: #134e4a; /* teal-900 */ 4 | --sl-color-accent: #14b8a6; /* teal-500 (базовый) */ 5 | --sl-color-accent-high: oklch(98.4% 0.014 180.72); /* teal-300 */ 6 | --sl-color-white: #ffffff; 7 | --sl-color-gray-1: #f1f5f9; /* slate-50 */ 8 | --sl-color-gray-2: #cbd5e1; /* slate-200 */ 9 | --sl-color-gray-3: #94a3b8; /* slate-400 */ 10 | --sl-color-gray-4: #64748b; /* slate-500 */ 11 | --sl-color-gray-5: #334155; /* slate-700 */ 12 | --sl-color-gray-6: #1e293b; /* slate-800 */ 13 | --sl-color-black: #0f172a; /* slate-900 */ 14 | } 15 | /* Light mode colors. */ 16 | :root[data-theme="light"] { 17 | --sl-color-accent-low: #99f6e4; /* teal-200 */ 18 | --sl-color-accent: #134e4a; /* teal-900 */ 19 | --sl-color-accent-high: #134e4a; /* teal-900 */ 20 | --sl-color-white: #0f172a; /* slate-900 */ 21 | --sl-color-gray-1: #334155; /* slate-700 (было 1e293b) */ 22 | --sl-color-gray-2: #475569; /* slate-600 (было 334155) */ 23 | --sl-color-gray-3: #64748b; /* slate-500 */ 24 | --sl-color-gray-4: #94a3b8; /* slate-400 */ 25 | --sl-color-gray-5: #cbd5e1; /* slate-200 */ 26 | --sl-color-gray-6: #e2e8f0; /* slate-100 (было f1f5f9) */ 27 | --sl-color-gray-7: #f1f5f9; /* slate-50 (было f8fafc) */ 28 | --sl-color-black: #ffffffe7; 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@evod/monorepo", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.9", 6 | "description": "Architecture linter for TypeScript projects", 7 | "license": "MIT", 8 | "engines": { 9 | "node": ">= 22" 10 | }, 11 | "packageManager": "npm@10.8.3", 12 | "scripts": { 13 | "build": "turbo build", 14 | "dev": "turbo dev", 15 | "lint": "turbo lint", 16 | "test": "turbo test", 17 | "type-check": "turbo type-check", 18 | "clean": "turbo clean", 19 | "changeset": "changeset", 20 | "changeset:version": "changeset version", 21 | "changeset:publish": "changeset publish", 22 | "version-packages": "changeset version", 23 | "release": "npm run build && changeset publish", 24 | "check-packages": "node scripts/check-packages.js", 25 | "prepare-release": "npm run build && npm run check-packages" 26 | }, 27 | "devDependencies": { 28 | "@changesets/cli": "^2.27.9", 29 | "@types/is-glob": "^4.0.4", 30 | "@types/node": "22.15.32", 31 | "eslint": "^9.10.0", 32 | "rimraf": "^6.0.1", 33 | "tsup": "^8.3.5", 34 | "tsx": "^4.19.0", 35 | "turbo": "^2.3.0", 36 | "typescript": "5.8.3", 37 | "vitest": "3.2.4" 38 | }, 39 | "workspaces": [ 40 | "apps/*", 41 | "packages/*", 42 | "kit/*" 43 | ], 44 | "dependencies": { 45 | "prettier": "^3.5.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/todo/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: "latest", 21 | sourceType: "module", 22 | project: ["./tsconfig.json", "./tsconfig.node.json"], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | }; 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /examples/todo/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/todo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo", 3 | "type": "module", 4 | "version": "0.0.9", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "lint:fix": "eslint . --fix", 11 | "preview": "vite preview", 12 | "lint:architect:watch": "evo lint --watch", 13 | "lint:architect": "evo lint" 14 | }, 15 | "dependencies": { 16 | "@radix-ui/react-checkbox": "^1.1.1", 17 | "@radix-ui/react-dropdown-menu": "^2.1.1", 18 | "@radix-ui/react-icons": "^1.3.0", 19 | "@radix-ui/react-label": "^2.1.0", 20 | "@radix-ui/react-slot": "^1.1.0", 21 | "chalk": "^5.3.0", 22 | "class-variance-authority": "^0.7.0", 23 | "clsx": "^2.1.1", 24 | "evolution-design": "file:../../", 25 | "lucide-react": "^0.399.0", 26 | "react": "^18.3.1", 27 | "react-dom": "^18.3.1", 28 | "tailwind-merge": "^2.5.2", 29 | "tailwindcss-animate": "^1.0.7", 30 | "zustand": "^4.5.5" 31 | }, 32 | "devDependencies": { 33 | "@antfu/eslint-config": "^3.3.2", 34 | "@types/node": "^20.14.9", 35 | "@types/react": "^18.3.3", 36 | "@types/react-dom": "^18.3.0", 37 | "@vitejs/plugin-react": "^4.3.1", 38 | "autoprefixer": "^10.4.19", 39 | "eslint": "^9.10.0", 40 | "postcss": "^8.4.39", 41 | "prettier": "^3.3.2", 42 | "tailwindcss": "^3.4.4", 43 | "tsx": "^4.19.0", 44 | "typescript": "^5.2.2", 45 | "vite": "^5.3.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/ui/TodoItem.tsx: -------------------------------------------------------------------------------- 1 | import type { Todo } from "../model/domain"; 2 | import { Button } from "@/shared/ui/shadcn/button.tsx"; 3 | import { Checkbox } from "@/shared/ui/shadcn/checkbox.tsx"; 4 | import { TrashIcon } from "lucide-react"; 5 | import { memo } from "react"; 6 | import { useTodos } from "../model/useTodos"; 7 | 8 | export const TodoItem = memo(({ todo }: { todo: Todo }) => { 9 | const toggleTodo = useTodos((state) => state.toggleTodo); 10 | const deleteTodo = useTodos((state) => state.deleteTodo); 11 | 12 | const handleToggleTodo = (id: number) => { 13 | toggleTodo(id); 14 | }; 15 | 16 | const handleDeleteTodo = (id: number) => { 17 | deleteTodo(id); 18 | }; 19 | 20 | return ( 21 |
22 |
23 | handleToggleTodo(todo.id)} 27 | /> 28 | 36 |
37 | 44 |
45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/linter/src/pretty-reporter/format-single-diagnostic.ts: -------------------------------------------------------------------------------- 1 | import type { AugmentedDiagnostic } from "./types"; 2 | import { relative } from "node:path"; 3 | import chalk from "chalk"; 4 | import figures from "figures"; 5 | import terminalLink from "terminal-link"; 6 | 7 | export function formatSingleDiagnostic( 8 | d: AugmentedDiagnostic, 9 | cwd: string, 10 | ): string { 11 | const x = 12 | d.rule.severity === "error" 13 | ? chalk.red(figures.cross) 14 | : chalk.yellow(figures.warning); 15 | const s = chalk.reset(figures.lineDownRight); 16 | const bar = chalk.reset(figures.lineVertical); 17 | const e = chalk.reset(figures.lineUpRight); 18 | const message = chalk.reset(d.message); 19 | const autofixable = 20 | d.fixes !== undefined && d.fixes.length > 0 21 | ? chalk.green(`${figures.tick} Auto-fixable`) 22 | : null; 23 | const location = chalk.gray(formatLocation(d.location, cwd)); 24 | const ruleName = d.rule.descriptionUrl 25 | ? chalk.blue(terminalLink(d.rule.name, d.rule.descriptionUrl!)) 26 | : d.rule.name; 27 | 28 | return ` 29 | ${s} ${location} 30 | ${x} ${message} 31 | ${autofixable ? `${autofixable}\n${bar}` : bar} 32 | ${e} ${ruleName} 33 | `.trim(); 34 | } 35 | 36 | function formatLocation( 37 | location: AugmentedDiagnostic["location"], 38 | cwd: string, 39 | ) { 40 | let path = relative(cwd, location.path); 41 | if (location.line !== undefined) { 42 | path += `:${location.line}`; 43 | 44 | if (location.column !== undefined) { 45 | path += `:${location.column}`; 46 | } 47 | } 48 | 49 | return path; 50 | } 51 | -------------------------------------------------------------------------------- /scripts/check-packages.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { readFileSync } from "fs"; 4 | import { join } from "path"; 5 | import { fileURLToPath } from "url"; 6 | import { dirname } from "path"; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = dirname(__filename); 10 | 11 | const packagesDir = join(__dirname, "..", "packages"); 12 | const packages = ["core", "linter"]; 13 | 14 | console.log("🔍 Проверяю готовность пакетов к публикации...\n"); 15 | 16 | for (const pkg of packages) { 17 | const packageJsonPath = join(packagesDir, pkg, "package.json"); 18 | const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); 19 | 20 | console.log(`📦 ${packageJson.name} v${packageJson.version}`); 21 | 22 | // Проверяем наличие необходимых полей 23 | const requiredFields = [ 24 | "name", 25 | "version", 26 | "description", 27 | "license", 28 | "publishConfig", 29 | ]; 30 | const missingFields = requiredFields.filter((field) => !packageJson[field]); 31 | 32 | if (missingFields.length > 0) { 33 | console.log(`❌ Отсутствуют поля: ${missingFields.join(", ")}`); 34 | } else { 35 | console.log("✅ Все необходимые поля присутствуют"); 36 | } 37 | 38 | // Проверяем наличие dist папки 39 | try { 40 | const distPath = join(packagesDir, pkg, "dist"); 41 | const fs = await import("fs"); 42 | await fs.promises.access(distPath); 43 | console.log("✅ Папка dist существует"); 44 | } catch { 45 | console.log("❌ Папка dist не найдена - запустите npm run build"); 46 | } 47 | 48 | console.log(""); 49 | } 50 | 51 | console.log("✨ Проверка завершена!"); 52 | -------------------------------------------------------------------------------- /packages/core/src/dependencies-map/parse-dependencies-map.ts: -------------------------------------------------------------------------------- 1 | import type { VfsNode } from "../vfs/types"; 2 | import type { DependenciesMap } from "./types"; 3 | import * as fs from "node:fs"; 4 | import { resolveImport } from "@evod/kit"; 5 | import precinct from "precinct"; 6 | import { parse as parseNearestTsConfig } from "tsconfck"; 7 | import { getFlattenFiles } from "../vfs/get-flatten-files"; 8 | 9 | const { paperwork } = precinct; 10 | 11 | export async function parseDependenciesMap(vfs: VfsNode) { 12 | const dependenciesMap: DependenciesMap = { 13 | dependencies: {}, 14 | dependencyFor: {}, 15 | }; 16 | 17 | const basePath = vfs.path; 18 | const { tsconfig } = await parseNearestTsConfig(basePath); 19 | 20 | const files = getFlattenFiles(vfs); 21 | 22 | for (const file of files) { 23 | const dependencies = paperwork(file.path, { 24 | includeCore: false, 25 | fileSystem: fs, 26 | }); 27 | const resolvedDependencies = dependencies 28 | .map((dependency) => 29 | resolveImport( 30 | dependency, 31 | file.path, 32 | tsconfig?.compilerOptions ?? {}, 33 | fs.existsSync, 34 | fs.existsSync 35 | ) 36 | ) 37 | .filter((dependency) => dependency !== null); 38 | 39 | dependenciesMap.dependencies[file.path] = new Set(resolvedDependencies); 40 | 41 | for (const dependency of resolvedDependencies) { 42 | if (!dependenciesMap.dependencyFor[dependency]) { 43 | dependenciesMap.dependencyFor[dependency] = new Set(); 44 | } 45 | dependenciesMap.dependencyFor[dependency].add(file.path); 46 | } 47 | } 48 | 49 | return dependenciesMap; 50 | } 51 | -------------------------------------------------------------------------------- /packages/core/src/rule/types.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractionInstance } from "../abstraction-instance/types"; 2 | import type { DependenciesMap } from "../dependencies-map/types"; 3 | import type { Path, VfsNode } from "../vfs/types"; 4 | 5 | export type RuleName = string; 6 | 7 | export interface Rule { 8 | name: RuleName; 9 | severity: Severity; 10 | descriptionUrl?: string; 11 | check: (context: RuleContext) => RuleResult | Promise; 12 | } 13 | 14 | export interface RuleOptions { 15 | name: RuleName; 16 | severity?: Severity; 17 | descriptionUrl?: string; 18 | check?: (context: RuleContext) => RuleResult | Promise; 19 | } 20 | 21 | export interface RuleContext { 22 | root: VfsNode; 23 | instance: AbstractionInstance; 24 | dependenciesMap: DependenciesMap; 25 | } 26 | 27 | export type Fix = 28 | | { 29 | type: "rename"; 30 | path: Path; 31 | newName: string; 32 | } 33 | | { 34 | type: "create-file"; 35 | path: Path; 36 | content: string; 37 | } 38 | | { 39 | type: "create-folder"; 40 | path: Path; 41 | } 42 | | { 43 | type: "delete"; 44 | path: Path; 45 | } 46 | | { 47 | type: "modify-file"; 48 | path: Path; 49 | content: string; 50 | }; 51 | 52 | export type Severity = "off" | "warn" | "error"; 53 | 54 | export interface RuleResult { 55 | diagnostics: Array; 56 | } 57 | 58 | export interface Diagnostic { 59 | message: string; 60 | fixes?: Array; 61 | location: { 62 | /** Absolute path to a folder or a file that contains the issue. */ 63 | path: Path; 64 | line?: number; 65 | column?: number; 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /packages/linter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edlint", 3 | "version": "0.0.9", 4 | "type": "module", 5 | "description": "Linter engine for Evolution Design architecture", 6 | "license": "MIT", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/evolution-design/evolution-design.git", 13 | "directory": "packages/linter" 14 | }, 15 | "homepage": "https://github.com/evolution-design/evolution-design#readme", 16 | "bugs": { 17 | "url": "https://github.com/evolution-design/evolution-design/issues" 18 | }, 19 | "keywords": [ 20 | "linter", 21 | "cli", 22 | "typescript", 23 | "evolution-design", 24 | "architecture", 25 | "code-quality" 26 | ], 27 | "bin": { 28 | "edlint": "./dist/cli.js" 29 | }, 30 | "exports": { 31 | ".": { 32 | "types": "./dist/index.d.ts", 33 | "import": "./dist/index.js", 34 | "require": "./dist/index.cjs" 35 | } 36 | }, 37 | "main": "./dist/index.cjs", 38 | "module": "./dist/index.js", 39 | "types": "./dist/index.d.ts", 40 | "files": [ 41 | "dist" 42 | ], 43 | "scripts": { 44 | "build": "tsup", 45 | "dev": "tsup --watch", 46 | "clean": "rimraf dist", 47 | "type-check": "tsc --noEmit" 48 | }, 49 | "dependencies": { 50 | "@evod/core": "*", 51 | "chalk": "^5.3.0", 52 | "citty": "^0.1.6", 53 | "figures": "^6.1.0", 54 | "prexit": "^2.3.0", 55 | "rxjs": "^7.8.1", 56 | "terminal-link": "^3.0.0", 57 | "zod": "^3.23.8", 58 | "zod-validation-error": "^3.3.1" 59 | }, 60 | "devDependencies": { 61 | "rimraf": "latest", 62 | "tsup": "latest", 63 | "typescript": "^5.8.3" 64 | }, 65 | "peerDependencies": { 66 | "typescript": ">=5" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /examples/todo/src/features/todos/model/useTodos.ts: -------------------------------------------------------------------------------- 1 | import type { Todo } from "./domain"; 2 | import { create } from "zustand"; 3 | 4 | const defaultState = [ 5 | { 6 | id: 1, 7 | text: "Finish project proposal", 8 | completed: false, 9 | createdAt: "2023-06-01", 10 | }, 11 | { 12 | id: 2, 13 | text: "Schedule meeting with client", 14 | completed: false, 15 | createdAt: "2023-06-02", 16 | }, 17 | { id: 3, text: "Buy groceries", completed: false, createdAt: "2023-06-03" }, 18 | { 19 | id: 4, 20 | text: "Clean the house", 21 | completed: false, 22 | createdAt: "2023-06-04", 23 | }, 24 | { id: 5, text: "Call mom", completed: false, createdAt: "2023-06-05" }, 25 | ]; 26 | 27 | interface TodoState { 28 | todos: Todo[]; 29 | addTodo: (text: string) => void; 30 | toggleTodo: (todoId: Todo["id"]) => void; 31 | deleteTodo: (todoId: Todo["id"]) => void; 32 | } 33 | 34 | export const useTodos = create()((set) => ({ 35 | todos: defaultState, 36 | addTodo: (text: string) => { 37 | set((prevState) => ({ 38 | todos: [ 39 | ...prevState.todos, 40 | { 41 | id: prevState.todos.length + 1, 42 | text, 43 | completed: false, 44 | createdAt: new Date().toISOString().slice(0, 10), 45 | }, 46 | ], 47 | })); 48 | }, 49 | toggleTodo: (todoId: Todo["id"]) => { 50 | set((prevState) => ({ 51 | todos: prevState.todos.map((todo) => { 52 | if (todo.id === todoId) { 53 | return { ...todo, completed: !todo.completed }; 54 | } 55 | return todo; 56 | }), 57 | })); 58 | }, 59 | deleteTodo: (todoId: Todo["id"]) => { 60 | set((prevState) => ({ 61 | todos: prevState.todos.filter((todo) => { 62 | return todo.id !== todoId; 63 | }), 64 | })); 65 | }, 66 | })); 67 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@evod/core", 3 | "version": "0.0.9", 4 | "type": "module", 5 | "description": "Core functionality for Evolution Design architecture linter", 6 | "license": "MIT", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/evolution-design/evolution-design.git", 13 | "directory": "packages/core" 14 | }, 15 | "homepage": "https://github.com/evolution-design/evolution-design#readme", 16 | "bugs": { 17 | "url": "https://github.com/evolution-design/evolution-design/issues" 18 | }, 19 | "keywords": [ 20 | "architecture", 21 | "linter", 22 | "typescript", 23 | "evolution-design", 24 | "code-quality" 25 | ], 26 | "exports": { 27 | ".": { 28 | "types": "./dist/index.d.ts", 29 | "import": "./dist/index.js", 30 | "require": "./dist/index.cjs" 31 | } 32 | }, 33 | "main": "./dist/index.cjs", 34 | "module": "./dist/index.js", 35 | "types": "./dist/index.d.ts", 36 | "files": [ 37 | "dist" 38 | ], 39 | "scripts": { 40 | "build": "tsup", 41 | "dev": "tsup --watch", 42 | "clean": "rimraf dist", 43 | "type-check": "tsc --noEmit" 44 | }, 45 | "dependencies": { 46 | "@evod/kit": "*", 47 | "c12": "^1.11.2", 48 | "chokidar": "^3.6.0", 49 | "globby": "^14.0.2", 50 | "immer": "^10.1.1", 51 | "is-glob": "^4.0.3", 52 | "minimatch": "^10.0.1", 53 | "precinct": "^12.1.2", 54 | "rxjs": "^7.8.1", 55 | "tsconfck": "^3.1.3", 56 | "zod": "^3.23.8", 57 | "zod-validation-error": "^3.3.1" 58 | }, 59 | "devDependencies": { 60 | "@types/is-glob": "^4.0.4", 61 | "typescript": "^5.8.3", 62 | "tsup": "latest", 63 | "rimraf": "latest" 64 | }, 65 | "peerDependencies": { 66 | "typescript": ">=5" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/linter/src/pretty-reporter/index.ts: -------------------------------------------------------------------------------- 1 | import type { AugmentedDiagnostic } from "./types.js"; 2 | import chalk from "chalk"; 3 | 4 | import figures from "figures"; 5 | import { formatSingleDiagnostic } from "./format-single-diagnostic.js"; 6 | import { s } from "./pluralization.js"; 7 | 8 | export function formatPretty( 9 | diagnostics: Array, 10 | cwd: string, 11 | ) { 12 | if (diagnostics.length === 0) { 13 | return chalk.green(`${figures.tick} No problems found!`); 14 | } 15 | 16 | const errors = diagnostics.filter((d) => d.rule.severity === "error"); 17 | const warnings = diagnostics.filter((d) => d.rule.severity === "warn"); 18 | 19 | let footer = `Found ${[ 20 | errors.length > 0 && 21 | chalk.red.bold(`${errors.length} error${s(errors.length)}`), 22 | warnings.length > 0 && 23 | chalk.yellow.bold(`${warnings.length} warning${s(warnings.length)}`), 24 | ] 25 | .filter(Boolean) 26 | .join(" and ")}`; 27 | 28 | const autofixable = diagnostics.filter((d) => (d.fixes?.length ?? 0) > 0); 29 | if (autofixable.length === diagnostics.length) { 30 | footer += ` (all can be fixed automatically with ${chalk.green.bold("--fix")})`; 31 | } else if (autofixable.length > 0) { 32 | footer += ` (${autofixable.length} can be fixed automatically with ${chalk.green.bold("--fix")})`; 33 | } else { 34 | footer += " (none can be fixed automatically)"; 35 | } 36 | 37 | return `\n${diagnostics 38 | .map((d) => formatSingleDiagnostic(d, cwd)) 39 | .join("\n\n")}\n\n${ 40 | // Due to formatting characters, it won't be exactly the size of the footer, that is okay 41 | chalk.gray(figures.line.repeat(footer.length)) 42 | }\n ${footer}\n`; 43 | } 44 | 45 | export function reportPretty( 46 | diagnostics: Array, 47 | cwd: string, 48 | ) { 49 | console.error(formatPretty(diagnostics, cwd)); 50 | } 51 | 52 | export type { AugmentedDiagnostic }; 53 | -------------------------------------------------------------------------------- /examples/todo/evo.config.ts: -------------------------------------------------------------------------------- 1 | import type { Abstraction, Rule } from "evolution-design"; 2 | import { abstraction, defineConfig } from "evolution-design"; 3 | 4 | import { 5 | dependenciesDirection, 6 | noUnabstractionFiles, 7 | publicAbstraction, 8 | requiredChildren, 9 | restrictCrossImports, 10 | } from "evolution-design/rules"; 11 | 12 | export default defineConfig({ 13 | root: root(), 14 | baseUrl: "./src", 15 | }); 16 | 17 | // Пример реализации слоёв FSD 18 | function root() { 19 | return abstraction({ 20 | name: "fsdApp", 21 | children: { 22 | app: app(), 23 | features: features(), 24 | shared: shared(), 25 | }, 26 | rules: [ 27 | dependenciesDirection(["app", "features", "shared"]), 28 | noUnabstractionFiles(), 29 | ], 30 | }); 31 | } 32 | 33 | function app() { 34 | return abstraction("app"); 35 | } 36 | 37 | function features() { 38 | return layer({ 39 | name: "features", 40 | child: feature(), 41 | rules: [restrictCrossImports(), noUnabstractionFiles()], 42 | }); 43 | } 44 | 45 | function shared() { 46 | return abstraction("shared"); 47 | } 48 | 49 | function feature() { 50 | return abstraction({ 51 | name: "feature", 52 | children: { 53 | "*": abstraction("other"), 54 | model: abstraction("model"), 55 | vm: abstraction("vm"), 56 | ui: abstraction("ui"), 57 | "index.ts": abstraction("entry"), 58 | }, 59 | rules: [ 60 | requiredChildren(), 61 | noUnabstractionFiles(), 62 | dependenciesDirection(["entry", "ui", "vm", "model", "other"]), 63 | publicAbstraction("entry"), 64 | ], 65 | }); 66 | } 67 | 68 | function layer({ 69 | name, 70 | child, 71 | rules = [], 72 | }: { 73 | name: string; 74 | child: Abstraction; 75 | rules?: Rule[]; 76 | }) { 77 | return abstraction({ 78 | name, 79 | children: { 80 | "*": child, 81 | }, 82 | rules: [...rules], 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /examples/todo/src/app/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | 37 | --chart-1: 12 76% 61%; 38 | 39 | --chart-2: 173 58% 39%; 40 | 41 | --chart-3: 197 37% 24%; 42 | 43 | --chart-4: 43 74% 66%; 44 | 45 | --chart-5: 27 87% 67%; 46 | } 47 | 48 | .dark { 49 | --background: 0 0% 3.9%; 50 | --foreground: 0 0% 98%; 51 | 52 | --card: 0 0% 3.9%; 53 | --card-foreground: 0 0% 98%; 54 | 55 | --popover: 0 0% 3.9%; 56 | --popover-foreground: 0 0% 98%; 57 | 58 | --primary: 0 0% 98%; 59 | --primary-foreground: 0 0% 9%; 60 | 61 | --secondary: 0 0% 14.9%; 62 | --secondary-foreground: 0 0% 98%; 63 | 64 | --muted: 0 0% 14.9%; 65 | --muted-foreground: 0 0% 63.9%; 66 | 67 | --accent: 0 0% 14.9%; 68 | --accent-foreground: 0 0% 98%; 69 | 70 | --destructive: 0 62.8% 30.6%; 71 | --destructive-foreground: 0 0% 98%; 72 | 73 | --border: 0 0% 14.9%; 74 | --input: 0 0% 14.9%; 75 | --ring: 0 0% 83.1%; 76 | --chart-1: 220 70% 50%; 77 | --chart-2: 160 60% 45%; 78 | --chart-3: 30 80% 55%; 79 | --chart-4: 280 65% 60%; 80 | --chart-5: 340 75% 55%; 81 | } 82 | } 83 | 84 | @layer base { 85 | * { 86 | @apply border-border; 87 | } 88 | body { 89 | @apply bg-background text-foreground; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/linter/src/lint/auto-fix.ts: -------------------------------------------------------------------------------- 1 | import type { Diagnostic } from "@evod/core"; 2 | import { mkdir, open, rename, rm } from "node:fs/promises"; 3 | import { dirname, join } from "node:path"; 4 | 5 | export async function applyAutofixes( 6 | diagnostics: Array 7 | ): Promise { 8 | const stillRelevantDiagnostics = []; 9 | const fixableDiagnostics = []; 10 | 11 | for (const diagnostic of diagnostics) { 12 | const fixes = diagnostic.fixes; 13 | 14 | if (!fixes) { 15 | // If we don't know how to fix, it's relevant right away 16 | stillRelevantDiagnostics.push(diagnostic); 17 | continue; 18 | } 19 | 20 | fixableDiagnostics.push(diagnostic); 21 | } 22 | 23 | try { 24 | await Promise.all(fixableDiagnostics.map(tryToApplyFixes)); 25 | } catch (error) { 26 | // If for some reason, a fix failed 27 | // then assume the diagnostics are still relevant 28 | // TODO: enhance it to push only failed fixes instead of all 29 | stillRelevantDiagnostics.push(...fixableDiagnostics); 30 | console.error(error); 31 | } 32 | 33 | return stillRelevantDiagnostics; 34 | } 35 | 36 | async function tryToApplyFixes(diagnostic: Diagnostic) { 37 | const fixes = diagnostic.fixes ?? []; 38 | 39 | return Promise.all( 40 | fixes.map((fix) => { 41 | switch (fix.type) { 42 | case "rename": 43 | return rename(fix.path, join(dirname(fix.path), fix.newName)); 44 | case "create-file": 45 | return open(fix.path, "w").then((file) => { 46 | file.write(fix.content); 47 | return file.close(); 48 | }); 49 | case "create-folder": 50 | return mkdir(fix.path, { recursive: true }); 51 | case "delete": 52 | return rm(fix.path, { recursive: true }); 53 | case "modify-file": 54 | return open(fix.path, "w").then(async (file) => { 55 | await file.write(fix.content); 56 | return file.close(); 57 | }); 58 | default: 59 | return undefined; 60 | } 61 | }) 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /examples/todo/src/shared/ui/shadcn/button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib/css"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import * as React from "react"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | 52 | ); 53 | }, 54 | ); 55 | Button.displayName = "Button"; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /packages/core/src/config/load.ts: -------------------------------------------------------------------------------- 1 | import type { EvolutionConfig } from "./define-config"; 2 | import { loadConfig, watchConfig as watchConfigC12 } from "c12"; 3 | import { from, map, Observable, switchMap } from "rxjs"; 4 | import { 5 | ConfigurationInvalidError, 6 | ConfigurationNotFoundError, 7 | } from "./errors"; 8 | import { EvolutionConfigSchema } from "./schema"; 9 | 10 | const CONFIG_NAME = "evo"; 11 | 12 | export interface ConfigResult { 13 | config: EvolutionConfig; 14 | configPath: string; 15 | } 16 | 17 | export function watchConfig({ 18 | cwd, 19 | onlyOne, 20 | }: { 21 | cwd: string; 22 | onlyOne?: boolean; 23 | }) { 24 | const config$ = from( 25 | loadConfig({ 26 | cwd, 27 | name: CONFIG_NAME, 28 | }), 29 | ).pipe( 30 | map(({ configFile, config }) => { 31 | if (!configFile) { 32 | throw new ConfigurationNotFoundError(); 33 | } 34 | return parseConfigResult(configFile, config); 35 | }), 36 | ); 37 | 38 | if (onlyOne) { 39 | return config$; 40 | } 41 | 42 | return config$.pipe( 43 | switchMap( 44 | ({ configPath, config }) => 45 | new Observable((subscriber) => { 46 | subscriber.next({ configPath, config }); 47 | 48 | let unwatchCallback = () => {}; 49 | 50 | watchConfigC12({ 51 | cwd, 52 | name: CONFIG_NAME, 53 | onUpdate: (config) => { 54 | subscriber.next( 55 | parseConfigResult(configPath, config.newConfig.config), 56 | ); 57 | }, 58 | }).then(({ unwatch }) => { 59 | unwatchCallback = unwatch; 60 | }); 61 | 62 | return () => unwatchCallback(); 63 | }), 64 | ), 65 | ); 66 | } 67 | 68 | function parseConfigResult(filepath: string, data: unknown): ConfigResult { 69 | const parseResult = EvolutionConfigSchema.safeParse(data); 70 | 71 | if (!parseResult.success) { 72 | throw new ConfigurationInvalidError(parseResult.error, filepath); 73 | } 74 | 75 | return { 76 | config: parseResult.data, 77 | configPath: filepath, 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /packages/core/src/abstraction.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from "./rule/types"; 2 | import type { Path } from "./vfs/types"; 3 | import * as fs from "node:fs"; 4 | 5 | export type AbstractionName = string; 6 | export type AbstractionMatcher = string; 7 | export interface Abstraction { 8 | name: AbstractionName; 9 | children: Record; 10 | rules: Rule[]; 11 | fractal?: AbstractionName; 12 | fileTemplate?: (path: Path) => string; 13 | } 14 | 15 | export interface AbstractionOptions { 16 | name: string; 17 | children?: Record; 18 | rules?: Rule[]; 19 | fractal?: string; 20 | fileTemplate?: ((path: Path) => string) | string; 21 | fileTemplateUrl?: string; 22 | } 23 | export function abstraction( 24 | name: string, 25 | optionalConfig?: Omit, 26 | ): Abstraction; 27 | export function abstraction(config: AbstractionOptions): Abstraction; 28 | export function abstraction( 29 | optionsOrName: AbstractionOptions | string, 30 | optionalConfig?: Omit, 31 | ): Abstraction { 32 | if (typeof optionsOrName === "string") { 33 | return { 34 | name: optionsOrName, 35 | children: optionalConfig?.children ?? {}, 36 | rules: optionalConfig?.rules ?? [], 37 | fractal: optionalConfig?.fractal, 38 | fileTemplate: normalizeFileTemplate( 39 | optionalConfig?.fileTemplate, 40 | optionalConfig?.fileTemplateUrl, 41 | ), 42 | }; 43 | } 44 | return { 45 | name: optionsOrName.name, 46 | children: optionsOrName.children ?? {}, 47 | rules: optionsOrName.rules ?? [], 48 | fractal: optionsOrName.fractal, 49 | fileTemplate: normalizeFileTemplate( 50 | optionsOrName.fileTemplate, 51 | optionsOrName?.fileTemplateUrl, 52 | ), 53 | }; 54 | } 55 | 56 | function normalizeFileTemplate( 57 | fileTemplate?: ((path: Path) => string) | string, 58 | fileTemplateUrl?: string, 59 | ): ((path: Path) => string) | undefined { 60 | if (fileTemplateUrl) { 61 | return () => fs.readFileSync(fileTemplateUrl, "utf-8"); 62 | } 63 | if (!fileTemplate) { 64 | return undefined; 65 | } 66 | if (typeof fileTemplate === "string") { 67 | return () => fileTemplate; 68 | } 69 | 70 | return fileTemplate; 71 | } 72 | -------------------------------------------------------------------------------- /packages/core/src/vfs/watch-fs.ts: -------------------------------------------------------------------------------- 1 | import type { Path, VfsEvents } from "./types"; 2 | import { join, sep } from "node:path"; 3 | import chokidar from "chokidar"; 4 | import { type GlobbyFilterFunction, isGitIgnored } from "globby"; 5 | import { filter, from, Observable, switchMap } from "rxjs"; 6 | import { addDirectory } from "./add-directory"; 7 | import { addFile } from "./add-file"; 8 | import { createVfsRoot } from "./create-root"; 9 | import { removeNode } from "./remove-node"; 10 | 11 | export function watchFs( 12 | path: Path, 13 | { onlyReady }: { onlyReady?: boolean } = {}, 14 | ) { 15 | const isIgnored$ = from(isGitIgnored({ cwd: path })); 16 | 17 | let vfs$ = isIgnored$.pipe( 18 | switchMap((isIgnored) => createWatcherObservable({ path, isIgnored })), 19 | ); 20 | 21 | if (onlyReady) { 22 | vfs$ = vfs$.pipe(filter((e) => e.type === "ready")); 23 | } 24 | 25 | return vfs$; 26 | } 27 | 28 | function createWatcherObservable({ 29 | path, 30 | isIgnored, 31 | }: { 32 | path: string; 33 | isIgnored: GlobbyFilterFunction; 34 | }) { 35 | return new Observable((observer) => { 36 | let vfs = createVfsRoot(path); 37 | const watcher = chokidar.watch(path, { 38 | ignored: (path) => 39 | path.split(sep).includes("node_modules") || isIgnored(path), 40 | ignoreInitial: false, 41 | alwaysStat: true, 42 | awaitWriteFinish: true, 43 | disableGlobbing: true, 44 | cwd: path, 45 | }); 46 | 47 | watcher.on("add", async (relativePath) => { 48 | vfs = addFile(vfs, join(path, relativePath)); 49 | observer.next({ type: "add", vfs }); 50 | }); 51 | 52 | watcher.on("addDir", async (relativePath) => { 53 | vfs = addDirectory(vfs, join(path, relativePath)); 54 | observer.next({ type: "addDir", vfs }); 55 | }); 56 | 57 | watcher.on("change", async () => { 58 | observer.next({ type: "change", vfs }); 59 | }); 60 | 61 | watcher.on("unlink", async (relativePath) => { 62 | vfs = removeNode(vfs, join(path, relativePath)); 63 | observer.next({ type: "unlink", vfs }); 64 | }); 65 | 66 | watcher.on("unlinkDir", async (relativePath) => { 67 | vfs = removeNode(vfs, join(path, relativePath)); 68 | observer.next({ type: "unlinkDir", vfs }); 69 | }); 70 | 71 | watcher.on("ready", () => { 72 | observer.next({ type: "ready", vfs }); 73 | }); 74 | 75 | return () => { 76 | watcher.close(); 77 | }; 78 | }); 79 | } 80 | -------------------------------------------------------------------------------- /apps/docs/astro.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import starlight from "@astrojs/starlight"; 3 | import { defineConfig } from "astro/config"; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | base: "/", 8 | integrations: [ 9 | starlight({ 10 | title: "Evolution Design", 11 | logo: { 12 | src: "./src/assets/logo.png", 13 | alt: "ED", 14 | }, 15 | social: { 16 | github: "https://github.com/evo-community/evolution-design", 17 | telegram: "https://t.me/+VugvWY1dtdRhM2Uy", 18 | }, 19 | customCss: [ 20 | // Relative path to your custom CSS file 21 | "./src/styles/custom.css", 22 | ], 23 | defaultLocale: "root", 24 | locales: { 25 | root: { 26 | label: "Russian", 27 | lang: "ru", 28 | }, 29 | en: { 30 | label: "English", 31 | }, 32 | }, 33 | sidebar: [ 34 | { 35 | label: "Начало", 36 | translations: { 37 | en: "Getting started", 38 | }, 39 | slug: "getting-started", 40 | }, 41 | { 42 | label: "Руководство", 43 | translations: { 44 | en: "Handbook", 45 | }, 46 | items: [ 47 | { 48 | label: "Быстрый старт", 49 | slug: "guide", 50 | translations: { 51 | en: "Quick start", 52 | }, 53 | }, 54 | { 55 | label: "Создание проекта на ED small", 56 | slug: "guide/ed-small", 57 | translations: { 58 | en: "Creating a project on ED small", 59 | }, 60 | }, 61 | ], 62 | }, 63 | { 64 | label: "Погружение", 65 | translations: { 66 | en: "Deep dive", 67 | }, 68 | items: [ 69 | { 70 | label: "Основные концепции", 71 | slug: "deep-dive", 72 | translations: { 73 | en: "Main concepts", 74 | }, 75 | }, 76 | ], 77 | }, 78 | { 79 | label: "Паттерны", 80 | translations: { 81 | en: "Patterns", 82 | }, 83 | items: [], 84 | }, 85 | { 86 | label: "Глоссарий", 87 | translations: { 88 | en: "Terms", 89 | }, 90 | items: [], 91 | }, 92 | ], 93 | }), 94 | ], 95 | }); 96 | -------------------------------------------------------------------------------- /packages/linter/src/cli/commands/lint.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | import { defineCommand } from "citty"; 3 | import { watchConfig } from "@evod/core"; 4 | import { applyAutofixes, lint } from "../../lint"; 5 | import prexit from "prexit"; 6 | import { catchError, map, switchMap } from "rxjs"; 7 | import { ZodError } from "zod"; 8 | import { fromError } from "zod-validation-error"; 9 | import { reportPretty, type AugmentedDiagnostic } from "../../pretty-reporter"; 10 | 11 | export default defineCommand({ 12 | meta: { 13 | name: "lint", 14 | description: "Lint the project", 15 | }, 16 | 17 | args: { 18 | watch: { 19 | type: "boolean", 20 | description: "Watch for changes", 21 | default: false, 22 | }, 23 | fix: { 24 | type: "boolean", 25 | description: "Apply autofixes", 26 | default: false, 27 | }, 28 | "fail-on-warning": { 29 | type: "boolean", 30 | description: "Fail on warnings", 31 | default: false, 32 | }, 33 | }, 34 | async run(ctx) { 35 | const { watch, fix, "fail-on-warning": failOnWarning } = ctx.args; 36 | 37 | const subscription = watchConfig({ 38 | cwd: process.cwd(), 39 | onlyOne: !watch, 40 | }) 41 | .pipe( 42 | map(({ configPath, config }) => ({ configPath, config, watch })), 43 | switchMap(lint), 44 | catchError((e) => { 45 | if (e instanceof ZodError) { 46 | console.error(fromError(e).toString()); 47 | } else if (e instanceof Error) { 48 | console.error(e.message); 49 | } 50 | 51 | process.exit(1); 52 | }) 53 | ) 54 | .subscribe(async (diagnostics) => { 55 | if (watch) { 56 | // eslint-disable-next-line no-console 57 | console.clear(); 58 | reportPretty(diagnostics, process.cwd()); 59 | if (fix) { 60 | await applyAutofixes(diagnostics); 61 | } 62 | } else { 63 | let stillRelevantDiagnostics = diagnostics; 64 | 65 | reportPretty(diagnostics, process.cwd()); 66 | 67 | if (fix) { 68 | stillRelevantDiagnostics = await applyAutofixes(diagnostics); 69 | } 70 | 71 | if (stillRelevantDiagnostics.length > 0) { 72 | const onlyWarnings = stillRelevantDiagnostics.every( 73 | (d: AugmentedDiagnostic) => d.rule.severity === "warn" 74 | ); 75 | if (failOnWarning || !onlyWarnings) { 76 | process.exit(1); 77 | } 78 | } 79 | process.exit(0); 80 | } 81 | }); 82 | 83 | prexit(() => subscription.unsubscribe()); 84 | }, 85 | }); 86 | -------------------------------------------------------------------------------- /apps/docs/README.md: -------------------------------------------------------------------------------- 1 | # Starlight Starter Kit: Basics 2 | 3 | [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) 4 | 5 | ``` 6 | npm create astro@latest -- --template starlight 7 | ``` 8 | 9 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) 10 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) 11 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics) 12 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs) 13 | 14 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 15 | 16 | ## 🚀 Project Structure 17 | 18 | Inside of your Astro + Starlight project, you'll see the following folders and files: 19 | 20 | ``` 21 | . 22 | ├── public/ 23 | ├── src/ 24 | │ ├── assets/ 25 | │ ├── content/ 26 | │ │ ├── docs/ 27 | │ │ └── config.ts 28 | │ └── env.d.ts 29 | ├── astro.config.mjs 30 | ├── package.json 31 | └── tsconfig.json 32 | ``` 33 | 34 | Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. 35 | 36 | Images can be added to `src/assets/` and embedded in Markdown with a relative link. 37 | 38 | Static assets, like favicons, can be placed in the `public/` directory. 39 | 40 | ## 🧞 Commands 41 | 42 | All commands are run from the root of the project, from a terminal: 43 | 44 | | Command | Action | 45 | | :------------------------ | :----------------------------------------------- | 46 | | `npm install` | Installs dependencies | 47 | | `npm run dev` | Starts local dev server at `localhost:4321` | 48 | | `npm run build` | Build your production site to `./dist/` | 49 | | `npm run preview` | Preview your build locally, before deploying | 50 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 51 | | `npm run astro -- --help` | Get help using the Astro CLI | 52 | 53 | ## 👀 Want to learn more? 54 | 55 | Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). 56 | -------------------------------------------------------------------------------- /packages/core/src/abstraction-instance/parse-abstraction-instance.ts: -------------------------------------------------------------------------------- 1 | import type { Abstraction } from "../abstraction"; 2 | import type { Path, VfsNode } from "../vfs/types"; 3 | import type { AbstractionInstance } from "./types"; 4 | import { relative } from "node:path"; 5 | import { minimatch } from "minimatch"; 6 | import { memoize } from "@evod/kit"; 7 | 8 | // Так как Vfs иммутабельный, 9 | // то ссылки на инсансы между вызовами 10 | // не будут изменены, благодаря мемоизации 11 | export const parseAbstractionInstance = memoize((abstraction: Abstraction) => 12 | memoize((node: VfsNode): AbstractionInstance => { 13 | if (node.type === "file") { 14 | return { 15 | abstraction, 16 | path: node.path, 17 | children: [], 18 | childNodes: [], 19 | }; 20 | } 21 | 22 | // Получаем все дочерние абстракции 23 | const children: Record = {}; 24 | 25 | for (const [pattern, childAbstraction] of Object.entries( 26 | abstraction.children 27 | )) { 28 | const nodeAbstractionInstance = 29 | parseAbstractionInstance(childAbstraction); 30 | const nodesStack: VfsNode[] = [node]; 31 | while (nodesStack.length) { 32 | const currentNode = nodesStack.pop()!; 33 | // Если путь соответствует паттерну. То записываем или **перезаписываем** инстранс абстракции 34 | if (minimatch(relative(node.path, currentNode.path), pattern)) { 35 | children[currentNode.path] = nodeAbstractionInstance(currentNode); 36 | 37 | // Внутрь дирректории для которой создали абстракцию не идём 38 | continue; 39 | } 40 | 41 | // Если для дирректории один из прошлых матчеров создал абстракцию, тоже не идём 42 | if (children[currentNode.path]) { 43 | continue; 44 | } 45 | 46 | if (currentNode.type === "folder") { 47 | nodesStack.push(...currentNode.children); 48 | } 49 | } 50 | } 51 | 52 | // Получаем все ноды, которые не являются абстракциями 53 | const childNodes: Path[] = []; 54 | const childrenNodesStack: VfsNode[] = [node]; 55 | 56 | while (childrenNodesStack.length) { 57 | const currentNode = childrenNodesStack.pop()!; 58 | 59 | // Если есть абстракция - игнорируем 60 | if (children[currentNode.path]) { 61 | continue; 62 | } 63 | 64 | if (currentNode.path !== node.path) { 65 | childNodes.push(currentNode.path); 66 | } 67 | 68 | if (currentNode.type === "folder") { 69 | childrenNodesStack.push(...currentNode.children); 70 | } 71 | } 72 | 73 | return { 74 | abstraction, 75 | path: node.path, 76 | childNodes: Object.values(childNodes), 77 | children: Object.values(children), 78 | }; 79 | }) 80 | ); 81 | -------------------------------------------------------------------------------- /examples/todo/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | "./pages/**/*.{ts,tsx}", 6 | "./components/**/*.{ts,tsx}", 7 | "./app/**/*.{ts,tsx}", 8 | "./src/**/*.{ts,tsx}", 9 | ], 10 | prefix: "", 11 | theme: { 12 | container: { 13 | center: "true", 14 | padding: "2rem", 15 | screens: { 16 | "2xl": "1400px", 17 | }, 18 | }, 19 | extend: { 20 | colors: { 21 | border: "hsl(var(--border))", 22 | input: "hsl(var(--input))", 23 | ring: "hsl(var(--ring))", 24 | background: "hsl(var(--background))", 25 | foreground: "hsl(var(--foreground))", 26 | primary: { 27 | DEFAULT: "hsl(var(--primary))", 28 | foreground: "hsl(var(--primary-foreground))", 29 | }, 30 | secondary: { 31 | DEFAULT: "hsl(var(--secondary))", 32 | foreground: "hsl(var(--secondary-foreground))", 33 | }, 34 | destructive: { 35 | DEFAULT: "hsl(var(--destructive))", 36 | foreground: "hsl(var(--destructive-foreground))", 37 | }, 38 | muted: { 39 | DEFAULT: "hsl(var(--muted))", 40 | foreground: "hsl(var(--muted-foreground))", 41 | }, 42 | accent: { 43 | DEFAULT: "hsl(var(--accent))", 44 | foreground: "hsl(var(--accent-foreground))", 45 | }, 46 | popover: { 47 | DEFAULT: "hsl(var(--popover))", 48 | foreground: "hsl(var(--popover-foreground))", 49 | }, 50 | card: { 51 | DEFAULT: "hsl(var(--card))", 52 | foreground: "hsl(var(--card-foreground))", 53 | }, 54 | chart: { 55 | 1: "hsl(var(--chart-1))", 56 | 2: "hsl(var(--chart-2))", 57 | 3: "hsl(var(--chart-3))", 58 | 4: "hsl(var(--chart-4))", 59 | 5: "hsl(var(--chart-5))", 60 | }, 61 | }, 62 | borderRadius: { 63 | lg: "var(--radius)", 64 | md: "calc(var(--radius) - 2px)", 65 | sm: "calc(var(--radius) - 4px)", 66 | }, 67 | keyframes: { 68 | "accordion-down": { 69 | from: { 70 | height: "0", 71 | }, 72 | to: { 73 | height: "var(--radix-accordion-content-height)", 74 | }, 75 | }, 76 | "accordion-up": { 77 | from: { 78 | height: "var(--radix-accordion-content-height)", 79 | }, 80 | to: { 81 | height: "0", 82 | }, 83 | }, 84 | }, 85 | animation: { 86 | "accordion-down": "accordion-down 0.2s ease-out", 87 | "accordion-up": "accordion-up 0.2s ease-out", 88 | }, 89 | }, 90 | }, 91 | plugins: [require("tailwindcss-animate")], 92 | }; 93 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/terms/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Глоссарий 3 | description: Глоссарий 4 | --- 5 | 6 | ### Информация 7 | 8 | Информация – совокупность знаний об объекте. 9 | 10 | ### Данные 11 | 12 | Данные – закодированная информация. 13 | 14 | ### Состояние 15 | 16 | Состояние – хранимые в системе данные. 17 | 18 | Обычно состояние представляет собой данные с длительным сроком жизни. 19 | 20 | Например, если данные используются только для промежуточных вычислений (как переменная i в цикле), это не считается состоянием. 21 | 22 | Состояние — это "долгоживущая" переменная, такая как state, которая изменяется в ответ на действия пользователя, вызовы функций и другие события. 23 | 24 | ```javascript 25 | // Состояние 26 | let state = 1; 27 | 28 | function multiple() { 29 | // Промежуточные данные 30 | let tempState = state; 31 | for (let i = state; i < state * 2; i++) { 32 | tempState += 1; 33 | } 34 | state = tempState; 35 | } 36 | ``` 37 | 38 | Примеры состояний: State manager, useState, DOM, Cookie, База данных, localStorage. Код также является состоянием. 39 | 40 | Дублирование – это когда в системе существуют два или более состояния, которые содержат разные данные, но при декодировании дают одну и ту же информацию. 41 | 42 | Пример дублирования состояния: 43 | 44 | ```javascript 45 | const user = { 46 | id: 1, 47 | age: 14, 48 | }; 49 | 50 | const adult_users = { 51 | 1: false, 52 | }; 53 | ``` 54 | 55 | В этом примере переменные `user` и `adult_users` представляют собой дублирование состояния. Хотя они содержат разные данные, обе переменные дают информацию о том, является ли пользователь совершеннолетним. Переменная `user` хранит возраст, а `adult_users` хранит булевое значение, указывающее на совершеннолетие. Оба состояния могут быть использованы для определения одного и того же факта, что приводит к дублированию информации. 56 | 57 | :::note 58 | Дублирование информации создает риск, что данные будут изменены в одном состоянии, а в другом останутся без изменений. Это может привести к тому, что состояния и данные будут различаться, что вызовет неконсистентность. В результате информация об одном и том же объекте в двух разных источниках может противоречить друг другу. 59 | ::: 60 | 61 | ### Кэш 62 | 63 | Кэш – дублирование состояния для экономии вычислительных мощностей. 64 | 65 | Пример кэша: 66 | 67 | ```javascript 68 | const [users, setUsers] = useState(); 69 | 70 | useEffect(() => { 71 | fetch("http://localhost:3000/users") 72 | .then((r) => r.json()) 73 | .then(setUsers); 74 | }, []); 75 | 76 | function getUserById(id) { 77 | return users.find((user) => user.id === id); 78 | } 79 | ``` 80 | 81 | В примере выше используется `useState` для хранения пользователей и `useEffect` для однократной загрузки данных с сервера. Данные кэшируются в `users`. Функция `getUserById` ищет пользователя в этом кэше, избегая повторных запросов к серверу. 82 | -------------------------------------------------------------------------------- /kit/kit/src/resolve-import.ts: -------------------------------------------------------------------------------- 1 | import type { CompilerOptions } from "typescript"; 2 | import { sep } from "node:path"; 3 | import ts from "typescript"; 4 | 5 | /** 6 | * Given a file name, an imported path, and a TSConfig object, produce a path to the imported file, relative to TypeScript's `baseUrl`. 7 | * 8 | * The resulting path uses OS-appropriate path separators. 9 | * 10 | * @example 11 | * ```tsx 12 | * // ./src/pages/home/ui/HomePage.tsx 13 | * import { Button } from "~/shared/ui"; 14 | * ``` 15 | * 16 | * ```json 17 | * // ./tsconfig.json 18 | * { 19 | * "compilerOptions": { 20 | * "moduleResolution": "Bundler", 21 | * "baseUrl": ".", 22 | * "paths": { 23 | * "~/*": ["./src/*"], 24 | * }, 25 | * }, 26 | * } 27 | * ``` 28 | * 29 | * ```tsx 30 | * resolveImport( 31 | * "~/shared/ui", 32 | * "./src/pages/home/ui/HomePage.tsx", 33 | * { moduleResolution: "Bundler", baseUrl: ".", paths: { "~/*": ["./src/*"] } }, 34 | * fs.existsSync 35 | * ); 36 | * ``` 37 | * Expected output: `src/shared/ui/index.ts` 38 | */ 39 | export function resolveImport( 40 | importedPath: string, 41 | importerPath: string, 42 | tsCompilerOptions: ImperfectCompilerOptions, 43 | fileExists: (path: string) => boolean, 44 | directoryExists?: (path: string) => boolean, 45 | ): string | null { 46 | return ( 47 | ts 48 | .resolveModuleName( 49 | importedPath, 50 | importerPath, 51 | normalizeCompilerOptions(tsCompilerOptions), 52 | { 53 | ...ts.sys, 54 | fileExists, 55 | directoryExists, 56 | }, 57 | ) 58 | .resolvedModule?.resolvedFileName?.replaceAll("/", sep) ?? null 59 | ); 60 | } 61 | 62 | const imperfectKeys = { 63 | module: ts.ModuleKind, 64 | moduleResolution: { 65 | ...ts.ModuleResolutionKind, 66 | node: ts.ModuleResolutionKind.Node10, 67 | }, 68 | moduleDetection: ts.ModuleDetectionKind, 69 | newLine: ts.NewLineKind, 70 | target: ts.ScriptTarget, 71 | }; 72 | 73 | /** TypeScript has a few fields which have values from an internal enum, and refuses to take the literal values from the tsconfig.json. */ 74 | function normalizeCompilerOptions( 75 | compilerOptions: ImperfectCompilerOptions, 76 | ): CompilerOptions { 77 | return Object.fromEntries( 78 | Object.entries(compilerOptions).map(([key, value]) => { 79 | if ( 80 | Object.keys(imperfectKeys).includes(key) && 81 | typeof value === "string" 82 | ) { 83 | for (const [enumKey, enumValue] of Object.entries( 84 | imperfectKeys[key as keyof typeof imperfectKeys], 85 | )) { 86 | if (enumKey.toLowerCase() === value.toLowerCase()) { 87 | return [key, enumValue]; 88 | } 89 | } 90 | } 91 | return [key, value]; 92 | }), 93 | ) as CompilerOptions; 94 | } 95 | 96 | export interface ImperfectCompilerOptions 97 | extends Omit { 98 | module?: ts.ModuleKind | keyof typeof ts.ModuleKind; 99 | moduleResolution?: 100 | | ts.ModuleResolutionKind 101 | | keyof typeof ts.ModuleResolutionKind 102 | | "node"; 103 | moduleDetection?: 104 | | ts.ModuleDetectionKind 105 | | keyof typeof ts.ModuleDetectionKind; 106 | newLine?: ts.NewLineKind | keyof typeof ts.NewLineKind; 107 | target?: ts.ScriptTarget | keyof typeof ts.ScriptTarget; 108 | } 109 | -------------------------------------------------------------------------------- /PUBLISHING.md: -------------------------------------------------------------------------------- 1 | # Публикация пакетов 2 | 3 | Этот документ описывает процесс публикации пакетов в монорепозитории Evolution Design. 4 | 5 | ## Пакеты для публикации 6 | 7 | Публикуются только пакеты из папки `packages/`: 8 | - `@evod/core` - основная функциональность линтера 9 | - `edlint` - CLI инструмент для линтинга 10 | 11 | Пакет `@evod/kit` остается приватным и используется только внутри монорепозитория. 12 | 13 | ## Процесс публикации 14 | 15 | ### 1. Локальная подготовка 16 | 17 | ```bash 18 | # Убедитесь, что все изменения зафиксированы 19 | git status 20 | 21 | # Соберите все пакеты 22 | npm run build 23 | 24 | # Проверьте готовность пакетов к публикации 25 | npm run check-packages 26 | ``` 27 | 28 | ### 2. Создание changeset 29 | 30 | ```bash 31 | # Создайте changeset для описания изменений 32 | npm run changeset 33 | ``` 34 | 35 | Выберите пакеты, которые изменились, и тип изменений: 36 | - `patch` - исправления багов 37 | - `minor` - новая функциональность (обратно совместимая) 38 | - `major` - breaking changes 39 | 40 | ### 3. Автоматическая публикация через GitHub Actions 41 | 42 | 1. Создайте PR с вашими изменениями и changeset 43 | 2. После мерджа в `main`, GitHub Actions автоматически: 44 | - Создаст PR с обновлением версий 45 | - После мерджа этого PR опубликует пакеты в npm 46 | 47 | ### 4. Ручная публикация (если нужно) 48 | 49 | Если автоматическая публикация не сработала или нужна экстренная публикация: 50 | 51 | ```bash 52 | # Переключитесь на main и получите последние изменения 53 | git checkout main 54 | git pull origin main 55 | 56 | # Убедитесь, что все зависимости установлены 57 | npm ci 58 | 59 | # Соберите пакеты 60 | npm run build 61 | 62 | # Проверьте готовность к публикации 63 | npm run check-packages 64 | 65 | # Опубликуйте пакеты 66 | npm run release 67 | ``` 68 | 69 | ## Настройка для первой публикации 70 | 71 | ### 1. Создайте NPM токен 72 | 73 | 1. Войдите в [npmjs.com](https://www.npmjs.com/) 74 | 2. Перейдите в настройки → Access Tokens 75 | 3. Создайте новый токен с правами "Automation" 76 | 77 | ### 2. Настройте GitHub Secrets 78 | 79 | В настройках репозитория добавьте секрет: 80 | - `NPM_TOKEN` - ваш NPM токен 81 | 82 | ### 3. Локальная настройка (для ручной публикации) 83 | 84 | Для локальной публикации добавьте в `.npmrc`: 85 | 86 | ``` 87 | //registry.npmjs.org/:_authToken=YOUR_NPM_TOKEN 88 | ``` 89 | 90 | **Важно:** Никогда не коммитьте токен в репозиторий! 91 | 92 | ## Структура версионирования 93 | 94 | Проект использует [Semantic Versioning](https://semver.org/): 95 | - `MAJOR.MINOR.PATCH` 96 | - Все пакеты синхронизированы по версиям 97 | 98 | ## Полезные команды 99 | 100 | ```bash 101 | # Создать changeset для новых изменений 102 | npm run changeset 103 | 104 | # Проверить статус текущих changesets 105 | npm run changeset status 106 | 107 | # Посмотреть, что будет опубликовано (без публикации) 108 | npm run changeset:publish --dry-run 109 | 110 | # Обновить версии пакетов (обычно делается через Release PR) 111 | npm run changeset:version 112 | 113 | # Опубликовать пакеты (ручная публикация) 114 | npm run changeset:publish 115 | 116 | # Полная публикация (сборка + публикация) 117 | npm run release 118 | 119 | # Собрать все пакеты 120 | npm run build 121 | 122 | # Очистить кеш сборки 123 | npm run clean 124 | 125 | # Проверить готовность к публикации 126 | npm run check-packages 127 | 128 | # Полная подготовка к релизу 129 | npm run prepare-release 130 | ``` 131 | 132 | ## Troubleshooting 133 | 134 | ### Проблемы с публикацией 135 | 136 | 1. **"403 Forbidden"** - проверьте NPM токен и права доступа 137 | 2. **"Package already exists"** - версия уже опубликована, обновите версию 138 | 3. **"Build failed"** - запустите `npm run build` и исправьте ошибки 139 | 140 | ### Откат публикации 141 | 142 | ```bash 143 | # Откат последней версии (в течение 72 часов) 144 | npm unpublish @evod/core@VERSION 145 | npm unpublish edlint@VERSION 146 | 147 | # Пометить версию как deprecated 148 | npm deprecate @evod/core@VERSION "Reason for deprecation" 149 | ``` -------------------------------------------------------------------------------- /apps/docs/src/content/docs/getting-started/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Что такое Evolution Design? 3 | description: Overview 4 | --- 5 | 6 | **Evolution Design** (Дальше `ED`) - это набор архитектурных **Best practices**, хорошо себя показавших в разработке современных Front-end приложений. 7 | Их использование позволяет получить **поддерживаемую** кодовую базу, не имея глубоких знаний о архитектуре программного обеспечения. 8 | 9 | ## Какие проблемы решает Evolution Design? 10 | 11 | --- 12 | 13 | 1. **Достаточно качественная архитектура:** 14 | 15 | ED предоставляет набор паттернов, которые для _среднего разработчика в среднем проекте_ позволяют увеличить: 16 | 17 | - Понятность кода 18 | - Надёжность 19 | - Переиспользуемость 20 | 21 | Сам факт внедрения паттернов ED увеличивает шансы вашего проекта не загнуться от неконтролируемого роста сложности. 22 | 23 | 2. **Стандартизация:** 24 | 25 | ED вводит набор понятий и паттернов, которые создают _единый язык_ для всех ваших разработчиков. 26 | Переход разработчика из проекта в проект значительно упрощается. 27 | 28 | 3. **Увеличение экспертизы:** 29 | 30 | В результате погружения в ED разработчик должен понять основные принципы, лежащие в основе архитектуры ПО, и научиться принимать **архитектурные решения, отталкиваясь от ситуации**. 31 | 32 | ED - это не конечный этап, это отправная точка для создания **лучшей кастомной архитектуры**. 33 | 34 | ## Ценности Evolution Design 35 | 36 | --- 37 | 38 | В процессе работы над Evolution Design мы придерживаемся следующих принципов: 39 | 40 | ### Прагматизм 41 | 42 | Архитектура - это сложная штука. Поэтому часто её воспринимают не как эффективные практики, а как набор _волшебных_ ритуалов. 43 | 44 | В Evolution Design каждый паттерн имеет **обоснование эффективности**, **границы применимости** и **связи с другими паттернами**. 45 | 46 | Таким образом, вы можете подобрать набор паттернов, необходимых именно вашему проекту. Не больше, не меньше! 47 | 48 | ### Постепенность 49 | 50 | Все проекты разные! Маленькому приложению не нужна навороченная архитектура. Но маленькое приложение рано или поздно станет большим! 51 | 52 | **Evolution Design** разработан так, чтобы архитектура вашего проекта развивалась вместе с потребностями вашего проекта! 53 | 54 | ### Подходит как начинающим, так и продвинутым 55 | 56 | У каждого разработчика свой уровень понимания архитектуры ПО. Мы это понимаем, поэтому ED можно использовать в двух вариантах: 57 | 58 | **Для начинающих**: в документации есть гайды, как использовать ED без глубокого понимания каждого паттерна. 59 | Таким образом, разработчик сможет получить достаточно **поддерживаемый код** без глубокого понимания темы. 60 | 61 | **Для продвинутых**: каждый паттерн имеет описание, на основе каких принципов он работает. Какие есть у него условия и ограничения. 62 | Также можно погрузиться в терминологию, чтобы построить целостную систему понимания архитектуры уровня приложения. 63 | 64 | На основании этого вы сможете подобрать идеальный набор паттернов под вашу ситуацию, или промодифицировать их, если потребуется, с полным пониманием дела. 65 | 66 | ### Не противоречивая система 67 | 68 | Нам надоели противоречия в теме разработки ПО. Главная цель ED - совместить все принципы разработки в единую непротиворечивую систему. 69 | В ней будут логично уживаться DRY, YAGNI, OOP, FP, SOLID, GRASP, Чистая архитектура, DDD. 70 | 71 | Но как можно совместить несовместимое? Мы для этого будем использовать главный принцип архитектуры "Everything is a Trade-off". 72 | 73 | ## Куда дальше? 74 | 75 | --- 76 | 77 | Вся документация построена в виде последовательного набора статей, вам достаточно двигаться сверху вниз по боковому меню. 78 | 79 | В первую очередь следует ознакомиться с разделом ["Руководство"](/guide), после которого вы сможете сразу получать преимущества от использования ED. 80 | 81 | :::note 82 | **ED находится в альфа-версии.** Поэтому разделы **"Погружение"**, **"Паттерны"** и **"Глоссарий"** ещё разрабатываются. 83 | 84 | Если вам хочется узнать о них раньше, большая часть материала уже есть в курсах сообщества [Evolution Community](https://evocomm.space/) 85 | ::: 86 | -------------------------------------------------------------------------------- /packages/core/src/abstraction-instance/parse-abstraction-instance.test.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractionInstance } from "./types"; 2 | import { join } from "node:path"; 3 | import { describe, expect, it } from "vitest"; 4 | import { abstraction } from "../abstraction"; 5 | import { addFile } from "../vfs/add-file"; 6 | import { createVfsRoot } from "../vfs/create-root"; 7 | import { parseAbstractionInstance } from "./parse-abstraction-instance"; 8 | 9 | describe("parseAbstractionInstance", () => { 10 | it("return abstraction instance from vfs and abstraction", () => { 11 | const project = join("/", "project"); 12 | const indexFile = join("/", "project", "index.ts"); 13 | const usersIndex = join("/", "project", "features", "user", "index.ts"); 14 | const usersUi = join("/", "project", "features", "user", "ui.tsx"); 15 | const mapIndex = join("/", "project", "features", "map", "index.ts"); 16 | const mapUi = join("/", "project", "features", "map", "ui.tsx"); 17 | const sharedUiButton = join("/", "project", "shared", "ui", "button.tsx"); 18 | 19 | let vfs = createVfsRoot(project); 20 | 21 | vfs = addFile(vfs, usersIndex); 22 | vfs = addFile(vfs, usersUi); 23 | vfs = addFile(vfs, mapIndex); 24 | vfs = addFile(vfs, mapUi); 25 | vfs = addFile(vfs, sharedUiButton); 26 | vfs = addFile(vfs, indexFile); 27 | vfs = addFile(vfs, join(project, "1.service", "service.ts")); 28 | vfs = addFile(vfs, join(project, "2.service", "service.ts")); 29 | vfs = addFile(vfs, join(project, "services", "3.service", "service.ts")); 30 | 31 | const service = abstraction("service"); 32 | const feature = abstraction("feature"); 33 | const shared = abstraction("shared"); 34 | const features = abstraction({ 35 | name: "features", 36 | children: { 37 | "*": feature, 38 | }, 39 | }); 40 | 41 | const app = abstraction({ 42 | name: "app", 43 | children: { 44 | features, 45 | shared, 46 | "**/*.service": service, 47 | }, 48 | }); 49 | 50 | const result = parseAbstractionInstance(app)(vfs); 51 | 52 | expect(result).toEqual({ 53 | path: project, 54 | abstraction: app, 55 | childNodes: [join(project, "services"), indexFile], 56 | children: [ 57 | { 58 | path: join(project, "features"), 59 | abstraction: features, 60 | childNodes: [], 61 | children: [ 62 | { 63 | path: join(project, "features", "map"), 64 | abstraction: feature, 65 | childNodes: [mapUi, mapIndex], 66 | children: [], 67 | }, 68 | { 69 | path: join(project, "features", "user"), 70 | abstraction: feature, 71 | childNodes: [usersUi, usersIndex], 72 | children: [], 73 | }, 74 | ], 75 | }, 76 | { 77 | path: join(project, "shared"), 78 | abstraction: shared, 79 | childNodes: [join(project, "shared", "ui"), sharedUiButton], 80 | children: [], 81 | }, 82 | { 83 | path: join(project, "services", "3.service"), 84 | abstraction: service, 85 | childNodes: [join(project, "services", "3.service", "service.ts")], 86 | children: [], 87 | }, 88 | { 89 | path: join(project, "2.service"), 90 | abstraction: service, 91 | childNodes: [join(project, "2.service", "service.ts")], 92 | children: [], 93 | }, 94 | { 95 | path: join(project, "1.service"), 96 | abstraction: service, 97 | childNodes: [join(project, "1.service", "service.ts")], 98 | children: [], 99 | }, 100 | ], 101 | } satisfies AbstractionInstance); 102 | }); 103 | 104 | it("allow redeclare abstractions by underlaying abstractions", () => { 105 | const projectPath = join("/", "project"); 106 | const file1Path = join("/", "project", "file1.ts"); 107 | const mapIndexPath = join("/", "project", "map", "index.ts"); 108 | 109 | let vfs = createVfsRoot(projectPath); 110 | 111 | vfs = addFile(vfs, file1Path); 112 | vfs = addFile(vfs, mapIndexPath); 113 | 114 | const other = abstraction("other"); 115 | const map = abstraction("map"); 116 | 117 | const app = abstraction({ 118 | name: "app", 119 | children: { 120 | "*": other, 121 | map: map, 122 | }, 123 | }); 124 | 125 | const result = parseAbstractionInstance(app)(vfs); 126 | 127 | expect(result).toEqual({ 128 | path: projectPath, 129 | abstraction: app, 130 | childNodes: [], 131 | children: [ 132 | { 133 | path: join(projectPath, "map"), 134 | abstraction: map, 135 | childNodes: [mapIndexPath], 136 | children: [], 137 | }, 138 | { 139 | path: file1Path, 140 | abstraction: other, 141 | childNodes: [], 142 | children: [], 143 | }, 144 | ], 145 | } satisfies AbstractionInstance); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/deep-dive/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Основные концепции 3 | description: Основные концепции 4 | --- 5 | 6 |
7 | 8 | Понятия вводимые в ED могут отличаться от вашего видиния! 9 | 10 | Правда в том, что нет "правильного определения". 11 | 12 | Мы стараемся что бы понятия которые мы вводим, корилировали с общепринятыми. 13 | Но наша главная задача, создать единую не противоречивую систему. 14 | 15 | _Для тех кто знает DDD, мы создаём [ubiquitous language](https://habr.com/ru/articles/232881) в ограниченном контексте ED_ 16 | 17 | Отдавайте себе отчёт, понятия вводимые ED базово созданы только для использования в ED. 18 | 19 |
20 | 21 | ## Абстракция 22 | 23 | --- 24 | 25 | Самый важный термин всей архитектуры. Всё что мы с вами будем делать, это создавать абстракции, переносить код между абстракциями, выстраивать связи между абстракциями. 26 | 27 | Звучит сложно но на самом деле абстракция - это любой способ _спрятать_ код, сложность любую информацию, за название и публичный интерфейс. 28 | 29 | Суть абстракции, **что вы можете понять что внутри даже не читая содержимого** 30 | 31 | ```typescript 32 | // Переменная это абстракция 33 | const userList = []; 34 | 35 | // Названия переменной достаточно что бы предположить что там 36 | userList.map((user) => user); 37 | 38 | // Функция это абстракция 39 | function createUser(name: string): Promise { 40 | // ... Много строк сложного кода 41 | } 42 | 43 | // Вызывая эту функцию мы знаем что она создаст юзера и вернёт его, даже не читая исходников 44 | const user = await createUser("Evgeny"); 45 | 46 | // Класс это абстракция 47 | class Board { 48 | addCard(title: string) { 49 | // ... Много строк сложного кода 50 | } 51 | removeCard(id: string) { 52 | // ... Много строк сложного кода 53 | } 54 | renderHTML(): string { 55 | // ... Много строк сложного кода 56 | } 57 | } 58 | // Когда мы используем этот класс, мы понимаем что вызов методов добавит на доску карточки. 59 | // А вызов renderHTML вернёт html этой борды даже не читая исходников 60 | const board = new Board(); 61 | board.addCard("Card 1"); 62 | board.removeCard("Card 1"); 63 | document.body.innerHTML = board.renderHTML(); 64 | 65 | // Файл это абстракция 66 | // Просто названия файла нам достаточно что бы предположить, что там 67 | import {} from "./use-user-list"; 68 | 69 | // Папка это абстракция 70 | // Благодаря названию папки мы знаем, 71 | // что UserCard - компонент 72 | import { UserCard } from "./components/user-card"; 73 | ``` 74 | 75 | **Важно:** не все абстракции выражены в коде 76 | 77 | Концепции, термины, паттерны - это всё абстракции 78 | 79 | ```tsx 80 | // По названию мы понимаем что это компонент 81 | // Значит: 82 | // - Там можно использовать хуки 83 | // - Оттуда нужно вернуть jsx 84 | // - В теле не должно быть side эффектов 85 | function UserComponent() {} 86 | ``` 87 | 88 | С помощью абстракций мы можем из **небольшого колличества кода, получать много информации** 89 | 90 | Более подробно об этом можно почитать тут [Абстракция](/terms/abstraction) 91 | 92 | ## Кошелёк миллера 93 | 94 | --- 95 | 96 | Окей, разобрались что такое абстракция, но зачем это нам? 97 | 98 | **Фишка в том, что мозг человека думает абстракциями. Не символами, не словами, не строками кода, а абстракциями.** 99 | 100 | Подробнее об этом тут [Нейрофизиология сложности кода](https://www.youtube.com/watch?v=ush4p9FdJk4) 101 | 102 | И у этого мышления есть важное ограничение, которое и лежит в основе сложности кода. 103 | 104 | **Одновременно мы можем думать только о 7+-2 абстракциях. (Обычно около 4x)** 105 | 106 | Это правило называется [**кошелёк миллера**](https://ru.wikipedia.org/wiki/%D0%9C%D0%B0%D0%B3%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%BE%D0%B5_%D1%87%D0%B8%D1%81%D0%BB%D0%BE_%D1%81%D0%B5%D0%BC%D1%8C_%D0%BF%D0%BB%D1%8E%D1%81-%D0%BC%D0%B8%D0%BD%D1%83%D1%81_%D0%B4%D0%B2%D0%B0) 107 | 108 | Попытайтесь понять 2 куска кода: 109 | 110 | ```js 111 | const maxSubArray = (nums) => { 112 | let maxSub = nums[0]; 113 | let curSum = 0; 114 | for (let i = 0; i < nums.length; i++) { 115 | if (curSum < 0) { 116 | curSum = 0; 117 | } 118 | curSum += nums[i]; 119 | maxSub = Math.max(maxSub, curSum); 120 | } 121 | return maxSub; 122 | }; 123 | ``` 124 | 125 | ```jsx 126 | const user = { 127 | id: 1, 128 | name: "Alex Johnson", 129 | email: "alex@example.com", 130 | avatar: "https://i.pravatar.cc/150?img=3", 131 | role: "Software Engineer", 132 | bio: "Full-stack developer specializing in React & Node", 133 | location: "San Francisco, CA", 134 | joined: "2022-03-15", 135 | followers: 1243, 136 | following: 562, 137 | }; 138 | ``` 139 | 140 | Почему первый понять значительно сложнее чем второй хотя и там и там 11 строк кода? 141 | 142 | В первом случае мы работаем с большим колличеством абстракций одновременно: цикл, Math.max, условия maxSub, curSum 143 | Кода мало а кошель переполнен. 144 | 145 | А во втором случае одновременных абстракций мало - пользователь, объявление объекта 146 | 147 | :::note 148 | Важно! Понятность кода величина относительная. Способность узнавать абстракции **зависит от опыта**. 149 | Что такое React компонент мы узнали, только полсе того как выучили React 150 | 151 | Те концепции которые мы будем дальше рассматривать упрощают жизнь, только для тех кто их знает! 152 | Не удивляйтесь реакции ваших коллег на ED если они даже доку ни разу не открывали 153 | ::: 154 | -------------------------------------------------------------------------------- /packages/core/src/vfs/vfs.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path"; 2 | import { describe, expect, it } from "vitest"; 3 | import { addDirectory } from "./add-directory"; 4 | import { addFile } from "./add-file"; 5 | import { createVfsRoot } from "./create-root"; 6 | import { removeNode } from "./remove-node"; 7 | 8 | describe("vfs root", () => { 9 | it("allows adding files and creates folders automatically", () => { 10 | let vfs = createVfsRoot(join("/", "project", "src")); 11 | 12 | expect(vfs).toEqual({ 13 | type: "folder", 14 | path: join("/", "project", "src"), 15 | children: [], 16 | }); 17 | 18 | vfs = addFile(vfs, join("/", "project", "src", "index.ts")); 19 | vfs = addFile(vfs, join("/", "project", "src", "components", "button.ts")); 20 | vfs = addFile(vfs, join("/", "project", "src", "components", "input.ts")); 21 | vfs = addFile( 22 | vfs, 23 | join("/", "project", "src", "components", "input", "styles.ts"), 24 | ); 25 | 26 | expect(vfs).toEqual({ 27 | type: "folder", 28 | path: join("/", "project", "src"), 29 | children: [ 30 | { 31 | type: "file", 32 | path: join("/", "project", "src", "index.ts"), 33 | }, 34 | { 35 | type: "folder", 36 | path: join("/", "project", "src", "components"), 37 | children: [ 38 | { 39 | type: "file", 40 | path: join("/", "project", "src", "components", "button.ts"), 41 | }, 42 | { 43 | type: "file", 44 | path: join("/", "project", "src", "components", "input.ts"), 45 | }, 46 | 47 | { 48 | type: "folder", 49 | path: join("/", "project", "src", "components", "input"), 50 | children: [ 51 | { 52 | type: "file", 53 | path: join( 54 | "/", 55 | "project", 56 | "src", 57 | "components", 58 | "input", 59 | "styles.ts", 60 | ), 61 | }, 62 | ], 63 | }, 64 | ], 65 | }, 66 | ], 67 | }); 68 | }); 69 | 70 | it("allows add directory", () => { 71 | let vfs = createVfsRoot(join("/", "project", "src")); 72 | 73 | vfs = addDirectory(vfs, join("/", "project", "src", "components", "ui")); 74 | 75 | expect(vfs).toEqual({ 76 | type: "folder", 77 | path: join("/", "project", "src"), 78 | children: [ 79 | { 80 | type: "folder", 81 | path: join("/", "project", "src", "components"), 82 | children: [ 83 | { 84 | type: "folder", 85 | path: join("/", "project", "src", "components", "ui"), 86 | children: [], 87 | }, 88 | ], 89 | }, 90 | ], 91 | }); 92 | }); 93 | 94 | it("allows removing files", () => { 95 | let vfs = createVfsRoot(join("/", "project", "src")); 96 | 97 | vfs = addFile(vfs, join("/", "project", "src", "index.ts")); 98 | vfs = addFile( 99 | vfs, 100 | join("/", "project", "src", "components", "input", "styles.ts"), 101 | ); 102 | 103 | expect(vfs).toEqual({ 104 | type: "folder", 105 | path: join("/", "project", "src"), 106 | children: [ 107 | { 108 | type: "file", 109 | path: join("/", "project", "src", "index.ts"), 110 | }, 111 | { 112 | type: "folder", 113 | path: join("/", "project", "src", "components"), 114 | children: [ 115 | { 116 | type: "folder", 117 | path: join("/", "project", "src", "components", "input"), 118 | children: [ 119 | { 120 | type: "file", 121 | path: join( 122 | "/", 123 | "project", 124 | "src", 125 | "components", 126 | "input", 127 | "styles.ts", 128 | ), 129 | }, 130 | ], 131 | }, 132 | ], 133 | }, 134 | ], 135 | }); 136 | 137 | vfs = removeNode( 138 | vfs, 139 | join("/", "project", "src", "components", "input", "styles.ts"), 140 | ); 141 | 142 | expect(vfs).toEqual({ 143 | type: "folder", 144 | path: join("/", "project", "src"), 145 | children: [ 146 | { 147 | type: "file", 148 | path: join("/", "project", "src", "index.ts"), 149 | }, 150 | { 151 | type: "folder", 152 | path: join("/", "project", "src", "components"), 153 | children: [ 154 | { 155 | type: "folder", 156 | path: join("/", "project", "src", "components", "input"), 157 | children: [], 158 | }, 159 | ], 160 | }, 161 | ], 162 | }); 163 | 164 | vfs = removeNode(vfs, join("/", "project", "src", "index.ts")); 165 | 166 | expect(vfs).toEqual({ 167 | type: "folder", 168 | path: join("/", "project", "src"), 169 | children: [ 170 | { 171 | type: "folder", 172 | path: join("/", "project", "src", "components"), 173 | children: [ 174 | { 175 | type: "folder", 176 | path: join("/", "project", "src", "components", "input"), 177 | children: [], 178 | }, 179 | ], 180 | }, 181 | ], 182 | }); 183 | }); 184 | 185 | it("allows tracking two separate roots independently", () => { 186 | let vfs1 = createVfsRoot(join("/", "project1", "src")); 187 | let vfs2 = createVfsRoot(join("/", "project2", "src")); 188 | 189 | vfs1 = addFile(vfs1, join("/", "project1", "src", "index.ts")); 190 | 191 | expect(vfs1).toEqual({ 192 | type: "folder", 193 | path: join("/", "project1", "src"), 194 | children: [ 195 | { 196 | type: "file", 197 | path: join("/", "project1", "src", "index.ts"), 198 | }, 199 | ], 200 | }); 201 | expect(vfs2).toEqual({ 202 | type: "folder", 203 | path: join("/", "project2", "src"), 204 | children: [], 205 | }); 206 | 207 | vfs2 = addFile( 208 | vfs2, 209 | join("/", "project2", "src", "shared", "ui", "button.ts"), 210 | ); 211 | 212 | expect(vfs2).toEqual({ 213 | type: "folder", 214 | path: join("/", "project2", "src"), 215 | children: [ 216 | { 217 | type: "folder", 218 | path: join("/", "project2", "src", "shared"), 219 | children: [ 220 | { 221 | type: "folder", 222 | path: join("/", "project2", "src", "shared", "ui"), 223 | children: [ 224 | { 225 | type: "file", 226 | path: join( 227 | "/", 228 | "project2", 229 | "src", 230 | "shared", 231 | "ui", 232 | "button.ts", 233 | ), 234 | }, 235 | ], 236 | }, 237 | ], 238 | }, 239 | ], 240 | }); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /packages/core/src/rules/index.ts: -------------------------------------------------------------------------------- 1 | import { extname, join } from "node:path"; 2 | import { 3 | type Diagnostic, 4 | getAbstractionInstanceLabel, 5 | getFlattenFiles, 6 | getNodesRecord, 7 | rule, 8 | type Rule, 9 | } from "@evod/core"; 10 | import isGlob from "is-glob"; 11 | 12 | export function off(rule: T): T { 13 | if (Array.isArray(rule)) { 14 | return rule.map((r) => ({ ...r, severity: "off" as const })) as T; 15 | } 16 | return { ...rule, severity: "off" }; 17 | } 18 | 19 | export function warn(rule: T): T { 20 | if (Array.isArray(rule)) { 21 | return rule.map((r) => ({ ...r, severity: "warn" as const })) as T; 22 | } 23 | return { ...rule, severity: "warn" }; 24 | } 25 | 26 | export function requiredChildren(abstractions?: string[]) { 27 | return rule({ 28 | name: "default/required-children", 29 | severity: "error", 30 | check({ instance, root }) { 31 | const diagnostics: Array = []; 32 | const nodesRecord = getNodesRecord(root); 33 | 34 | const reuqiredAbstractions = Object.entries(instance.abstraction.children) 35 | .filter(([ext]) => !isGlob(ext)) 36 | .filter( 37 | ([, abstraction]) => 38 | !abstractions || abstractions.includes(abstraction.name) 39 | ); 40 | 41 | for (const [ext, abstraction] of reuqiredAbstractions) { 42 | const path = join(instance.path, ext); 43 | 44 | const instanceNode = nodesRecord[path]; 45 | if (instanceNode !== undefined) { 46 | continue; 47 | } 48 | 49 | const message = `Required abstraction "${abstraction.name}" in "${getAbstractionInstanceLabel(instance)}"`; 50 | 51 | if (extname(path) === "") { 52 | diagnostics.push({ 53 | message, 54 | location: { path }, 55 | fixes: [ 56 | { 57 | type: "create-folder", 58 | path, 59 | }, 60 | ], 61 | }); 62 | } else { 63 | diagnostics.push({ 64 | message, 65 | location: { path }, 66 | fixes: [ 67 | { 68 | type: "create-file", 69 | path, 70 | content: abstraction.fileTemplate?.(path) ?? "", 71 | }, 72 | ], 73 | }); 74 | } 75 | } 76 | 77 | return { 78 | diagnostics, 79 | }; 80 | }, 81 | }); 82 | } 83 | 84 | export function noUnabstractionFiles() { 85 | return rule({ 86 | name: "default/no-unabstraction-files", 87 | severity: "error", 88 | check({ instance, root }) { 89 | const record = getNodesRecord(root); 90 | const files = instance.childNodes.filter( 91 | (node) => record[node]?.type === "file" 92 | ); 93 | if (files.length > 0) { 94 | return { 95 | diagnostics: files.map((node) => ({ 96 | message: ` 'Unabstraction files are not allowed in ${instance.abstraction.name}'`, 97 | location: { path: node }, 98 | })), 99 | }; 100 | } 101 | 102 | return { 103 | diagnostics: [], 104 | }; 105 | }, 106 | }); 107 | } 108 | 109 | export function publicAbstraction(name: string): Rule { 110 | return { 111 | name: "default/public-abstraction", 112 | severity: "error", 113 | check({ instance, dependenciesMap, root }) { 114 | const diagnostics: Array = []; 115 | const nodesRecord = getNodesRecord(root); 116 | 117 | const childFilesEntires = instance.children.flatMap((childInstance) => { 118 | const instanceNode = nodesRecord[childInstance.path]; 119 | const files = getFlattenFiles(instanceNode); 120 | return files.map((file) => [file.path, childInstance] as const); 121 | }); 122 | 123 | const childFilesIndex = Object.fromEntries(childFilesEntires); 124 | 125 | for (const [path, childInstance] of childFilesEntires) { 126 | const importers = dependenciesMap.dependencyFor[path]; 127 | 128 | if (!importers) { 129 | continue; 130 | } 131 | 132 | if (childInstance.abstraction.name === name) { 133 | continue; 134 | } 135 | 136 | for (const importer of importers) { 137 | const dependencyInstance = childFilesIndex[importer]; 138 | if (dependencyInstance === undefined) { 139 | diagnostics.push({ 140 | message: `Imports of "${getAbstractionInstanceLabel(instance)}" bypassing the public api are forbidden`, 141 | location: { path }, 142 | }); 143 | } 144 | } 145 | } 146 | 147 | return { diagnostics }; 148 | }, 149 | }; 150 | } 151 | 152 | export function restrictCrossImports() { 153 | return rule({ 154 | name: "default/restrict-cross-imports", 155 | severity: "error", 156 | async check({ root, instance, dependenciesMap }) { 157 | const diagnostics: Array = []; 158 | const nodesRecord = getNodesRecord(root); 159 | 160 | const childFilesEntires = instance.children.flatMap((childInstance) => { 161 | const instanceNode = nodesRecord[childInstance.path]; 162 | const files = getFlattenFiles(instanceNode); 163 | return files.map((file) => [file.path, childInstance] as const); 164 | }); 165 | 166 | const childFilesIndex = Object.fromEntries(childFilesEntires); 167 | 168 | for (const [path, instance] of childFilesEntires) { 169 | const dependencies = dependenciesMap.dependencies[path]; 170 | 171 | for (const dependency of dependencies) { 172 | const dependencyInstance = childFilesIndex[dependency]; 173 | if (dependencyInstance === undefined) { 174 | continue; 175 | } 176 | 177 | if (dependencyInstance.path !== instance.path) { 178 | diagnostics.push({ 179 | message: `Forbidden dependency "${getAbstractionInstanceLabel(instance)}" <= "${getAbstractionInstanceLabel(dependencyInstance)}". 180 | cross imports are not allowed!`, 181 | location: { path }, 182 | }); 183 | } 184 | } 185 | } 186 | 187 | return { diagnostics }; 188 | }, 189 | }); 190 | } 191 | 192 | export function dependenciesDirection(order: string[]) { 193 | return rule({ 194 | name: `default/dependencies-direction`, 195 | severity: "error", 196 | async check({ root, instance, dependenciesMap }) { 197 | const diagnostics: Array = []; 198 | const nodesRecord = getNodesRecord(root); 199 | 200 | const childFilesEntires = instance.children.flatMap((childInstance) => { 201 | const instanceNode = nodesRecord[childInstance.path]; 202 | const files = getFlattenFiles(instanceNode); 203 | 204 | return files.map((file) => [file.path, childInstance] as const); 205 | }); 206 | 207 | const childFilesIndex = Object.fromEntries(childFilesEntires); 208 | 209 | for (const [path, instance] of childFilesEntires) { 210 | const dependencies = dependenciesMap.dependencies[path]; 211 | const instanceNameIndex = order.indexOf(instance.abstraction.name); 212 | 213 | for (const dependency of dependencies) { 214 | const dependencyInstance = childFilesIndex[dependency]; 215 | if (dependencyInstance === undefined) { 216 | continue; 217 | } 218 | 219 | const dependencyInstanceNameIndex = order.indexOf( 220 | dependencyInstance.abstraction.name 221 | ); 222 | 223 | if (dependencyInstanceNameIndex < instanceNameIndex) { 224 | diagnostics.push({ 225 | message: `Forbidden dependency "${instance.abstraction.name}" <= "${dependencyInstance.abstraction.name}". 226 | allowed dependencies order: ${order.join(" <= ")}`, 227 | location: { path }, 228 | }); 229 | } 230 | } 231 | } 232 | 233 | return { diagnostics }; 234 | }, 235 | }); 236 | } 237 | -------------------------------------------------------------------------------- /examples/todo/src/shared/ui/shadcn/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib/css"; 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 3 | import { 4 | CheckIcon, 5 | ChevronRightIcon, 6 | DotFilledIcon, 7 | } from "@radix-ui/react-icons"; 8 | 9 | import * as React from "react"; 10 | 11 | const DropdownMenu = DropdownMenuPrimitive.Root; 12 | 13 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 14 | 15 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 16 | 17 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 18 | 19 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 20 | 21 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 22 | 23 | const DropdownMenuSubTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef & { 26 | inset?: boolean; 27 | } 28 | >(({ className, inset, children, ...props }, ref) => ( 29 | 38 | {children} 39 | 40 | 41 | )); 42 | DropdownMenuSubTrigger.displayName = 43 | DropdownMenuPrimitive.SubTrigger.displayName; 44 | 45 | const DropdownMenuSubContent = React.forwardRef< 46 | React.ElementRef, 47 | React.ComponentPropsWithoutRef 48 | >(({ className, ...props }, ref) => ( 49 | 57 | )); 58 | DropdownMenuSubContent.displayName = 59 | DropdownMenuPrimitive.SubContent.displayName; 60 | 61 | const DropdownMenuContent = React.forwardRef< 62 | React.ElementRef, 63 | React.ComponentPropsWithoutRef 64 | >(({ className, sideOffset = 4, ...props }, ref) => ( 65 | 66 | 76 | 77 | )); 78 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 79 | 80 | const DropdownMenuItem = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef & { 83 | inset?: boolean; 84 | } 85 | >(({ className, inset, ...props }, ref) => ( 86 | 95 | )); 96 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 97 | 98 | const DropdownMenuCheckboxItem = React.forwardRef< 99 | React.ElementRef, 100 | React.ComponentPropsWithoutRef 101 | >(({ className, children, checked, ...props }, ref) => ( 102 | 111 | 112 | 113 | 114 | 115 | 116 | {children} 117 | 118 | )); 119 | DropdownMenuCheckboxItem.displayName = 120 | DropdownMenuPrimitive.CheckboxItem.displayName; 121 | 122 | const DropdownMenuRadioItem = React.forwardRef< 123 | React.ElementRef, 124 | React.ComponentPropsWithoutRef 125 | >(({ className, children, ...props }, ref) => ( 126 | 134 | 135 | 136 | 137 | 138 | 139 | {children} 140 | 141 | )); 142 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 143 | 144 | const DropdownMenuLabel = React.forwardRef< 145 | React.ElementRef, 146 | React.ComponentPropsWithoutRef & { 147 | inset?: boolean; 148 | } 149 | >(({ className, inset, ...props }, ref) => ( 150 | 159 | )); 160 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 161 | 162 | const DropdownMenuSeparator = React.forwardRef< 163 | React.ElementRef, 164 | React.ComponentPropsWithoutRef 165 | >(({ className, ...props }, ref) => ( 166 | 171 | )); 172 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 173 | 174 | function DropdownMenuShortcut({ 175 | className, 176 | ...props 177 | }: React.HTMLAttributes) { 178 | return ( 179 | 183 | ); 184 | } 185 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 186 | 187 | export { 188 | DropdownMenu, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuContent, 191 | DropdownMenuGroup, 192 | DropdownMenuItem, 193 | DropdownMenuLabel, 194 | DropdownMenuPortal, 195 | DropdownMenuRadioGroup, 196 | DropdownMenuRadioItem, 197 | DropdownMenuSeparator, 198 | DropdownMenuShortcut, 199 | DropdownMenuSub, 200 | DropdownMenuSubContent, 201 | DropdownMenuSubTrigger, 202 | DropdownMenuTrigger, 203 | }; 204 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/guide/ed-small.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Создание проекта на ED small 3 | description: Создание проекта на ED small 4 | --- 5 | 6 | import { FileTree } from "@astrojs/starlight/components"; 7 | import { Aside } from "@astrojs/starlight/components"; 8 | 9 | Как и было описано в базовом гайде, ED small предназначен для относительно небольших проектов. 10 | 11 | Очень приблизительный критерий, когда брать ED small: 12 | 13 | - До 12 человеко/месяцев разработки 14 | - До 1-2 человек фронтов 15 | 16 |
17 | Про мифический человеко/месяц: 18 | 19 | Человеко/месяц — это единица измерения трудозатрат. Один человеко/месяц — это 160 часов работы. 20 | 21 | **Да, мы знаем, что это некорректная оценка, и у неё есть масса недостатков.** Её единственная цель здесь — помочь оценить **порядок сложности** проекта! 22 | 23 |
24 | 25 | ## Зачем нужен ED small? 26 | 27 | Многие скажут: **"Если проект маленький, то зачем там нужна архитектура?"** 28 | 29 | ED small спроектирован так, чтобы при минимальных затратах времени и усилий получить самые важные архитектурные преимущества. 30 | 31 | - **Стандартизация**: Возможность переиспользовать опыт разработки проектов под ED. 32 | - **Модульность**: Простота разработки независимых частей приложения. 33 | - **Расширяемость**: Легкость добавления новых возможностей. 34 | - **Надежность**: Минимальные архитектурные ограничения, которые делают изменения более предсказуемыми. 35 | - **Дальнейшее развитие**: Подготовка к переходу на более продвинутые архитектуры при росте проекта. 36 | 37 | ## Начало работы с vite SPA 38 | 39 | ED small подходит для работы с любыми фреймворками и технологиями. В этом гайде будет описано использование в наиболее популярном формате. **React + Vite + SPA**. 40 | 41 | Особенности работы с другими технологиями будут описаны в отдельных гайдах. 42 | 43 | ### Базовая структура проекта 44 | 45 | [Материалы (1-init)](https://github.com/clean-frontend/miro-materials/tree/main/parts/1-init) 46 | 47 |
48 | Видео: Базовая структура проекта 49 | 58 |
59 | 60 | --- 61 | 62 | ### Настройка eslint 63 | 64 | [Материалы (2-linter)](https://github.com/clean-frontend/miro-materials/tree/main/parts/2-linter) 65 | 66 |
67 | Видео: Настройка eslint 68 | 77 |
78 | 79 | --- 80 | 81 | ### react-router-dom 82 | 83 | [Материалы (3-router)](https://github.com/clean-frontend/miro-materials/tree/main/parts/3-router) 84 | 85 |
86 | Видео: react-router-dom 87 | 96 |
97 | 98 | --- 99 | 100 | ### config 101 | 102 | [Материалы (4-config)](https://github.com/clean-frontend/miro-materials/tree/main/parts/4-config) 103 | 104 |
105 | Видео: config 106 | 115 |
116 | 117 | --- 118 | 119 | ### api, openapi-typescript, react-query, msw 120 | 121 | [Материалы (5-api)](https://github.com/clean-frontend/miro-materials/tree/main/parts/5-api) 122 | 123 |
124 | Видео: api, openapi-typescript, react-query, msw 125 | 134 |
135 | 136 | --- 137 | 138 | ### shadcn, tailwindcss 139 | 140 | [Материалы (6-shadcn)](https://github.com/clean-frontend/miro-materials/tree/main/parts/6-shadcn) 141 | 142 |
143 | Видео: shadcn, tailwindcss 144 | 153 |
154 | 155 | --- 156 | 157 | ### Пример реализации `Grouped module` 158 | 159 | [Материалы (7-auth-pages)](https://github.com/clean-frontend/miro-materials/tree/main/parts/7-auth-pages) 160 | 161 |
162 | Видео: Пример реализации Grouped module 163 | 172 |
173 | 174 | --- 175 | 176 | ### Работа с сессией (access / refresh token) 177 | 178 | [Материалы (8-session)](https://github.com/clean-frontend/miro-materials/tree/main/parts/8-session) 179 | 180 |
181 | Видео: Работа с сессией (access / refresh token) 182 | 191 |
192 | 193 | --- 194 | 195 | ### Пример реализации `Module with compose` (логика) 196 | 197 | [Материалы (9-boards-list)](https://github.com/clean-frontend/miro-materials/tree/main/parts/9-boards-list) 198 | 199 |
200 | Видео: Пример реализации Module with compose (логика) 201 | 210 |
211 | 212 | --- 213 | 214 | ### Пример реализации `Module with compose` (отображение) 215 | 216 | [Материалы (9-boards-list)](https://github.com/clean-frontend/miro-materials/tree/main/parts/9-boards-list) 217 | 218 |
219 | Видео: Пример реализации Module with compose (отображение) 220 | 229 |
230 | 231 | --- 232 | 233 | ### Пример реализации Flat module и взаимодействия модулей 234 | 235 | [Материалы (9-boards-list)](https://github.com/clean-frontend/miro-materials/tree/main/parts/9-boards-list) 236 | 237 |
238 | 239 | Видео: Пример реализации Flat module и взаимодействия модулей 240 | 241 | 250 |
251 | -------------------------------------------------------------------------------- /apps/docs/src/content/docs/guide/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Быстрый старт 3 | description: Быстрый старт 4 | --- 5 | 6 | import { FileTree } from "@astrojs/starlight/components"; 7 | import { Aside } from "@astrojs/starlight/components"; 8 | 9 | В этом разделе мы рассмотрим все основные паттерны ED. 10 | 11 | Этой статьи будет достаточно, чтобы уже получить пользу от использования этого паттерна. 12 | 13 | Дальнейшие статьи в разделе гайды будут раскрывать отдельные аспекты работы с ED. 14 | 15 | ## Какие проекты можно делать на ED 16 | 17 | ED предназначен для работы на основных web фреймворках: vue, react, svelte, solid. 18 | 19 | В простых модификациях он будет полезен даже в самых базовых проектах. 20 | 21 | Единственное, для разработки библиотек ED не предназначен. Но вы всегда можете взять часть паттернов, если они вам будут полезны. 22 | 23 | ## Модификации ED 24 | 25 | Архитектура представлена в 4-х модификациях 26 | 27 | 1. **ED small** - для маленьких проектов (до 12 человеко/месяцев разработки) 28 | 2. **ED medium** - для средних проектов (команда до 5-6 человек фронтов) 29 | 3. **ED enterprise** - для крупных проектов (больше одной команды) 30 | 4. **ED monorepo** - для команды, которая работает над несколькими проектами 31 | 32 | В текущем гайде будут описаны первые 2 модификации. 3-4 в будущем будут описаны в других статьях 33 | 34 | ## Из чего состоит ED. 35 | 36 | Для внешнего наблюдателя это просто папочки и файлики. На самом же деле это абстракции и архитектурные границы. 37 | 38 | Подробнее об этом мы поговорим далее. Но пока вам никто не мешает об этом думать, как о папочках и файликах 39 | 40 | Первое, с чего начинается ED, это с выделения слоёв. 41 | 42 | ## Выделение основных слоёв 43 | 44 | Как и в большей части архитектур, в ED на верхнем уровне код организован по слоям. 45 | 46 | Всего есть 4 слоя верхнего уровня. 47 | 48 | 49 | 50 | - src/ 51 | - app/ 52 | - features/ 53 | - services/ 54 | - shared/ 55 | 56 | 57 | 58 |
59 | Отличия от FSD 60 | 61 | - В ED `shared` в отличие от fsd `shared` может содержать бизнес-логику. 62 | - `services` очень похожи на `entities`, но тут нет ограничений на семантику "объекта из бизнеса" 63 | - `features` это крупные куски функциональности приложения. По сути это слои `pages` `widgets` `features` `entities` в одном. 64 | - Отдельного слоя `pages` нет. Код страниц находится в `features`. Если страницы содержат несколько фич, то композиция находится в `app` 65 | - Слайсы у нас называются модулями, а сегменты группами. А ещё у нас разрешены подмодули. 66 | 67 |
68 | 69 | ### App 70 | 71 | --- 72 | 73 | Слой точка входа. Здесь происходит запуск проекта, глобальная конфигурация, связь фич в единое приложение 74 | 75 | Внутренняя структура `app` не стандартизирована и сильно зависит от приложения. 76 | 77 | Слой, в котором должна лежать **самая часто меняющаяся логика** 78 | 79 |
80 | Примеры кода, который тут может быть: 81 | 82 | - `app.tsx` корневой компонент приложения 83 | - `root-layout.tsx` общий лейаут всего приложения 84 | - `root-header.tsx` заголовок всего приложения. Может делегировать задачи компонентам из `features` 85 | - `global.css` глобальные стили 86 | - `router.tsx` инициализация `react-router` 87 | - `providers.tsx` компонент, который использует глобальные react провайдеры 88 | 89 | ```tsx 90 | export function Providers({ children }: { children: React.ReactNode }) { 91 | return ( 92 | 93 | {children} 94 | 95 | ); 96 | } 97 | ``` 98 | 99 | - В `next.js` app остаётся самим собой, только старайтесь большую часть логики уносить в `features` 100 | 101 |
102 | 103 | ### Features 104 | 105 | --- 106 | 107 | Основной слой. Здесь должна находиться большая часть кода приложения. 108 | 109 | Каждая папка слоя `features` это реализация **крупного независимого куска функциональности** 110 | 111 | 118 | 119 | Пример `features` для планировщика задач 120 | 121 | 122 | - src/ 123 | - app/ 124 | - features/ 125 | - auth/ 126 | - task-list/ 127 | - sidebar/ 128 | - settings/ 129 | 130 | 131 | 132 | #### Ограничение взаимодействия с `app` 133 | 134 | 137 | 138 | Это крайне важное ограничение даёт нам следующее: 139 | 140 | - В `app` находятся часто меняющиеся штуки. Зависимость на часто меняющийся код делает фичи ненадёжными [sdp](http://blog.antidasoftware.com/2011/07/stable-dependencies-principle.html) 141 | - Импорт из app почти всегда - циклическая зависимость. Циклические зависимости всегда плохо, и для компилятора, и для мозга. [adp](https://en.wikipedia.org/wiki/Acyclic_dependencies_principle) 142 | 143 | #### Структура фичи 144 | 145 | Каждая фича по своей сути это модуль! 146 | 147 | Так как **модули в ED бывают большие** их структура подробно описана [ниже](#структура-модуля). 148 | 149 | Сейчас важно понимать, что в фиче может быть всё: И компоненты отображения, и логика, и инфраструктура, и страница. 150 | 151 | Главный критерий, все эти вещи должны иметь высокую **смысловую связность** 152 | 153 | #### Взаимодействие фич 154 | 155 | Тут есть различие между "маленькой" модификацией и "средней" 156 | 157 | ##### Средняя модификация 158 | 159 | 163 | 164 | 165 | - src/ 166 | - app/ 167 | - features/ 168 | - auth/ 169 | - task-list/ // из task-list нельзя импортировать auth 170 | 171 | 172 | 173 | Это ограничение очень важно в средних и крупных проектах, чтобы можно было рассматривать фичи как независимые блоки. 174 | 175 | - Таким образом сильно возрастает понятность. Так как можно рассматривать фичу в отрыве от других 176 | - Увеличивается надёжность. Так как изменение в одной фиче с меньшей вероятностью сломает другую 177 | - Защита от циклических зависимостей. 178 | 179 | В реальности, полностью изолированные фичи бывают редко. Поэтому для взаимодействия фич, с запретом на прямой импорт используются паттерны _слабой связанности_ или по-другому _dependency inversion (Инверсия зависимостей)_ 180 | 181 | Звучит сложно, но по факту это: связь через общий стейт, слоты, рендер-пропсы, события, контекст, и _dependency injection_ 182 | 183 |
184 | Подробнее о DI 185 | 186 | - [Dependency Inversion](https://www.youtube.com/watch?v=9gOrAh7H88o) 187 | - [Также в курсе по FSD в сообществе, есть большой практический урок по этой технике](https://evocomm.space/course/fsd) 188 | 189 |
190 | 191 | Это самая сложная часть ED. По этой причине в "маленькой" модификации это ограничение накладывать нецелесообразно 192 | 193 | ##### Маленькая модификация 194 | 195 | В маленькой модификации прямой импорт разрешён. Но это не отменяет, что этих импортов должно быть как можно меньше! 196 | 197 | 201 | 202 | Чем меньше связей между фичами будет, тем лучше. В идеале их вообще не должно быть 203 | 204 | ### Services 205 | 206 | Слой переиспользуемых бизнес [модулей](#структура-модуля). **Могут хранить не только логику, но и представление**. 207 | 208 | 214 | 215 | Как и фича, каждый сервис это самодостаточный модуль. Но в отличие от фичи он не реализует функционал приложения, 216 | а помогает фичам выполнять свою работу. 217 | 218 | Чаще всего модули в services нужны, если есть большое количество переиспользуемой между фичами логики. 219 | 220 | Пример `services` для планировщика задач 221 | 222 | 223 | - src/ 224 | - app/ 225 | - features/ 226 | - auth/ 227 | - task-list/ 228 | - manage-settings/ 229 | - services/ 230 | - session/ 231 | - settings/ 232 | 233 | 234 | 235 | В данном случае в `session` скорее всего находится хранилище сессии, которое используется в большей части других фич. 236 | А в `settings` хранятся настройки, которые редактируются в фиче `manage-settings` и используются в `task-list` 237 | 238 | #### Ограничение взаимодействия с `app` и `features` 239 | 240 | Как фичи не могут импортировать `app`, так и сервисы не могут импортировать `features` и `app` 241 | 242 | Сделано это по тем же самым причинам. Но для сервисов это ещё важнее. Они чаще всего переиспользуются в нескольких местах. 243 | 244 | Зависимость на более часто меняющиеся фичи сделала бы сервисы неустойчивыми, и подвергла бы опасности сразу несколько других фич. 245 | 246 | #### Взаимодействие сервисов 247 | 248 | Тут нет однозначного решения. Базово я разрешаю взаимодействие сервисов друг с другом. 249 | 250 | Так как чаще всего _dependency inversion_ на таком уровне вызывает очень много сложностей. 251 | 252 | Но если появляются проблемы, вводится такое же ограничение как и для фич. 253 | 254 | 260 | 261 | ### Shared 262 | 263 | Слой ядро приложения. Здесь расположены вещи, которые используются в приложении повсеместно 264 | 265 | 273 | 274 | Старайтесь располагать в `shared` только следующее: 275 | 276 | - Глобальные бизнес типы. Типы редко меняются и не вызывают багов 277 | - Глобальную инфраструктуру. Инстансы сторов, нотификации, интернационализацию, тему 278 | Обычно такая инфраструктура используется широко, а меняется редко 279 | - Глобальные константы, связывающие приложение. Например, константы роутинга и результат чтения .env 280 | 281 |
282 | Важные исключения: 283 | 284 | По своей сути `uikit` и `api` не должны находиться в `shared` 285 | 286 | Это крайне часто меняющиеся модули. Но все попытки унести их из `shared` не удались. 287 | 288 | Поэтому знайте, что `uikit` и `api` вечные источники багов. И относитесь к ним соответственно 289 | 290 |
291 | 292 | Структура `shared` жёстко не стандартизирована, но всегда похожа 293 | 294 | Пример стандартного **shared** 295 | 296 | _всё ниже перечисленное опционально и может не понадобиться в вашем случае_ 297 | 298 | 299 | - src/ 300 | - shared/ 301 | - domain/ // глобальные бизнес типы. Нужные для работы инфраструктуры и всего приложения 302 | - ids.ts 303 | - events.ts 304 | - session.ts 305 | - user.ts 306 | - ui/ // uikit приложения 307 | - kit/ 308 | - button.tsx 309 | - table/ // переиспользуемый компонент таблицы 310 | - api/ // обычно автоматически генерируемый api instance 311 | - api-instance.ts // инстанс axios 312 | - generated.ts 313 | - model/ // работа с глобальными данными 314 | - routes.ts // константы для роутинга 315 | - config.ts // получение доступа к .env 316 | - store.ts // инстанс redux 317 | - lib/ // Глобальная инфраструктура и хелперы 318 | - notifications/ 319 | - i18n/ 320 | - react/ 321 | - use-mutation-observer.ts 322 | - date.ts 323 | 324 | 325 | 326 | #### `domain, model, ui, lib` 327 | 328 | Весь код `shared` разделён на стандартные группы: 329 | 330 | - `domain`: Самые важные бизнес типы и правила. Эта группа должна быть изолированной и ни на кого не зависеть 331 | - `model`: Работа с глобальным состоянием 332 | - `ui`: Глобальные компоненты 333 | - `lib`: модули глобальной инфраструктуры 334 | 335 | #### Ограничение взаимодействия с `app`, `features` и `services` 336 | 337 | `shared` это слой, в котором находится корневая логика. Он не может напрямую работать ни с одним другим слоем. 338 | 339 | При этом его могут и будут импортировать все другие слои. 340 | 341 | Это правило крайне важно и не должно нарушаться. `shared` - опасный слой. С ним всегда нужно быть на чеку) 342 | 343 | ## Схема слоёв 344 | 345 | ### "Маленькая" модификация 346 | 347 | ![Small schema](../../../assets/small-schema.png) 348 | 349 | ### "Средняя" модификация 350 | 351 | ![Medium schema](../../../assets/medium-schema.png) 352 | 353 | ## Структура модуля 354 | 355 | --- 356 | 357 | `features` и `services` - в ED содержат модули. Но по вашему желанию вы можете создавать модули и в `app` и `shared` 358 | 359 | Также ED поддерживает концепцию `sub-modules`, таким образом Модули в ED могут быть **очень большими**. 360 | 361 | По сути модули в ED можно рассматривать как мини-приложения, у которых есть своя архитектура 362 | 363 | ### Этапы эволюции модулей 364 | 365 | Мы против оверхеда, поэтому у модулей есть этапы эволюции, от простого к сложному. 366 | 367 | Всегда при создании модулей **начинайте с самых простых этапов**, а потом проводите рефакторинг, если это требуется. 368 | 369 | По нашему опыту это самый эффективный подход! _Излишняя архитектура хуже, чем её недостаток_ 370 | 371 | 377 | 378 | ### Этап 1: Single file module 379 | 380 | Да-да, самые простые модули могут состоять только из одного файла! 381 | 382 |
383 | 384 | Вот пример фичи `todo-list`, на самых первых этапах 385 | 386 | 387 | ```tsx 388 | import { useState } from "react"; 389 | 390 | export function TodoListPage() { 391 | const [todos, setTodos] = useState([]); 392 | const [input, setInput] = useState(""); 393 | 394 | const addTodo = (e) => { 395 | e.preventDefault(); 396 | if (!input.trim()) return; 397 | setTodos([...todos, { id: Date.now(), text: input, done: false }]); 398 | setInput(""); 399 | }; 400 | 401 | const toggleTodo = (id) => { 402 | setTodos( 403 | todos.map((todo) => 404 | todo.id === id ? { ...todo, done: !todo.done } : todo, 405 | ), 406 | ); 407 | }; 408 | 409 | const deleteTodo = (id) => { 410 | setTodos(todos.filter((todo) => todo.id !== id)); 411 | }; 412 | 413 | return ( 414 |
415 |

Todo

416 | 417 |
418 | setInput(e.target.value)} 421 | className="flex-1 border p-2 rounded-l" 422 | placeholder="Add todo..." 423 | /> 424 | 430 |
431 | 432 |
    433 | {todos.map((todo) => ( 434 |
  • 435 | toggleTodo(todo.id)} 439 | className="mr-2" 440 | /> 441 | 446 | {todo.text} 447 | 448 | 454 |
  • 455 | ))} 456 |
457 | 458 | {todos.length > 0 && ( 459 |
460 | {todos.filter((t) => !t.done).length} items left 461 |
462 | )} 463 |
464 | ); 465 | } 466 | ``` 467 | 468 |
469 | 470 | Чаще всего модули очень быстро перерастают этот этап. Но иногда нет! 471 | 472 | **С файлом до 400 строк работать приемлемо.** 473 | 474 | При этом скорость разработки и рефакторинга такого модуля выше, чем модуля из 20 файлов по 20 строк 475 | 476 | Не пренебрегайте этим подходом, особенно на этапе прототипирования. 477 | 478 | ### Этап 2: Flat module 479 | 480 | Вот ваш файл стал больше 400 строк, и стало неудобно. Что выделяем `components/hooks/ui/model`? 481 | 482 | **Нет ещё рано!** 483 | 484 | Нет ничего хуже папки, в которой один файл. 485 | 486 | Второй этап эволюции, это создание плоской структуры разнородных модулей 487 | 488 | Вот пример фичи `todo-list` на втором этапе 489 | 490 | 491 | 492 | - todo-list/ 493 | - todo-list-page.tsx 494 | - use-todo-list.tsx 495 | - api.tsx 496 | - use-intersection-observer.tsx 497 | - index.ts 498 | 499 | 500 | 501 | Здесь мы просто разделили код на функции. А потом вынесли их в отдельные файлы. 502 | Файлы называем в соответствии с содержимым. Избегаем названия `hooks` `components` 503 | 504 | 508 | 509 | Это число выведено эмпирически и связано с концепцией [**кошелёка миллера**](https://ru.wikipedia.org/wiki/%D0%9C%D0%B0%D0%B3%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%BE%D0%B5_%D1%87%D0%B8%D1%81%D0%BB%D0%BE_%D1%81%D0%B5%D0%BC%D1%8C_%D0%BF%D0%BB%D1%8E%D1%81-%D0%BC%D0%B8%D0%BD%D1%83%D1%81_%D0%B4%D0%B2%D0%B0) 510 | 511 | #### Public api 512 | 513 | На этом этапе уже не понятно, какой код предназначен для внешнего использования, а какой должен оставаться внутри. 514 | 515 | Поэтому мы добавляем `public-api`. 516 | 517 | Это `index.ts` файл, в котором вы реэкспортируем (иногда со сменой названия) доступные извне элементы. 518 | 519 | ```tsx 520 | export { TodoListPage } from "./todo-list-page"; 521 | ``` 522 | 523 |
524 | Для тех у кого next.js 525 | 526 | Если в index файлах нет сайд эффектов, то в vite tree-shaking работает корректно 527 | 528 | С next.js же встречались проблемы. Поэтому там часто используется другой подход с public-api. 529 | 530 | Все приватные файлы начинаются с \_, а публичные без. Таким образом не обязательно создавать только один public файл 531 | 532 | 533 | 534 | - todo-list/ 535 | - todo-list-page.tsx 536 | - \_use-todo-list.tsx 537 | - \_api.tsx 538 | - \_use-intersection-observer.tsx 539 | 540 | 541 | 542 |
543 | 544 | #### Explicit dependencies (экспериментально) 545 | 546 | При взаимодействии модулей друг с другом, часто крайне сложно проследить все зависимости модуля от других модулей. 547 | 548 | Поэтому все зависимости, которые используются внутри, можно реэкспортировать через специальный `deps.ts` файл. 549 | 550 | _Особенно это важно для `services` и `features`, если разрешены кросс импорты (из фичи фичу, из сервиса сервис)_ 551 | 552 | Пример: 553 | 554 | 555 | 556 | - features/ 557 | - settings/ 558 | - auth/ 559 | - todo-list/ 560 | - index.ts 561 | - deps.ts 562 | - todo-list-page.tsx 563 | - use-todo-list.tsx 564 | - create-todo-form.tsx 565 | 566 | 567 | 568 | deps.tsx 569 | 570 | ```tsx 571 | export { useSession } from "@/features/auth"; 572 | export { useSettings } from "@/features/settings"; 573 | ``` 574 | 575 | todo-list-page.tsx 576 | 577 | ```tsx 578 | import { useSession, useSettings } from "./deps"; 579 | ``` 580 | 581 | #### Sub modules 582 | 583 | По сути, когда мы сделали такое разделение, мы получили модуль, который состоит из нескольких однофайловых модулей. 584 | 585 | Но они не обязаны быть однофайловыми. **Любой дочерний модуль может быть на любом этапе эволюции** 586 | 587 | 588 | 589 | - todo-list/ 590 | - index.ts 591 | - todo-list-page.tsx 592 | - use-todo-list.tsx 593 | - create-todo-form/ 594 | - index.ts 595 | - create-todo-form.tsx 596 | - use-create-todo.tsx 597 | 598 | 599 | 600 | 601 | 602 | #### Опасности вложенности 603 | 604 | Благодаря подмодулям, на втором уровне можно оставаться очень долго. 605 | 606 | Но глубокая вложенность не так хороша, как кажется. Глубокие древовидные структуры сложны для понимания. 607 | Намного комфортнее читаются **однородные списки** (слой features как раз пример однородного списка) 608 | 609 | Поэтому чаще всего для преодоления ограничения в ~6 элементов подмодулям лучше предпочитать создание `групп` 610 | 611 | ### Этап 3: Grouped module 612 | 613 | Как было сказано выше, если для разнородных папок комфортно < 6 элементов. 614 | То для однородных папок это количество резко возрастает (до 20 элементов. Сильно зависиот от однородности) 615 | 616 | Поэтому мы можем разделить подмодули на группы, таким образом сильно увеличив допустимый размер модуля 617 | 618 | #### Что такое группа. 619 | 620 | Группа это объединение нескольких модулей на основании общего признака: 621 | 622 | Примеры групп: 623 | 624 | - components 625 | - hooks 626 | - services 627 | - features 628 | - ui 629 | - model 630 | - lib 631 | - api 632 | 633 | 643 | 644 | Кстати, все слои это тоже группы.😉 645 | 646 | #### Стандартные группы 647 | 648 | Существуют достаточно удачные группы, которые хорошо себя показали: 649 | 650 | ##### Группа: `ui` 651 | 652 | По сути это объединение всех компонентов, реже хуков, которые отвечают целиком и полностью за отображение. И не несут в себе сложной логики 653 | 654 | Примеры: 655 | 656 | - todo-list-page.tsx 657 | - todo-card.tsx 658 | - todo-form.tsx 659 | - use-render-arrows.tsx 660 | 661 | ##### Группа: `model` 662 | 663 | Группа, в которой лежит основная работа с данными. 664 | 665 | Если вы на чистом react, здесь лежат хуки, которые манипулируют данными в отрыве от отображения. 666 | 667 | Если у вас стейт менеджер, то здесь будет лежать вся логика работы со стейт менеджером 668 | 669 | Примеры: 670 | 671 | - use-todo-list.ts 672 | - todo-list.slice.ts 673 | - todo-list-store.ts 674 | - todo-item.ts 675 | 676 | ##### Группа: `lib` 677 | 678 | Группа, в которой находится инфраструктурный код. 679 | Это код чаще всего предоставляет более удобные обёртки над браузерным api и библиотеками. 680 | Или просто упрощает рутинные задачи 681 | 682 | Примеры: 683 | 684 | - use-mutation-observer.ts 685 | - date.ts 686 | 687 | ##### Группа: `api` 688 | 689 | Группа для кода работы с api и типами контрактов. 690 | 691 | ##### Группа: `domain` 692 | 693 | Если логика в `model` становится очень сложной. 694 | 695 | То код, описывающий самые важные бизнес процессы: 696 | 697 | - Расчёт скидки 698 | - Вычисление отпуска 699 | - Получение прогресса 700 | - Расчёт координат при перемещении элемента по карте 701 | 702 | Можно вынести в виде `чистых функций` в группу `domain` 703 | 704 | Также в `domain` находятся все типы, над которыми эти чистые функции проводят манипуляции 705 | 706 | Примеры: 707 | 708 | - map-node.ts 709 | - get-intersections.ts 710 | - compute-next-lesson.ts 711 | 712 | ##### Группа: `view-model` 713 | 714 | В некоторых кейсах модуль содержит большое количество логики, которая обрабатывает пользовательский ввод 715 | 716 | Обычно это происходит, если реализуется `dnd` или анимации 717 | 718 | В таком случае этот код можно вынести в отдельную группу `view-model` 719 | 720 | Пример: 721 | 722 | - use-dnd.tsx 723 | - use-animation.tsx 724 | 725 | #### Вложенные группы 726 | 727 | Внутри группы вы можете группировать модули и по другим признакам. 728 | 729 | Это работает точно так же, как с подмодулями. Только не забывайте, что излишняя глубина это неудобно 730 | 731 | Пример: 732 | 733 | 734 | 735 | - ui/ 736 | - fields/ 737 | - file-field.tsx 738 | - text-field.tsx 739 | - select-field.tsx 740 | - date-field.tsx 741 | - create-user-form.tsx 742 | - update-user-form.tsx 743 | 744 | 745 | 746 | #### Пример модуля с группами 747 | 748 | 749 | 750 | - todo-list/ 751 | - index.ts 752 | - api.ts 753 | - model/ 754 | - todo-item.ts 755 | - use-create-todo.ts 756 | - use-update-todo.ts 757 | - ui/ 758 | - pages/ 759 | - todo-list-page.tsx 760 | - todo-details-page.tsx 761 | - fields/ 762 | - file-field.tsx 763 | - text-field.tsx 764 | - select-field.tsx 765 | - date-field.tsx 766 | - create-todo-form.tsx 767 | - todo-details-form.tsx 768 | - todo-item.module.css 769 | - todo-item.tsx 770 | 771 | 772 | 773 | #### Группы и подмодули 774 | 775 | Появление групп не отменяет подмодули. Иногда чем разделять код на группы, лучше разбить весь код на несколько подмодулей 776 | 777 | 778 | 779 | - todo-list/ 780 | - index.ts 781 | - api.ts 782 | - ui/ 783 | - file-field.tsx 784 | - text-field.tsx 785 | - select-field.tsx 786 | - date-field.tsx 787 | - todo-list/ 788 | - index.ts 789 | - todo-list-page.tsx 790 | - create-todo-form.tsx 791 | - use-create-todo.ts 792 | - todo-item.tsx 793 | - todo-details/ 794 | - index.ts 795 | - todo-details-page.tsx 796 | - todo-details-form.tsx 797 | - use-update-todo.ts 798 | 799 | 800 | 801 | #### Когда переходить на следующий этап? 802 | 803 | На этом этапе можно разрабатывать модули любого размера. 804 | 805 | Но здесь есть важная проблема - **связи между подмодулями хаотичны, и в них может быть очень сложно разобраться** 806 | 807 | Если вы столкнулись с такой проблемой, вам поможет следующий этап: 808 | 809 | ### Этап 4: Module with compose 810 | 811 | Это сложный, но при этом крайне мощный паттерн борьбы с хаотическими зависимостями. 812 | 813 | Его ближайщий аналог это DIP принцип из SOLID. 814 | 815 | Основная суть этого паттерна, чтобы **убрать зависимости между подмодулями** в `model` и `ui` 816 | 817 | Для этого используются инструменты слабой связанности. 818 | (для `ui` слоты и рендер-пропсы, для `model` события, DI, или простая связь через параметры) 819 | 820 | После того как мы получили **набор независимых элементов**, 821 | всё это объединяется в специальных компонентах `медиаторах` 822 | 823 | Для них я обычно создаю отдельную группу `compose` 824 | Связи между компонентами в `compose` разрешены 825 | 826 | Пример: 827 | 828 | 829 | 830 | - todo-list/ 831 | - index.ts 832 | - api.ts 833 | - compose/ 834 | - todo-list-page.tsx 835 | - todo-details-page.tsx 836 | - create-todo-form.tsx 837 | - todo-details-form.tsx 838 | - domain/ 839 | - todo-item.ts 840 | - model/ 841 | - use-todo-list.ts 842 | - use-delete-todo.ts 843 | - use-create-todo.ts 844 | - use-update-todo.ts 845 | - ui/ 846 | - fields/ 847 | - file-field.tsx 848 | - text-field.tsx 849 | - select-field.tsx 850 | - date-field.tsx 851 | - todo-page-layout.tsx 852 | - todo-item.tsx 853 | - common-fields.tsx 854 | - update-button.tsx 855 | - delete-button.tsx 856 | 857 | 858 | 859 | Тогда `todo-list-page.tsx` выглядел бы как-то так 860 | 861 | ```tsx 862 | export function TodoListPage() { 863 | const todoList = useTodoList(); 864 | const deleteTodo = useDeleteTodo(todoList); 865 | const createTodo = useCreateTodo(todoList); 866 | const updateTodo = useUpdateTodo(todoList); 867 | 868 | return ( 869 | } 871 | todos={todoList.list.map((item) => ( 872 | 873 | 874 | 875 | 876 | ))} 877 | /> 878 | ); 879 | } 880 | ``` 881 | 882 |
883 | Где можно почитать подробнее 884 | 885 | Документация ещё активно разрабатывается. Позже об этом паттерне появится отдельная статья. 886 | Сейчас вы можете изучить следующие материаллы: 887 | 888 | - [О том как автор пришёл к такому подходу](https://www.youtube.com/watch?v=VFipNg6sVMU) 889 | - [Версия этого паттерна в gof](https://refactoring.guru/ru/design-patterns/mediator) 890 | - [Версия этого паттерна в grasp](https://hackernoon.com/grasp-principles-part-3-polymorphism-pure-fabrication-indirection-protected-variations) 891 | - [Версия этого паттерна в solid](https://ru.wikipedia.org/wiki/%D0%9F%D1%80%D0%B8%D0%BD%D1%86%D0%B8%D0%BF_%D0%B8%D0%BD%D0%B2%D0%B5%D1%80%D1%81%D0%B8%D0%B8_%D0%B7%D0%B0%D0%B2%D0%B8%D1%81%D0%B8%D0%BC%D0%BE%D1%81%D1%82%D0%B5%D0%B9х) 892 | - [Если вы в сообществе, паттерны 8 - 12](https://evocomm.space/course/react-patterns) 893 | 894 |
895 | 896 | #### Ограничения этапа 4 897 | 898 | Самые главные здесь ограничения технические. Не все инструменты поддерживают слабую связанность. 899 | И не во всех ситуациях, это даёт нужную производительность 900 | 901 | Но в большей части ситуаций, такой подход помогает сильно уменьшить сложность модуля! 902 | 903 | ### Выводы по эволюции модулей 904 | 905 | Может быть вы уже запутались во всех вариантах модулей здесь представленных. 906 | 907 | _Это нормально, подобная гибкость чуть усложняет вход._ 908 | 909 | При более глубоком рассмотрении оказывается, что это всё вариации вокруг трёх понятий: `модуль` `группа` и `public-api`. 910 | 911 | **На самом деле, этот подход одновременно и стандартизирует подходы. И предоставляет гибкость** 912 | 913 | По мере усложнения ваших модулей, вы можете строить удобный и поддерживаемый код из кирпичиков `модулей` и `групп` 914 | 915 | При этом не тратить время на постоянный оверхед от неудобных ритуалов и паттернов. 916 | 917 | 921 | 922 | ## Что дальше? 923 | 924 | Пользуясь советами из этого гайда, вы уже сейчас можете начать использовать ED. 925 | 926 | - По любым вопросам пишите в чат архитектуры в [tg](https://t.me/+VugvWY1dtdRhM2Uy). 927 | - Смотрите примеры разработки на ED в [youtube](https://www.youtube.com/playlist?list=PLMlifxDLpB1DXcNcXK4Wl8jtC2SOyWETr) 928 | 929 | Если хотите понять, а как это всё вообще работает. Приходите читать [продвинутую часть документации](/deep-dive) 930 | 931 | Сейчас эта часть только начинает развиваться, но со временем там будет появляться всё больше статей, раскрывающих концепции и паттерны 932 | --------------------------------------------------------------------------------