ma7engine: ядро, шаблон панели, скаффолдер и доктор

Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
2026-08-26 20:39:37 +03:00
co-authored by Claude Opus 5
commit 18bd05bcbb
50 changed files with 8152 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
// Реестр вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент)
// metadata.json — манифест: { id, label, icon, category?, permission?, order?, devOnly? }
//
// Сам поиск папок (import.meta.glob) остаётся в приложении и передаётся сюда
// аргументами — это compile-time фича Vite, её паттерн резолвится относительно
// файла, где она написана. Уехав в ядро, она сканировала бы ядро, а не
// src/tabs/ приложения.
//
// metadata.json НЕ проверяется компилятором (это JSON), поэтому содержимое
// валидируется здесь в рантайме — кривой манифест падает с внятной ошибкой при
// сборке TABS, а не «тихо» в UI.
import type { ComponentType } from 'react';
import { OWNER_PERMISSION } from '../auth';
// Сериализуемая часть из metadata.json. Обязательны только id, label и icon:
// панель без ролей и без группировки меню — базовый случай, а не урезанный.
export interface TabMeta {
id: string;
label: string;
icon: string; // имя иконки → карта ICONS
// Группа в меню. Без неё вкладка попадает в единственную безымянную группу,
// и сайдбар рисует плоский список.
category?: string;
// Право, нужное для доступа. Без него вкладка видна любому залогиненному.
permission?: string;
order?: number; // порядок в меню (по возрастанию; без него — 0)
// Вкладка, которой не должно быть в проде: её роут на бэкенде там всё равно
// отдаёт 404 независимо от прав, и вкладка вела бы в тупик.
devOnly?: boolean;
}
// Собранная вкладка: метадата + резолвнутые Icon и Page.
export interface TabDef extends TabMeta {
Icon: () => JSX.Element;
Page: ComponentType<any>;
}
export interface TabGroup {
category: string;
tabs: TabDef[];
}
export interface BuiltTabs {
TABS: TabDef[];
ALL_TAB_PERMISSIONS: string[];
visibleTabsByCategory: (permissions: string[]) => TabGroup[];
}
type MetaModules = Record<string, { default: unknown }>;
type PageModules = Record<string, { default: ComponentType<any> }>;
type IconMap = Record<string, () => JSX.Element>;
// './users/metadata.json' → 'users'
const folderOf = (path: string) => path.split('/')[1];
const REQUIRED_STRINGS = ['id', 'label', 'icon'] as const;
const OPTIONAL_STRINGS = ['category', 'permission'] as const;
// Валидация сырого metadata.json → типизированный TabMeta (или внятная ошибка).
function toMeta(name: string, raw: unknown): TabMeta {
const m = (raw ?? {}) as Record<string, unknown>;
const fail = (what: string): never => {
throw new Error(`tabs/${name}/metadata.json: ${what}`);
};
const nonEmpty = (v: unknown) => typeof v === 'string' && v.trim() !== '';
for (const key of REQUIRED_STRINGS) {
if (!nonEmpty(m[key])) fail(`поле "${key}" должно быть непустой строкой`);
}
for (const key of OPTIONAL_STRINGS) {
if (m[key] !== undefined && !nonEmpty(m[key])) fail(`"${key}" должно быть непустой строкой`);
}
if (m.order !== undefined && typeof m.order !== 'number') fail('"order" должно быть числом');
if (m.devOnly !== undefined && typeof m.devOnly !== 'boolean') fail('"devOnly" должно быть булевым');
return {
id: m.id as string,
label: m.label as string,
icon: m.icon as string,
category: m.category as string | undefined,
permission: m.permission as string | undefined,
order: m.order as number | undefined,
devOnly: m.devOnly as boolean | undefined,
};
}
// Собирает реестр вкладок из результатов import.meta.glob приложения:
//
// export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs(
// import.meta.glob('./*/metadata.json', { eager: true }),
// import.meta.glob('./*/index.tsx', { eager: true }),
// ICONS,
// );
//
// Вкладок может не быть вовсе (чистый каркас) — тогда TABS пустой, и оболочка
// показывает заглушку вместо контента.
export function buildTabs(metas: MetaModules, pages: PageModules, icons: IconMap): BuiltTabs {
const TABS: TabDef[] = Object.entries(metas)
.map(([path, mod]) => {
const name = folderOf(path);
const meta = toMeta(name, mod.default);
const page = pages[`./${name}/index.tsx`]?.default;
if (!page) {
throw new Error(`tabs/${name}: нет index.tsx рядом с metadata.json`);
}
if (!icons[meta.icon]) {
throw new Error(`tabs/${name}: иконка "${meta.icon}" не зарегистрирована в ICONS`);
}
return { ...meta, Icon: icons[meta.icon], Page: page };
})
// import.meta.env.PROD — режим сборки этого фронта (vite build), а не
// окружения бэкенда, к которому он ходит.
.filter(t => !t.devOnly || !import.meta.env.PROD)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
const ids = TABS.map(t => t.id);
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
if (dup) {
throw new Error(`tabs: дублирующийся id "${dup}" — id вкладок должны быть уникальны`);
}
// Полный список прав, встречающихся у вкладок — по нему строится форма
// выдачи прав в панелях, где такая вкладка есть: появилась вкладка с новым
// permission — появился и чекбокс, без ручной синхронизации списков.
const ALL_TAB_PERMISSIONS = Array.from(
new Set(TABS.map(t => t.permission).filter((p): p is string => !!p)),
);
// Вкладки, доступные набору прав, сгруппированные по категориям в порядке
// первого появления (сами вкладки уже отсортированы по order). Вкладки без
// category собираются в одну безымянную группу — сайдбар рисует её без
// заголовка, то есть плоским списком.
function visibleTabsByCategory(permissions: string[]): TabGroup[] {
const owner = permissions.includes(OWNER_PERMISSION);
const visible = TABS.filter(t => !t.permission || owner || permissions.includes(t.permission));
const groups: TabGroup[] = [];
for (const tab of visible) {
const category = tab.category ?? '';
let group = groups.find(g => g.category === category);
if (!group) { group = { category, tabs: [] }; groups.push(group); }
group.tabs.push(tab);
}
return groups;
}
return { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory };
}