refactor: перенести сборку в проекты
- перенесены каноны и VitePress-конфиги в projects/<slug> - добавлены корневой и проектные build.ts для сборки артефактов - добавлены shared-библиотеки сборки в projects/_shared/lib - обновлены CI, Dockerfile, package.json, gitignore и README - удалена сборка frontend-агента
This commit is contained in:
174
projects/_shared/docs/vitepress/HomeLink.vue
Normal file
174
projects/_shared/docs/vitepress/HomeLink.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
variant?: 'back' | 'repo' | 'screen'
|
||||
}>(), {
|
||||
variant: 'back',
|
||||
})
|
||||
|
||||
const repositoryUrl = 'https://gromlab.ru/gromov/docs'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="variant === 'back'"
|
||||
class="docsNavLink docsBackLink"
|
||||
href="/"
|
||||
target="_self"
|
||||
aria-label="Вернуться к списку документаций"
|
||||
>
|
||||
<span class="docsBackIcon" aria-hidden="true">←</span>
|
||||
<span>К списку документаций</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-else-if="variant === 'repo'"
|
||||
class="docsRepoLink"
|
||||
:href="repositoryUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Открыть репозиторий"
|
||||
title="Репозиторий"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M12 .5a12 12 0 0 0-3.8 23.38c.6.12.83-.26.83-.57l-.02-2.04c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.09-.73.09-.73 1.2.09 1.83 1.24 1.83 1.24 1.08 1.83 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.14-.3-.54-1.52.1-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.28-1.55 3.29-1.23 3.29-1.23.64 1.66.24 2.88.12 3.18a4.65 4.65 0 0 1 1.23 3.22c0 4.61-2.8 5.63-5.48 5.92.42.36.81 1.1.81 2.22l-.01 3.29c0 .31.2.69.82.57A12 12 0 0 0 12 .5Z" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<nav v-else class="docsNavLinks" aria-label="Навигация документации">
|
||||
<a
|
||||
class="docsNavLink"
|
||||
href="/"
|
||||
target="_self"
|
||||
aria-label="Вернуться к списку документаций"
|
||||
>
|
||||
<span class="docsBackIcon" aria-hidden="true">←</span>
|
||||
<span>К списку документаций</span>
|
||||
</a>
|
||||
<a
|
||||
class="docsNavLink"
|
||||
:href="repositoryUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Открыть репозиторий документации"
|
||||
>
|
||||
<svg class="docsScreenRepoIcon" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M12 .5a12 12 0 0 0-3.8 23.38c.6.12.83-.26.83-.57l-.02-2.04c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.09-.73.09-.73 1.2.09 1.83 1.24 1.83 1.24 1.08 1.83 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.14-.3-.54-1.52.1-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.28-1.55 3.29-1.23 3.29-1.23.64 1.66.24 2.88.12 3.18a4.65 4.65 0 0 1 1.23 3.22c0 4.61-2.8 5.63-5.48 5.92.42.36.81 1.1.81 2.22l-.01 3.29c0 .31.2.69.82.57A12 12 0 0 0 12 .5Z" />
|
||||
</svg>
|
||||
Репозиторий
|
||||
</a>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.docsBackLink {
|
||||
margin-left: 16px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.docsNavLinks {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.docsNavLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: var(--vp-nav-height);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.docsBackIcon {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 15px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.docsNavLink:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.docsNavLink:hover .docsBackIcon {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.docsNavLink:focus-visible {
|
||||
border-radius: 4px;
|
||||
outline: 2px solid var(--vp-c-brand-1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.docsRepoLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: var(--vp-nav-height);
|
||||
margin-left: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.docsRepoLink:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.docsRepoLink:focus-visible {
|
||||
border-radius: 6px;
|
||||
outline: 2px solid var(--vp-c-brand-1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.docsRepoLink svg,
|
||||
.docsScreenRepoIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.docsNavLinks {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 8px 0 12px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.docsNavLinks .docsNavLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.docsScreenRepoIcon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.docsBackLink,
|
||||
.docsRepoLink {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.docsNavLinks {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.docsBackLink {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
projects/_shared/docs/vitepress/theme.ts
Normal file
15
projects/_shared/docs/vitepress/theme.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { h } from 'vue';
|
||||
import DefaultTheme from 'vitepress/theme';
|
||||
import type { Theme } from 'vitepress';
|
||||
import HomeLink from './HomeLink.vue';
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout() {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
'nav-bar-content-before': () => h(HomeLink, { variant: 'back' }),
|
||||
'nav-bar-content-after': () => h(HomeLink, { variant: 'repo' }),
|
||||
'nav-screen-content-before': () => h(HomeLink, { variant: 'screen' }),
|
||||
});
|
||||
},
|
||||
} satisfies Theme;
|
||||
18
projects/_shared/docs/vitepress/themeHead.ts
Normal file
18
projects/_shared/docs/vitepress/themeHead.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { HeadConfig } from 'vitepress';
|
||||
|
||||
export const themeSyncHead: HeadConfig[] = [
|
||||
[
|
||||
'script',
|
||||
{ id: 'sync-docs-theme' },
|
||||
`;(() => {
|
||||
const theme = localStorage.getItem('vitepress-theme-appearance')
|
||||
if (theme) return
|
||||
|
||||
const legacyTheme = localStorage.getItem('all-docs-theme')
|
||||
if (!legacyTheme) return
|
||||
|
||||
localStorage.setItem('vitepress-theme-appearance', legacyTheme === 'system' ? 'auto' : legacyTheme)
|
||||
localStorage.removeItem('all-docs-theme')
|
||||
})()`,
|
||||
],
|
||||
];
|
||||
112
projects/_shared/lib/prepare-docs.ts
Normal file
112
projects/_shared/lib/prepare-docs.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
type Page = {
|
||||
source: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
type RouteRewrite = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
type DocsConfig = {
|
||||
mounts: Page[];
|
||||
routeRewrites?: RouteRewrite[];
|
||||
};
|
||||
|
||||
type ProjectConfig = {
|
||||
slug: string;
|
||||
docsDir: string;
|
||||
};
|
||||
|
||||
const MD_LINK_RE = /\]\((?!#|[a-z][a-z0-9+.-]*:|\/\/)([^)\s]+)(#[^)]*)?\)/gi;
|
||||
|
||||
function normalizePath(value: string) {
|
||||
return value.split(path.sep).join('/').replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function formatRelativeMarkdownPath(fromTarget: string, toTarget: string) {
|
||||
const relative = path.relative(path.dirname(fromTarget), toTarget).split(path.sep).join('/');
|
||||
return relative.startsWith('.') ? relative : `./${relative}`;
|
||||
}
|
||||
|
||||
async function loadDocsConfig(configPath: string) {
|
||||
return (await import(`${pathToFileURL(configPath).href}?t=${Date.now()}`)) as DocsConfig;
|
||||
}
|
||||
|
||||
export async function prepareDocs(projectDir: string, config: ProjectConfig) {
|
||||
const docsDir = path.join(projectDir, config.docsDir);
|
||||
const contentDir = path.join(docsDir, 'content');
|
||||
const docsConfig = await loadDocsConfig(path.join(docsDir, 'docs.config.ts'));
|
||||
const targetBySource = new Map(
|
||||
docsConfig.mounts.map((page) => [normalizePath(path.resolve(projectDir, page.source)), normalizePath(page.target)]),
|
||||
);
|
||||
const routeRewrites = [...(docsConfig.routeRewrites ?? [])].sort((a, b) => b.from.length - a.from.length);
|
||||
|
||||
function applyRouteRewrites(route: string) {
|
||||
for (const rewrite of routeRewrites) {
|
||||
if (route === rewrite.from || route.startsWith(`${rewrite.from}/`) || route.startsWith(`${rewrite.from}#`)) {
|
||||
return `${rewrite.to}${route.slice(rewrite.from.length)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatDocsRoute(route: string) {
|
||||
const rewritten = applyRouteRewrites(route);
|
||||
if (rewritten) return rewritten;
|
||||
if (route === '/docs') return '/';
|
||||
if (route.startsWith('/docs/')) return route.slice('/docs'.length);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatRelativeRoute(hrefPath: string, sourceDir: string) {
|
||||
const sourcePath = normalizePath(path.relative(projectDir, path.resolve(sourceDir, hrefPath)));
|
||||
if (sourcePath.startsWith('canons/')) return formatDocsRoute(`/docs/${sourcePath.slice('canons/'.length)}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function transformMarkdownLinks(content: string, page: Page) {
|
||||
const sourceDir = path.dirname(path.resolve(projectDir, page.source));
|
||||
|
||||
return content.replace(MD_LINK_RE, (match, href: string, hash = '') => {
|
||||
const [hrefPath, query = ''] = href.split('?');
|
||||
const queryPart = query ? `?${query}` : '';
|
||||
|
||||
if (hrefPath.startsWith('/')) {
|
||||
const route = formatDocsRoute(hrefPath) ?? applyRouteRewrites(hrefPath);
|
||||
return route ? `](${route}${queryPart}${hash})` : match;
|
||||
}
|
||||
|
||||
if (!hrefPath.endsWith('.md')) {
|
||||
const route = formatRelativeRoute(hrefPath, sourceDir);
|
||||
return route ? `](${route}${queryPart}${hash})` : match;
|
||||
}
|
||||
|
||||
const target = targetBySource.get(normalizePath(path.resolve(sourceDir, hrefPath)));
|
||||
if (!target) return match;
|
||||
|
||||
return `](${formatRelativeMarkdownPath(page.target, `${target}${queryPart}`)}${hash})`;
|
||||
});
|
||||
}
|
||||
|
||||
fs.rmSync(contentDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(contentDir, { recursive: true });
|
||||
|
||||
for (const page of docsConfig.mounts) {
|
||||
const sourcePath = path.resolve(projectDir, page.source);
|
||||
const targetPath = path.join(contentDir, page.target);
|
||||
|
||||
if (!fs.existsSync(sourcePath)) throw new Error(`Не найден канон: ${sourcePath}`);
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, transformMarkdownLinks(fs.readFileSync(sourcePath, 'utf8'), page), 'utf8');
|
||||
console.log(`${page.target} -> ${path.relative(projectDir, sourcePath)}`);
|
||||
}
|
||||
|
||||
console.log(`Подготовлен VitePress content для ${config.slug}: ${docsConfig.mounts.length} страниц`);
|
||||
}
|
||||
51
projects/_shared/lib/root-llms.ts
Normal file
51
projects/_shared/lib/root-llms.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { docs } from '../../../src/config/docs.config';
|
||||
|
||||
const siteTitle = 'Документация';
|
||||
const siteDescription = 'Единое пространство для идей, черновиков и первых версий документаций, которые ещё формируются и постепенно становятся самостоятельными материалами.';
|
||||
|
||||
function formatMarkdownLink(label: string, href: string, description: string) {
|
||||
return `- [${label}](${href}): ${description}`;
|
||||
}
|
||||
|
||||
function findDocLink(doc: (typeof docs)[number], label: string) {
|
||||
return doc.links.find((link) => link.label === label);
|
||||
}
|
||||
|
||||
export function generateRootLlms(rootDir = process.cwd()) {
|
||||
const publicDir = path.join(rootDir, 'public');
|
||||
const llmsPath = path.join(publicDir, 'llms.txt');
|
||||
const content = [
|
||||
`# ${siteTitle}`,
|
||||
'',
|
||||
`> ${siteDescription}`,
|
||||
'',
|
||||
'Этот файл является корневой картой документаций. Для работы с конкретным направлением используйте его собственный `llms.txt`.',
|
||||
'',
|
||||
'## Documentation',
|
||||
'',
|
||||
...docs
|
||||
.map((doc) => {
|
||||
const link = findDocLink(doc, 'llms.txt');
|
||||
return link ? formatMarkdownLink(doc.title, link.href, doc.description) : undefined;
|
||||
})
|
||||
.filter(Boolean),
|
||||
'',
|
||||
'## Optional',
|
||||
'',
|
||||
...docs
|
||||
.map((doc) => {
|
||||
const link = findDocLink(doc, 'llms-full.txt');
|
||||
return link ? formatMarkdownLink(`${doc.title} full`, link.href, `Полный bundle документации: ${doc.label.toLowerCase()}.`) : undefined;
|
||||
})
|
||||
.filter(Boolean),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(llmsPath, content, 'utf8');
|
||||
|
||||
console.log(`Сгенерирован ${path.relative(rootDir, llmsPath)}`);
|
||||
}
|
||||
12
projects/_shared/lib/run.ts
Normal file
12
projects/_shared/lib/run.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export function run(command: string, args: string[], cwd = process.cwd()) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Command failed: ${[command, ...args].join(' ')}`);
|
||||
}
|
||||
136
projects/_shared/lib/zip.ts
Normal file
136
projects/_shared/lib/zip.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
type ZipEntry = {
|
||||
name: string;
|
||||
content: Buffer;
|
||||
};
|
||||
|
||||
function collectFiles(dir: string, baseDir = dir, archiveRoot = path.basename(dir)): ZipEntry[] {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.flatMap((entry) => {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) return collectFiles(entryPath, baseDir, archiveRoot);
|
||||
|
||||
const relativePath = path.relative(baseDir, entryPath).split(path.sep).join('/');
|
||||
|
||||
return [
|
||||
{
|
||||
name: `${archiveRoot}/${relativePath}`,
|
||||
content: fs.readFileSync(entryPath),
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function createCrc32Table() {
|
||||
return Array.from({ length: 256 }, (_, index) => {
|
||||
let value = index;
|
||||
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
|
||||
return value >>> 0;
|
||||
});
|
||||
}
|
||||
|
||||
const crc32Table = createCrc32Table();
|
||||
|
||||
function crc32(buffer: Buffer) {
|
||||
let crc = 0xffffffff;
|
||||
|
||||
for (const byte of buffer) {
|
||||
crc = crc32Table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function writeUInt16(value: number) {
|
||||
const buffer = Buffer.alloc(2);
|
||||
buffer.writeUInt16LE(value, 0);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function writeUInt32(value: number) {
|
||||
const buffer = Buffer.alloc(4);
|
||||
buffer.writeUInt32LE(value >>> 0, 0);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function createZip(entries: ZipEntry[]) {
|
||||
const localParts: Buffer[] = [];
|
||||
const centralParts: Buffer[] = [];
|
||||
const dosTime = 0;
|
||||
const dosDate = ((2026 - 1980) << 9) | (1 << 5) | 1;
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const fileName = Buffer.from(entry.name, 'utf8');
|
||||
const checksum = crc32(entry.content);
|
||||
const size = entry.content.length;
|
||||
|
||||
const localHeader = Buffer.concat([
|
||||
writeUInt32(0x04034b50),
|
||||
writeUInt16(20),
|
||||
writeUInt16(0),
|
||||
writeUInt16(0),
|
||||
writeUInt16(dosTime),
|
||||
writeUInt16(dosDate),
|
||||
writeUInt32(checksum),
|
||||
writeUInt32(size),
|
||||
writeUInt32(size),
|
||||
writeUInt16(fileName.length),
|
||||
writeUInt16(0),
|
||||
fileName,
|
||||
]);
|
||||
|
||||
localParts.push(localHeader, entry.content);
|
||||
|
||||
centralParts.push(Buffer.concat([
|
||||
writeUInt32(0x02014b50),
|
||||
writeUInt16(20),
|
||||
writeUInt16(20),
|
||||
writeUInt16(0),
|
||||
writeUInt16(0),
|
||||
writeUInt16(dosTime),
|
||||
writeUInt16(dosDate),
|
||||
writeUInt32(checksum),
|
||||
writeUInt32(size),
|
||||
writeUInt32(size),
|
||||
writeUInt16(fileName.length),
|
||||
writeUInt16(0),
|
||||
writeUInt16(0),
|
||||
writeUInt16(0),
|
||||
writeUInt16(0),
|
||||
writeUInt32(0),
|
||||
writeUInt32(offset),
|
||||
fileName,
|
||||
]));
|
||||
|
||||
offset += localHeader.length + size;
|
||||
}
|
||||
|
||||
const centralDirectory = Buffer.concat(centralParts);
|
||||
const endOfCentralDirectory = Buffer.concat([
|
||||
writeUInt32(0x06054b50),
|
||||
writeUInt16(0),
|
||||
writeUInt16(0),
|
||||
writeUInt16(entries.length),
|
||||
writeUInt16(entries.length),
|
||||
writeUInt32(centralDirectory.length),
|
||||
writeUInt32(offset),
|
||||
writeUInt16(0),
|
||||
]);
|
||||
|
||||
return Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory]);
|
||||
}
|
||||
|
||||
export function writeZipFromDirectory(sourceDir: string, zipPath: string, archiveRoot = path.basename(sourceDir)) {
|
||||
fs.mkdirSync(path.dirname(zipPath), { recursive: true });
|
||||
fs.writeFileSync(zipPath, createZip(collectFiles(sourceDir, sourceDir, archiveRoot)));
|
||||
}
|
||||
Reference in New Issue
Block a user