feat: Добавить VitePress

This commit is contained in:
2026-07-25 17:56:16 +03:00
parent cfcff10c58
commit 6f6e4896af
80 changed files with 4663 additions and 665 deletions

View File

@@ -6,7 +6,7 @@ import skillConfig from '../src-skills/slm-design/skill.config.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const sourceDir = path.join(repoRoot, 'src-skills', skillConfig.name);
const sourcePath = path.join(sourceDir, skillConfig.source);
const docsDir = path.join(repoRoot, 'docs');
const legacyDocsDir = path.join(repoRoot, 'old-docs');
const outputDir = path.join(repoRoot, 'skills', skillConfig.name);
const includePattern = /<!--\s*include:\s*(.*?)\s*-->/g;
@@ -50,8 +50,8 @@ if (!fs.existsSync(sourcePath)) {
throw new Error(`Skill source not found: ${path.relative(repoRoot, sourcePath)}`);
}
if (!fs.existsSync(docsDir)) {
throw new Error('Documentation directory not found: docs');
if (!fs.existsSync(legacyDocsDir)) {
throw new Error('Legacy documentation directory not found: old-docs');
}
const source = fs.readFileSync(sourcePath, 'utf8');
@@ -65,6 +65,6 @@ const output = [
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, 'SKILL.md'), `${output}\n`);
fs.cpSync(docsDir, path.join(outputDir, 'reference'), { recursive: true });
fs.cpSync(legacyDocsDir, path.join(outputDir, 'reference'), { recursive: true });
console.log(path.relative(repoRoot, path.join(outputDir, 'SKILL.md')));

View File

@@ -0,0 +1,88 @@
import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import MiniSearch from 'minisearch'
import { collectRules, RULE_SEARCH_OPTIONS } from './lib/specification.mjs'
const repoRoot = fileURLToPath(new URL('../', import.meta.url))
const specificationRoot = path.join(repoRoot, 'docs', 'ru', 'specification')
const distRoot = path.join(repoRoot, 'site', '.vitepress', 'dist')
const chunksDirectory = path.join(distRoot, 'assets', 'chunks')
const rules = await collectRules(specificationRoot)
const chunkNames = (await readdir(chunksDirectory))
.filter((file) => file.startsWith('@localSearchIndexru.') && file.endsWith('.js'))
if (chunkNames.length !== 1) {
throw new Error(`Expected one Russian search index chunk, found ${chunkNames.length}`)
}
const searchData = (
await import(`${pathToFileURL(path.join(chunksDirectory, chunkNames[0])).href}?t=${Date.now()}`)
).default
const searchIndex = MiniSearch.loadJSON(searchData, {
fields: ['title', 'titles', 'text'],
storeFields: ['title', 'titles'],
})
const catalogHtml = await readFile(
path.join(distRoot, 'ru', 'specification', 'rules.html'),
'utf8',
)
const htmlCache = new Map()
function pageHtmlPath(pageHref) {
const route = pageHref.replace(/^\/ru\/specification\/?/, '')
return route.endsWith('/') || route === ''
? path.join(distRoot, 'ru', 'specification', route, 'index.html')
: path.join(distRoot, 'ru', 'specification', `${route}.html`)
}
for (const rule of rules) {
const expectedId = `/slm-design${rule.href}`
const firstResult = searchIndex.search(rule.id, RULE_SEARCH_OPTIONS)[0]
if (firstResult?.id !== expectedId) {
throw new Error(
`Search for ${rule.id} returned ${firstResult?.id || 'nothing'} instead of ${expectedId}`,
)
}
if (!firstResult.title.startsWith(rule.id)) {
throw new Error(`Search title for ${rule.id} does not start with the exact rule ID`)
}
if (firstResult.titles?.[0] !== 'Спецификация') {
throw new Error(`Search breadcrumb for ${rule.id} does not identify Specification`)
}
const htmlPath = pageHtmlPath(rule.pageHref)
let html = htmlCache.get(htmlPath)
if (!html) {
html = await readFile(htmlPath, 'utf8')
htmlCache.set(htmlPath, html)
}
if (!html.includes(`id="${rule.anchor}"`)) {
throw new Error(`Missing HTML anchor for ${rule.id} in ${rule.relativePath}`)
}
if (!html.includes(`class="slm-rule__permalink" href="#${rule.anchor}"`)) {
throw new Error(`Missing permalink for ${rule.id} in ${rule.relativePath}`)
}
if (!catalogHtml.includes(`>${rule.id}</a>`)) {
throw new Error(`Rule catalog does not contain ${rule.id}`)
}
}
const representativePage = await readFile(
path.join(distRoot, 'ru', 'specification', 'foundations.html'),
'utf8',
)
if (!representativePage.includes('class="doc-set-header"')) {
throw new Error('Specification sidebar does not contain the document-set header')
}
console.log(
`Documentation search check passed: ${rules.length} exact rule queries, anchors, permalinks, and catalog entries.`,
)

118
scripts/check-docs.mjs Normal file
View File

@@ -0,0 +1,118 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
collectMarkdownFiles,
collectRules,
RULE_ID_PATTERN_SOURCE,
RULE_LEVEL_PATTERN_SOURCE,
} from './lib/specification.mjs'
const specificationRoot = fileURLToPath(
new URL('../docs/ru/specification/', import.meta.url),
)
const declarationPattern = new RegExp(
`^\\*\\*(${RULE_ID_PATTERN_SOURCE}) - (${RULE_LEVEL_PATTERN_SOURCE})\\.\\*\\*`,
'gm',
)
const declarationLinePattern = new RegExp(
`^\\*\\*(${RULE_ID_PATTERN_SOURCE}) - (${RULE_LEVEL_PATTERN_SOURCE})\\.\\*\\*`,
)
const referencePattern = new RegExp(`\\b${RULE_ID_PATTERN_SOURCE}\\b`, 'g')
const legacyBaseReferencePattern = /\bSLM-(?!BASE-|ADV-|PRO-)[A-Z][A-Z0-9]*-\d{3}\b/g
function lineNumberAt(content, index) {
return content.slice(0, index).split('\n').length
}
const files = await collectMarkdownFiles(specificationRoot)
const registry = await collectRules(specificationRoot)
const declarations = new Map()
const references = []
const errors = []
const ruleCounts = { BASE: 0, ADV: 0, PRO: 0 }
for (const file of files) {
const content = await readFile(file, 'utf8')
const relativePath = path.relative(specificationRoot, file).split(path.sep).join('/')
const lines = content.split('\n')
for (const [index, line] of lines.entries()) {
if (line.startsWith('**SLM-') && !declarationLinePattern.test(line)) {
errors.push(`${relativePath}:${index + 1}: malformed rule declaration`)
}
}
for (const match of content.matchAll(declarationPattern)) {
const id = match[1]
const location = `${relativePath}:${lineNumberAt(content, match.index)}`
const existingLocation = declarations.get(id)
if (existingLocation) {
errors.push(`${location}: duplicate ${id}; first declared at ${existingLocation}`)
} else {
declarations.set(id, location)
ruleCounts[id.split('-')[1]] += 1
}
if (id.startsWith('SLM-BASE-') && relativePath.startsWith('modes/')) {
errors.push(`${location}: base rule ${id} cannot be declared in an overlay`)
} else if (id.startsWith('SLM-ADV-') && !relativePath.startsWith('modes/advanced/')) {
errors.push(`${location}: ${id} must be declared under modes/advanced`)
} else if (id.startsWith('SLM-PRO-') && !relativePath.startsWith('modes/pro/')) {
errors.push(`${location}: ${id} must be declared under modes/pro`)
}
}
for (const match of content.matchAll(referencePattern)) {
references.push({
id: match[0],
location: `${relativePath}:${lineNumberAt(content, match.index)}`,
})
}
for (const match of content.matchAll(legacyBaseReferencePattern)) {
errors.push(
`${relativePath}:${lineNumberAt(content, match.index)}: legacy base rule ID ${match[0]}`,
)
}
if (relativePath.startsWith('modes/advanced/') && /\bSLM-PRO-[A-Z-]+-\d{3}\b/.test(content)) {
errors.push(`${relativePath}: Advanced overlay references a Pro rule`)
}
if (relativePath.startsWith('modes/pro/') && /\bSLM-ADV-[A-Z-]+-\d{3}\b/.test(content)) {
errors.push(`${relativePath}: Pro overlay references an Advanced rule`)
}
}
for (const reference of references) {
if (!declarations.has(reference.id)) {
errors.push(`${reference.location}: unknown rule reference ${reference.id}`)
}
}
if (registry.length !== declarations.size) {
errors.push(
`rule registry contains ${registry.length} records, but validator found ${declarations.size} declarations`,
)
}
for (const rule of registry) {
if (!declarations.has(rule.id)) {
errors.push(`${rule.relativePath}:${rule.line}: registry contains undeclared rule ${rule.id}`)
}
}
if (errors.length > 0) {
console.error(`Documentation check failed with ${errors.length} error(s):`)
for (const error of errors) console.error(`- ${error}`)
process.exitCode = 1
} else {
console.log(
`Documentation check passed: ${files.length} files, ${declarations.size} rules `
+ `(${ruleCounts.BASE} base, ${ruleCounts.ADV} advanced, ${ruleCounts.PRO} pro), `
+ `${references.length} rule occurrences.`,
)
}

View File

@@ -0,0 +1,129 @@
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
export const RULE_ID_PATTERN_SOURCE = 'SLM-(?:BASE|ADV|PRO)-[A-Z][A-Z0-9]*-\\d{3}'
export const RULE_LEVEL_PATTERN_SOURCE = 'ОБЯЗАН|ЗАПРЕЩЕНО|СЛЕДУЕТ|МОЖЕТ'
export const RULE_SEARCH_OPTIONS = {
fuzzy: false,
prefix: true,
combineWith: 'AND',
boost: { title: 50, text: 2, titles: 1 },
}
const ruleIdPattern = /^SLM-(BASE|ADV|PRO)-([A-Z][A-Z0-9]*)-(\d{3})$/
const declarationPattern = new RegExp(
`^\\*\\*(${RULE_ID_PATTERN_SOURCE}) - (${RULE_LEVEL_PATTERN_SOURCE})\\.\\*\\*\\s+(.+?)\\s*$`,
)
const rulesetOrder = { BASE: 0, ADV: 1, PRO: 2 }
export async function collectMarkdownFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true })
const files = []
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) {
files.push(...await collectMarkdownFiles(entryPath))
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(entryPath)
}
}
return files
}
export function parseRuleId(id) {
const match = id.match(ruleIdPattern)
if (!match) return null
return {
ruleset: match[1],
area: match[2],
number: Number(match[3]),
}
}
export function parseRuleDeclaration(line) {
const match = line.match(declarationPattern)
if (!match) return null
const parsedId = parseRuleId(match[1])
if (!parsedId) return null
return {
id: match[1],
...parsedId,
level: match[2],
markdown: match[3],
text: stripInlineMarkdown(match[3]),
}
}
export function stripInlineMarkdown(value) {
return value
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/<[^>]+>/g, '')
.replace(/[*_~]/g, '')
.replace(/\\([\\`*{}\[\]()#+.!_-])/g, '$1')
.replace(/\s+/g, ' ')
.trim()
}
export function specificationPathToHref(relativePath, basePath = '/ru/specification/') {
let route = relativePath.split(path.sep).join('/')
if (route === 'index.md') route = ''
else route = route.replace(/(?:^|\/)index\.md$/, '/').replace(/\.md$/, '')
return `${basePath}${route}`
}
export async function collectRules(specificationRoot, basePath = '/ru/specification/') {
const files = await collectMarkdownFiles(specificationRoot)
const rules = []
for (const file of files) {
const content = await readFile(file, 'utf8')
const relativePath = path.relative(specificationRoot, file).split(path.sep).join('/')
const headings = []
for (const [lineIndex, line] of content.split('\n').entries()) {
const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/)
if (heading) {
const level = heading[1].length
headings.length = level
headings[level - 1] = stripInlineMarkdown(heading[2])
continue
}
const rule = parseRuleDeclaration(line)
if (!rule) continue
const pageTitle = headings[0] || relativePath
const sectionTitles = headings.slice(1).filter(Boolean)
const pageHref = specificationPathToHref(relativePath, basePath)
rules.push({
...rule,
anchor: rule.id.toLowerCase(),
href: `${pageHref}#${rule.id.toLowerCase()}`,
line: lineIndex + 1,
pageHref,
pageTitle,
relativePath,
sectionTitle: sectionTitles.at(-1) || pageTitle,
sectionTitles,
})
}
}
return rules.sort((left, right) => (
rulesetOrder[left.ruleset] - rulesetOrder[right.ruleset]
|| left.area.localeCompare(right.area)
|| left.number - right.number
|| left.id.localeCompare(right.id)
))
}