Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,12 @@ concurrency:
cancel-in-progress: true

jobs:
quality:
content-quality:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v4
Expand All @@ -30,18 +28,37 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Prepare generated content
run: npm run prepare:content
- name: Test content quality rules
run: npm run test:content

- name: Run content quality gates
run: npm run content:quality

- name: Upload content report
if: always()
uses: actions/upload-artifact@v4
with:
name: content-report-json
path: .cache/content-report.json
name: content-quality-report
if-no-files-found: warn
path: |
.cache/content-report.json
.cache/content-report.html

application-quality:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Validate markdown links and content references
run: npm run validate:content
- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint
Expand Down
46 changes: 41 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,46 @@ npm run dev
- `npm run prepare:content:warning` - сборка manifest в warning-режиме без падения по content issues.
- `npm run prepare:content:report` - генерация `content-report.json` с autofix suggestions.
- `npm run content:sync` - compatibility alias для `content:manifest`.
- `npm run content:quality` - сборка manifest/search index, запуск quality gates и создание JSON/HTML-отчетов.
- `npm run test:content` - unit-тесты движка, правил и генераторов отчетов.
- `npm run quality` - подготовка контента и полный запуск content quality gates.
- `npm run search:build` - генерация `public/search-index.json`.
- `npm run validate:content` - проверка markdown-ссылок, якорей и репозиторных ссылок в code fence.
- `npm run validate:content` - compatibility alias для `content:quality`.
- `npm run export` - информационный скрипт: static export выполняется внутри `next build`.

## Критерии качества контента

Полная локальная проверка контента:

```bash
npm run quality
npm run test:content
```

Отчеты сохраняются в:

- `.cache/content-report.json` - структурированный отчет для автоматической обработки;
- `.cache/content-report.html` - человекочитаемый отчет с метриками и найденными проблемами.

Ошибки блокируют CI:

- отсутствующий `description`;
- дублирующийся slug;
- некорректный author;
- битая внутренняя ссылка, anchor или ссылка на файл;
- пустой section index;
- страница крупнее 1 MiB;
- search chunk длиннее 420 символов.

Предупреждения не блокируют CI:

- страница крупнее 512 KiB;
- неиспользуемый файл из `resources/`;
- orphan page, на которую не ссылаются другие документы.

Пороговые значения и исключения находятся в
[`scripts/content-quality-config.mjs`](./scripts/content-quality-config.mjs).

## Структура проекта

```text
Expand All @@ -122,7 +158,7 @@ SUPPORT.md

## CI/CD и деплой

- [`.github/workflows/pr-check.yml`](./.github/workflows/pr-check.yml) проверяет `prepare:content`, `validate:content`, `lint`, `typecheck` и `build` для Pull Request.
- [`.github/workflows/pr-check.yml`](./.github/workflows/pr-check.yml) проверяет content quality rules, публикует JSON/HTML-отчет как artifact, а также запускает `lint`, `typecheck` и `build` для Pull Request.
- [`.github/workflows/deploy-gh-pages.yml`](./.github/workflows/deploy-gh-pages.yml) публикует сайт в GitHub Pages при пуше в `main` и при ручном запуске.
- В `Settings -> Pages` должен быть выбран `Source: GitHub Actions`.

Expand All @@ -138,9 +174,9 @@ SUPPORT.md
## Как вносить изменения

1. Добавьте или обновите материал в `docs/<track>/...`.
2. Запустите `npm run content:manifest` или сразу `npm run prepare:content`.
3. Проверьте контент командой `npm run validate:content`.
4. Проверьте проект командами `npm run lint` и `npm run typecheck`.
2. Запустите `npm run quality`.
3. Запустите `npm run test:content`.
4. Проверьте проект командами `npm run lint`, `npm run typecheck` и `npm run build`.
5. Откройте Pull Request.

Для каждого материала требуется поле `author` во frontmatter: GitHub login или ссылка на профиль.
Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@
"private": true,
"description": "Static-first documentation platform for StackMIREA.",
"scripts": {
"prepare:content": "npm run content:manifest && npm run search:build",
"content:manifest": "node scripts/content-manifest.mjs --mode=error",
"search:build": "node scripts/build-search-index.mjs",
"content:quality": "node scripts/content-quality.mjs",
"validate:content": "npm run content:quality",
"test:content": "node --test scripts/content-quality/__tests__/*.test.mjs",
"quality": "npm run content:quality",
"dev": "next dev",
"predev": "npm run prepare:content",
"build": "next build",
"export": "node -e \"console.log('Static export is produced by next build when output=\\'export\\'')\"",
"start": "npx serve out -p 3000",
"lint": "next lint",
"typecheck": "tsc --noEmit --incremental false",
"prepare:content": "npm run content:manifest && npm run search:build",
"prepare:content:warning": "node scripts/content-manifest.mjs --mode=warning && npm run search:build",
"prepare:content:report": "node scripts/content-manifest.mjs --mode=autofix-report",
"content:manifest": "node scripts/content-manifest.mjs --mode=error",
"content:sync": "npm run content:manifest",
"search:build": "node scripts/build-search-index.mjs",
"validate:content": "node scripts/validate-content.mjs",
"prebuild": "npm run prepare:content"
},
"dependencies": {
Expand Down Expand Up @@ -53,7 +56,4 @@
"engines": {
"node": ">=20.0.0"
}


}

2 changes: 1 addition & 1 deletion public/search-index.json

Large diffs are not rendered by default.

51 changes: 37 additions & 14 deletions scripts/build-search-index.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { findTopics, manifestPath, readContentManifest, stripMarkdown, normalizeSearchValue } from "./content-manifest.mjs";
import {
findTopics,
manifestPath,
normalizeSearchValue,
readContentManifest,
stripMarkdown
} from "./content-manifest.mjs";

const PUBLIC_ROOT = path.join(process.cwd(), "public");
const SEARCH_INDEX_PATH = path.join(PUBLIC_ROOT, "search-index.json");
Expand Down Expand Up @@ -108,7 +115,7 @@ function extractKeywords(values, limit = 18) {
.map(([token]) => token);
}

function createChunks(source, docKeywords) {
export function createChunks(source, docKeywords) {
const lines = source.split("\n");
const chunks = [];
let currentHeading = "";
Expand Down Expand Up @@ -162,7 +169,7 @@ function createChunks(source, docKeywords) {
return chunks;
}

function createDoc(doc) {
export function createSearchDocument(doc) {
const docKeywords = [doc.title, doc.description, doc.section, doc.sectionTitle];
const chunks = createChunks(doc.body, docKeywords);
const chunkTopics = chunks.flatMap((chunk) => chunk.topics);
Expand All @@ -183,15 +190,9 @@ function createDoc(doc) {
};
}

function buildSearchIndex() {
if (!fs.existsSync(manifestPath)) {
console.error('Content manifest ".cache/content-manifest.json" was not found. Run "npm run content:manifest" first.');
process.exit(1);
}

const manifest = readContentManifest();
export function buildSearchIndex(manifest) {
const docs = manifest.docs
.map(createDoc)
.map(createSearchDocument)
.sort((left, right) => left.title.localeCompare(right.title));

return {
Expand All @@ -201,6 +202,28 @@ function buildSearchIndex() {
};
}

fs.mkdirSync(PUBLIC_ROOT, { recursive: true });
fs.writeFileSync(SEARCH_INDEX_PATH, JSON.stringify(buildSearchIndex()), "utf8");
console.log(`Search index generated at ${path.relative(process.cwd(), SEARCH_INDEX_PATH)}`);
export function writeSearchIndex(index, targetPath = SEARCH_INDEX_PATH) {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, `${JSON.stringify(index)}\n`, "utf8");
}

const executedPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
const currentPath = fileURLToPath(import.meta.url);

if (executedPath === currentPath) {
if (!fs.existsSync(manifestPath)) {
console.error(
'Content manifest ".cache/content-manifest.json" was not found. Run "npm run content:manifest" first.'
);
process.exitCode = 1;
} else {
const manifest = readContentManifest();
const index = buildSearchIndex(manifest);

writeSearchIndex(index);

console.log(
`Search index generated at ${path.relative(process.cwd(), SEARCH_INDEX_PATH)}`
);
}
}
18 changes: 18 additions & 0 deletions scripts/content-quality-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const contentQualityConfig = {
pageSize: {
warningBytes: 512 * 1024,
errorBytes: 1024 * 1024
},
searchChunk: {
maxCharacters: 420
},
sectionIndex: {
minTextCharacters: 80
},
orphanPages: {
ignoredSlugs: []
},
resources: {
ignoredPaths: ["resources/bigdata/README.md"]
}
};
68 changes: 68 additions & 0 deletions scripts/content-quality.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { buildSearchIndex, writeSearchIndex } from "./build-search-index.mjs";
import {
ContentRepository,
ManifestBuilder,
projectRoot
} from "./content-manifest.mjs";
import { contentQualityConfig } from "./content-quality-config.mjs";
import { ValidationEngine } from "./content-quality/engine.mjs";
import { writeHtmlReport } from "./content-quality/html-reporter.mjs";
import { collectResourceFiles } from "./content-quality/resources.mjs";
import { contentQualityRules } from "./content-quality/rules/index.mjs";
import {
createContentReport,
finalizeReport,
writeJsonReport
} from "./content-report.mjs";

function toStructuredManifestIssue(issue) {
return {
ruleId: issue.code,
severity: issue.severity,
file: issue.file ?? `generated:${issue.virtualPath ?? "content-manifest"}`,
message: issue.message,
details: {
virtualPath: issue.virtualPath ?? null
}
};
}

const manifestBuilder = new ManifestBuilder(new ContentRepository(), "error");
const manifest = manifestBuilder.write();
const searchIndex = buildSearchIndex(manifest);
const resources = collectResourceFiles(projectRoot);

writeSearchIndex(searchIndex);

const engine = new ValidationEngine(contentQualityRules);
const issues = engine.run({
projectRoot,
manifest,
searchIndex,
resources,
config: contentQualityConfig
});

const report = createContentReport("quality");
report.summary.documents = manifest.docs.length;
report.summary.resources = resources.length;
report.summary.searchChunks = searchIndex.docs.reduce(
(total, doc) => total + doc.chunks.length,
0
);
report.issues.push(
...manifestBuilder.report.issues
.filter((issue) => !["missing-description", "duplicate-slug"].includes(issue.code))
.map(toStructuredManifestIssue),
...issues
);

finalizeReport(report);
writeJsonReport(report);
writeHtmlReport(report);

const summary = `${report.summary.errors} error(s), ${report.summary.warnings} warning(s)`;
console.log(`Content quality ${report.status}: ${summary}.`);
console.log("Reports generated at .cache/content-report.json and .cache/content-report.html.");

process.exitCode = report.summary.errors > 0 ? 1 : 0;
Loading
Loading