- добавлена сборка self-contained skill для Claude Code и opencode - добавлен install-ready архив skill в public/slm-design/skill - обновлена карточка SLM Design с меню действий открыть/скачать - добавлен static fallback главной страницы из общего конфига - подключены Mantine Menu и Phosphor Icons для действий карточки
141 lines
6.8 KiB
TypeScript
141 lines
6.8 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { writeZipFromDirectory } from '../../_shared/lib/zip';
|
|
import config from '../project.config';
|
|
|
|
const projectDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const rootDir = path.resolve(projectDir, '../..');
|
|
const templatePath = path.join(projectDir, 'skill', 'slm-design.skill.md');
|
|
const outputDir = path.join(rootDir, 'public', config.slug, 'skill');
|
|
const skillZipPath = path.join(outputDir, 'slm-design.skill.zip');
|
|
|
|
const description = 'Use this skill ONLY when working in a project that uses SLM Design (Scoped Layered Module Design) — a frontend architecture with layers app, layouts, screens, widgets, business, infra, ui, shared; modules with public API via index.ts; business-domain factories; and segments like ui/, parts/, hooks/, services/, mappers/. Apply when designing new modules, deciding where to place code, reviewing imports and dependency direction, refactoring existing SLM code, or planning monorepo placement (apps/, packages/ui, packages/infra, packages/shared). Project signals that SLM is active (strongest first): src/screens/ + src/widgets/ combination (highly specific to SLM — FSD uses pages/, Atomic and Clean don\'t have this layer); *.factory.ts files in business modules with index.ts exporting only factory and type-only exports; src/screens/ alongside src/ui/ and src/shared/; in monorepo, the same structure inside apps/{app}/src/. Note: src/business/ is a strong confirmation when present, but small or early-stage SLM projects may not have it yet — absence of business/ does NOT rule out SLM. Conflicting signals indicating other architectures: src/features/ or src/entities/ or src/pages/ (FSD); src/atoms/ or src/molecules/ (Atomic); src/domain/ or src/useCases/ (Clean). User signals (Russian or English): "SLM", "SLM Design", "Scoped Layered Module Design", "куда положить", "where to place", "какой слой", "which layer", "это модуль или компонент", "module or component", "можно ли так импортировать", "can I import", "deep import", "фабрика", "factory", "публичный API", "public API", "parts/", "business-домен", "business domain", "композиция фабрик", "factory composition". Do NOT use this skill for: projects on Feature-Sliced Design (FSD), Atomic Design, Clean Architecture, or any other frontend architecture — SLM has its own rules and is NOT a synonym for these; legacy codebases without explicit SLM structure (do not propose migration unless the user explicitly asks); small isolated tasks like styling, single-file bug fixes, CSS, or build tooling where architectural placement is not the question; backend architecture. When project architecture is ambiguous, ask the user before applying SLM rules.';
|
|
|
|
const canonPages = [
|
|
{
|
|
anchor: 'canon-architecture-index',
|
|
source: 'canons/architecture/index.md',
|
|
},
|
|
{
|
|
anchor: 'canon-architecture-layers',
|
|
source: 'canons/architecture/layers.md',
|
|
},
|
|
{
|
|
anchor: 'canon-architecture-modules',
|
|
source: 'canons/architecture/modules.md',
|
|
},
|
|
{
|
|
anchor: 'canon-architecture-segments',
|
|
source: 'canons/architecture/segments.md',
|
|
},
|
|
{
|
|
anchor: 'canon-architecture-monorepo',
|
|
source: 'canons/architecture/monorepo.md',
|
|
},
|
|
{
|
|
anchor: 'canon-examples-react-factory',
|
|
source: 'canons/examples/react/factory.md',
|
|
},
|
|
{
|
|
anchor: 'canon-examples-react-factory-composition',
|
|
source: 'canons/examples/react/factory-composition.md',
|
|
},
|
|
{
|
|
anchor: 'canon-examples-react-composition-provider',
|
|
source: 'canons/examples/react/composition-provider.md',
|
|
},
|
|
] as const;
|
|
|
|
const routeAnchors = new Map([
|
|
['/architecture', 'canon-architecture-index'],
|
|
['/architecture/', 'canon-architecture-index'],
|
|
['/architecture/layers', 'canon-architecture-layers'],
|
|
['/architecture/modules', 'canon-architecture-modules'],
|
|
['/architecture/segments', 'canon-architecture-segments'],
|
|
['/architecture/monorepo', 'canon-architecture-monorepo'],
|
|
['/examples/react/factory', 'canon-examples-react-factory'],
|
|
['/examples/react/factory-composition', 'canon-examples-react-factory-composition'],
|
|
['/examples/react/composition-provider', 'canon-examples-react-composition-provider'],
|
|
]);
|
|
|
|
function stripFrontmatter(content: string) {
|
|
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
|
|
}
|
|
|
|
function transformOutsideCode(content: string, transformLine: (line: string) => string) {
|
|
let inFence = false;
|
|
|
|
return content
|
|
.split('\n')
|
|
.map((line) => {
|
|
if (/^\s*(```|~~~)/.test(line)) {
|
|
inFence = !inFence;
|
|
return line;
|
|
}
|
|
|
|
if (inFence) return line;
|
|
|
|
return transformLine(line);
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
function shiftHeadings(content: string) {
|
|
return transformOutsideCode(content, (line) => line.replace(/^(#{1,6})\s/, '##$1 '));
|
|
}
|
|
|
|
function rewriteMarkdownLinks(content: string) {
|
|
return transformOutsideCode(content, (line) => line.replace(/\]\((\/[^)\s#]+\/?)(#[^)\s]+)?\)/g, (match, route: string, hash = '') => {
|
|
const normalizedRoute = route.length > 1 && route.endsWith('/') ? route.slice(0, -1) : route;
|
|
const anchor = routeAnchors.get(route) ?? routeAnchors.get(normalizedRoute);
|
|
|
|
if (!anchor) return match;
|
|
|
|
return `](${hash || `#${anchor}`})`;
|
|
}));
|
|
}
|
|
|
|
function prepareCanon(source: string, anchor: string) {
|
|
const sourcePath = path.join(projectDir, source);
|
|
const content = fs.readFileSync(sourcePath, 'utf8');
|
|
const prepared = rewriteMarkdownLinks(shiftHeadings(stripFrontmatter(content).trim()));
|
|
|
|
return `<a id="${anchor}"></a>\n\n${prepared}`;
|
|
}
|
|
|
|
function createSkillBody() {
|
|
const template = fs.readFileSync(templatePath, 'utf8').trim();
|
|
const canonContent = canonPages.map((page) => prepareCanon(page.source, page.anchor)).join('\n\n---\n\n');
|
|
|
|
return template.replace('<!-- SLM_CANON_CONTENT -->', canonContent);
|
|
}
|
|
|
|
function createSkill(frontmatter: string) {
|
|
return `${frontmatter.trim()}\n\n${createSkillBody()}\n`;
|
|
}
|
|
|
|
function writeFile(filePath: string, content: string) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
}
|
|
|
|
function buildSkill() {
|
|
const frontmatter = `---\nname: slm-design\ndescription: ${description}\n---`;
|
|
const skillContent = createSkill(frontmatter);
|
|
|
|
fs.rmSync(outputDir, { recursive: true, force: true });
|
|
|
|
writeFile(path.join(outputDir, '.claude', 'skills', 'slm-design', 'SKILL.md'), skillContent);
|
|
writeFile(path.join(outputDir, '.opencode', 'skills', 'slm-design', 'SKILL.md'), skillContent);
|
|
|
|
fs.rmSync(skillZipPath, { force: true });
|
|
writeZipFromDirectory(outputDir, skillZipPath, '');
|
|
|
|
console.log(`Собран ${path.relative(rootDir, outputDir)}`);
|
|
console.log(`Собран ${path.relative(rootDir, skillZipPath)}`);
|
|
}
|
|
|
|
buildSkill();
|