mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
chore: Новый черновик DRAFT, удалить старые docs-v
This commit is contained in:
@@ -1,88 +0,0 @@
|
||||
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.`,
|
||||
)
|
||||
@@ -1,118 +0,0 @@
|
||||
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.`,
|
||||
)
|
||||
}
|
||||
149
scripts/check-site.mjs
Normal file
149
scripts/check-site.mjs
Normal file
@@ -0,0 +1,149 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('../', import.meta.url))
|
||||
const distRoot = path.join(repositoryRoot, 'site', '.vitepress', 'dist')
|
||||
const rulesSource = path.join(repositoryRoot, 'DRAFT', 'rules', 'level-1.md')
|
||||
const siteOrigin = 'https://site.test'
|
||||
const siteBase = '/slm-design/'
|
||||
|
||||
const expectedPages = [
|
||||
'404.html',
|
||||
'index.html',
|
||||
'level-1/index.html',
|
||||
'level-1/terminology.html',
|
||||
'level-1/layers.html',
|
||||
'level-1/dependencies.html',
|
||||
'level-1/modules.html',
|
||||
'level-1/groups.html',
|
||||
'level-1/segments.html',
|
||||
'level-1/components.html',
|
||||
'level-1/nested-modules.html',
|
||||
'level-1/lifecycle.html',
|
||||
'level-1/validation.html',
|
||||
'rules/index.html',
|
||||
'rules/level-1.html',
|
||||
].sort()
|
||||
|
||||
async function collectHtmlFiles(directory, prefix = '') {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.posix.join(prefix, entry.name)
|
||||
const absolutePath = path.join(directory, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await collectHtmlFiles(absolutePath, relativePath))
|
||||
} else if (entry.isFile() && entry.name.endsWith('.html')) {
|
||||
files.push(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function pageUrl(relativePath) {
|
||||
if (relativePath === 'index.html') return `${siteOrigin}${siteBase}`
|
||||
if (relativePath.endsWith('/index.html')) {
|
||||
return `${siteOrigin}${siteBase}${relativePath.slice(0, -'index.html'.length)}`
|
||||
}
|
||||
return `${siteOrigin}${siteBase}${relativePath.slice(0, -'.html'.length)}`
|
||||
}
|
||||
|
||||
function htmlPathForUrl(url) {
|
||||
const route = decodeURIComponent(url.pathname.slice(siteBase.length))
|
||||
if (!route) return 'index.html'
|
||||
if (route.endsWith('/')) return `${route}index.html`
|
||||
return `${route}.html`
|
||||
}
|
||||
|
||||
const actualPages = (await collectHtmlFiles(distRoot)).sort()
|
||||
if (JSON.stringify(actualPages) !== JSON.stringify(expectedPages)) {
|
||||
throw new Error(
|
||||
`Published page set differs from allowlist.\nExpected: ${expectedPages.join(', ')}\nActual: ${actualPages.join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const htmlByPage = new Map()
|
||||
for (const relativePath of actualPages) {
|
||||
htmlByPage.set(relativePath, await readFile(path.join(distRoot, relativePath), 'utf8'))
|
||||
}
|
||||
|
||||
for (const [relativePath, html] of htmlByPage) {
|
||||
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1])
|
||||
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index)
|
||||
if (duplicateIds.length > 0) {
|
||||
throw new Error(`${relativePath} contains duplicate ids: ${[...new Set(duplicateIds)].join(', ')}`)
|
||||
}
|
||||
|
||||
for (const match of html.matchAll(/<a\b[^>]*\bhref="([^"]+)"/g)) {
|
||||
const target = new URL(match[1], pageUrl(relativePath))
|
||||
if (target.origin !== siteOrigin) continue
|
||||
if (!target.pathname.startsWith(siteBase)) {
|
||||
throw new Error(`${relativePath} links outside the configured base: ${match[1]}`)
|
||||
}
|
||||
|
||||
const targetPath = htmlPathForUrl(target)
|
||||
const targetHtml = htmlByPage.get(targetPath)
|
||||
if (!targetHtml) {
|
||||
throw new Error(`${relativePath} contains broken link ${match[1]}`)
|
||||
}
|
||||
|
||||
if (target.hash) {
|
||||
const anchor = decodeURIComponent(target.hash.slice(1))
|
||||
if (!targetHtml.includes(`id="${anchor}"`)) {
|
||||
throw new Error(`${relativePath} links to missing anchor ${match[1]}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rulesMarkdown = await readFile(rulesSource, 'utf8')
|
||||
const ruleIds = [...rulesMarkdown.matchAll(/^### (SLM-L1-[A-Z_]+-[AR]\d{3})$/gm)]
|
||||
.map((match) => match[1])
|
||||
const rulesHtml = htmlByPage.get('rules/level-1.html')
|
||||
const searchChunksDirectory = path.join(distRoot, 'assets', 'chunks')
|
||||
const searchChunks = (await readdir(searchChunksDirectory))
|
||||
.filter((file) => file.startsWith('@localSearchIndex') && file.endsWith('.js'))
|
||||
|
||||
if (searchChunks.length !== 1) {
|
||||
throw new Error(`Expected one local search index, found ${searchChunks.length}`)
|
||||
}
|
||||
|
||||
const searchModuleUrl = `${pathToFileURL(path.join(searchChunksDirectory, searchChunks[0])).href}?t=${Date.now()}`
|
||||
const searchData = JSON.parse((await import(searchModuleUrl)).default)
|
||||
const searchUrls = new Set(Object.values(searchData.documentIds))
|
||||
|
||||
for (const ruleId of ruleIds) {
|
||||
const anchor = ruleId.toLowerCase()
|
||||
const expectedUrl = `${siteBase}rules/level-1#${anchor}`
|
||||
|
||||
if (!rulesHtml.includes(`id="${anchor}"`)) {
|
||||
throw new Error(`Published registry does not contain anchor ${anchor}`)
|
||||
}
|
||||
|
||||
if (!searchUrls.has(expectedUrl)) {
|
||||
throw new Error(`Local search index does not contain canonical record ${expectedUrl}`)
|
||||
}
|
||||
}
|
||||
|
||||
const levelOneHtml = htmlByPage.get('level-1/index.html')
|
||||
if (!levelOneHtml.includes('class="doc-set-header"')) {
|
||||
throw new Error('Level 1 pages do not render the document header')
|
||||
}
|
||||
|
||||
const notFoundHtml = htmlByPage.get('404.html')
|
||||
if (!notFoundHtml.includes('Страница не найдена')) {
|
||||
throw new Error('404 page is not localized')
|
||||
}
|
||||
|
||||
const sitemap = await readFile(path.join(distRoot, 'sitemap.xml'), 'utf8')
|
||||
for (const forbiddenRoute of ['/ru/', '/domains/', '/specification/']) {
|
||||
if (sitemap.includes(forbiddenRoute)) {
|
||||
throw new Error(`Sitemap contains archival or excluded route ${forbiddenRoute}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Site check passed: ${actualPages.length - 1} pages and ${ruleIds.length} searchable rules.`)
|
||||
@@ -1,129 +0,0 @@
|
||||
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)
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user