Skip to content

Demo: Order Management ​

Этот небольшой интерфейс управления заказами показывает @arfa-tools/generator как слой генерации, проверок и LLM feedback в типовом React + Vite-проекте. В данном примере используется FSD 2.1 архитектура.

Предпросмотр ​

builder
format.ts
generator.ts
generatorFirst.ts
parallel.ts
reloadPlugin.ts
src
biome.json
index.html
package.json
tsconfig.json
vite.config.ts
ts
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import {
  CheckerBoundaries,
  CheckerCli,
  FileGenerator,
  PluginReexport,
  PluginTranslations,
  PluginValidators,
  type TypeMessage,
} from '@arfa-tools/generator';

import { createMessages } from '../src/shared/i18n/createMessages.ts';
import { format } from './format.ts';

const filename = fileURLToPath(import.meta.url);
const rootDir = path.resolve(path.dirname(filename), '..');
const sourceDir = path.resolve(rootDir, 'src');
const exclude = /(^_|^index\.ts$|^messages\.ts$|\.css$|\.d\.ts$|\.md$|package\.json$)/;
const headerTemplate = `// biome-ignore-all assist/source/organizeImports: This file is auto-generated\n\n`;

function upperFirst(value: string): string {
  return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}

function parseMessages(filePath: string): Array<TypeMessage> {
  const source = fs.readFileSync(filePath, 'utf-8');
  const content = source.replace(/^[\s\S]*?export\s+const\s+messages\s*=\s*/, 'return ');

  if (content === source) throw new Error(`Messages export not found in: ${filePath}`);

  const messages = new Function('createMessages', content)(createMessages);

  return Object.values(messages);
}

export const generator = new FileGenerator({
  rootDir: sourceDir,
  headerTemplate,
  historyPath: path.resolve(rootDir, '.llm/history.jsonl'),
  format,
  plugins: [
    new PluginTranslations([
      {
        entries: ['**/messages.ts'],
        targetFile: path.resolve(sourceDir, 'shared/i18n/usedTranslations.json'),
        languages: ['en'],
        baseLanguage: 'en',
        getKey({ name }) {
          return name;
        },
        parseMessages,
        async translate() {
          return '';
        },
      },
    ]),
    new PluginValidators([
      {
        entries: ['shared/api/*/contracts'],
        exclude,
        targetFolder: path.resolve(sourceDir, 'shared/api/validators'),
        tsconfigPath: path.resolve(rootDir, 'tsconfig.json'),
        requestDeclaration: 'TypeRequest',
        responseDeclaration: 'TypeResponse',
        formatContent({ schema, targetPath }) {
          return `export const ${path.parse(targetPath).name} = ${JSON.stringify(schema, null, 2)} as const;\n`;
        },
      },
    ]),
    new PluginReexport([
      {
        entries: ['pages/*'],
        exclude,
        noPackage: true,
        fileNameTemplate: () => 'index.ts',
        template({ folderName }) {
          const pageName = `${upperFirst(folderName)}Page`;

          return `export { ${pageName} } from './ui/${pageName}';`;
        },
      },
      {
        entries: ['shared/api/*'],
        exclude: /(^validators$|^index\.ts$)/,
        noPackage: true,
        fileNameTemplate: () => 'index.ts',
        template({ files }) {
          return files
            .filter(({ ext }) => ext === '.ts')
            .map(({ name }) => `export type * from './${name}';`)
            .join('\n');
        },
      },
      {
        entries: ['pages/*/api'],
        exclude,
        noPackage: true,
        fileNameTemplate: () => 'index.ts',
        template({ files }) {
          return files.map(({ name }) => `export { ${name} } from './${name}';`).join('\n');
        },
        validateContent({ name, content }) {
          return content.includes(`export async function ${name}(`);
        },
      },
      {
        entries: ['shared/lib/*', 'shared/i18n'],
        exclude,
        noPackage: true,
        fileNameTemplate: () => 'index.ts',
        template({ files }) {
          return files
            .filter(({ ext }) => ['.ts', '.tsx', '.json'].includes(ext))
            .map(({ name, ext, fileName }) =>
              ext === '.json'
                ? `export { default as ${name} } from './${fileName}';`
                : `export * from './${name}';`
            )
            .join('\n');
        },
      },
      {
        entries: ['pages/*/ui/*'],
        exclude,
        onlyPackage: true,
        fileNameTemplate({ folderName }) {
          return `${upperFirst(folderName)}.tsx`;
        },
        template: () => '',
      },
      {
        entries: ['shared/ui/*'],
        exclude,
        fileNameTemplate: () => 'index.ts',
        template({ files }) {
          return files
            .filter(({ ext }) => ['.ts', '.tsx'].includes(ext))
            .map(({ name }) => `export * from './${name}';`)
            .join('\n');
        },
      },
    ]),
  ],
  checkers: [
    new CheckerBoundaries({
      tsconfigPath: path.resolve(rootDir, 'tsconfig.json'),
      extensions: ['.ts', '.tsx'],
      rules: [
        {
          entries: ['app/**'],
          allow: {
            imports: [
              'pages/*/index.ts',
              'shared/api/*/index.ts',
              'shared/i18n/index.ts',
              'shared/lib/*/index.ts',
              'shared/ui/*/index.ts',
            ],
            importsInsideScope: 'all',
            external: true,
          },
        },
        {
          entries: ['pages/**'],
          allow: {
            imports: [
              'shared/api/*/index.ts',
              'shared/i18n/index.ts',
              'shared/lib/*/index.ts',
              'shared/ui/*/index.ts',
            ],
            importsInsideScope: 'sameTopLevel',
            external: true,
          },
        },
        {
          entries: ['shared/**'],
          allow: {
            imports: [],
            importsInsideScope: 'all',
            external: true,
          },
        },
      ],
    }),
    new CheckerCli({
      name: 'biome',
      getCommand({ changedFiles }) {
        if (changedFiles.length === 0) return false;

        return [
          path.resolve(rootDir, 'node_modules/.bin/biome'),
          'check',
          '--no-errors-on-unmatched',
          ...changedFiles.map(({ relativePath }) => relativePath),
        ];
      },
    }),
    new CheckerCli({
      name: 'tsc',
      getCommand: () => [path.resolve(rootDir, 'node_modules/.bin/tsc')],
    }),
  ],
});

Скачивание ​

shell
npx degit dkazakov8/arfa-generator/examples/orders react-example
cd react-example
npm install

Режимы запуска ​

КомандаПоведение
npm run parallelОдин проход генерации и проверок, затем отдельная сборка Vite
npm run parallel-watchПервоначальная генерация, затем параллельная работа вотчера генератора и dev-сервера Vite с HMR. В большинстве случаев будет 2 HMR-обновления: первый после редактирования файла, второй — после записи генератором новых файлов. Обратите внимание, что в данном случае генератор не знает об ошибках при сборке и не выводит их в едином отчете
npm run generator-firstОдин проход генерации и проверок со сборкой Vite внутри afterCheckers
npm run generator-first-watchПервоначальная генерация и сборка, затем watch только генератора; каждая следующая сборка вызывается в afterCheckers. В этом случае не работает HMR, а обновление страницы делается кастомным плагином, который перезагружает страницу только 1 раз — после завершения всего цикла, включая сборку. Ошибки сборки попадают в отчет
npm run feedbackПолучение последнего отчета в Markdown-формате (удобно для LLM-агентов)

Сгенерированные файлы ​

ARFA Generator создает и обновляет следующие файлы (список получен с помощью getControlledFiles):

text
shared/i18n/usedTranslations.json
shared/api/validators/shared/api/customers/contracts/customers.ts
shared/api/validators/shared/api/invoices/contracts/invoices.ts
shared/api/validators/shared/api/orders/contracts/orders.ts
shared/api/validators/shared/api/orders/contracts/updateOrderStatus.ts
pages/customers/index.ts
pages/invoices/index.ts
pages/orders/index.ts
shared/api/customers/index.ts
shared/api/invoices/index.ts
shared/api/orders/index.ts
pages/customers/api/index.ts
pages/invoices/api/index.ts
pages/orders/api/index.ts
shared/i18n/index.ts
shared/lib/dates/index.ts
shared/lib/money/index.ts
shared/lib/timing/index.ts
pages/customers/ui/CustomerStatusBadge/package.json
pages/invoices/ui/InvoiceStatusBadge/package.json
pages/orders/ui/OrderStatusBadge/package.json
shared/ui/Button/package.json
shared/ui/Button/index.ts
shared/ui/DataTable/package.json
shared/ui/DataTable/index.ts
shared/ui/EmptyState/package.json
shared/ui/EmptyState/index.ts
shared/ui/MoneyText/package.json
shared/ui/MoneyText/index.ts

Попробуйте вызвать ошибки ​

Запустите npm run generator-first-watch и вносите изменения по одному. Последний отчёт можно получить командой npm run feedback. Перед следующим экспериментом отменяйте предыдущую правку.

  • Переименуйте тип TypeRequest в src/shared/api/orders/contracts/orders.ts вместе с его использованиями — ошибка генерации схем валидации.
  • Импортируйте страницу заказов из страницы клиентов — нарушение границ между страницами.
  • Импортируйте любую страницу из компонента в shared/ui — нарушение границ слоя shared.
  • В App.tsx замените импорт из pages/orders на pages/orders/ui/OrdersPage — нарушение публичного API страницы.
  • В loadOrders.ts переименуйте экспортируемую async-функцию, сохранив имя файла — ошибка валидации реэкспорта.
  • В OrderStatusBadge/messages.ts уберите export у объявления messages — ошибка обработки переводов.
  • В App.tsx укажите несуществующий модуль в импорте — ошибка сборки Vite.
  • Обратитесь к необъявленной переменной в TypeScript-файле — диагностика tsc.
  • Добавьте debugger; в тело функции в loadOrders.ts — диагностика Biome.

MIT License