Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/content_update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,6 @@ body:
attributes:
label: Чеклист Контрибьютора
options:
- label: Я буду редактировать контент в docs/, а не в сгенерированном content/
- label: Я буду редактировать контент в docs/ и пересоберу content manifest
- label: Я запущу prepare:content и validate:content перед открытием PR
- label: Я добавлю author metadata, если это уместно
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pnpm-debug.log*

# Build artifacts
*.tsbuildinfo
.cache/

# Python
__pycache__/
Expand Down
10 changes: 5 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
Основные директории:

- `docs/` - исходные материалы, которые редактируются вручную
- `content/` - синхронизированный слой, который использует приложение
- `.cache/content-manifest.json` - build-time manifest, который используют приложение, поиск и валидатор
- `resources/` - датасеты и дополнительные артефакты
- `public/` - публичные ассеты, включая `search-index.json`
- `scripts/` - синхронизация контента, валидация и сборка поискового индекса
- `scripts/` - сборка content manifest, валидация и сборка поискового индекса
- `.github/workflows/` - CI и деплой

## Локальный Запуск
Expand All @@ -54,7 +54,7 @@ npm run dev

1. Сделайте fork репозитория и создайте ветку от `main`.
2. Вносите изменения с понятной и ограниченной областью.
3. Если меняете документацию, редактируйте `docs/`, а не сгенерированный `content/`.
3. Если меняете документацию, редактируйте `docs/`; manifest пересобирается командой `prepare:content`.
4. Запустите нужные проверки локально.
5. Откройте pull request с понятным описанием и результатами проверки.

Expand All @@ -78,8 +78,8 @@ npm run validate:content
Пайплайн проекта устроен так:

1. `docs/` является редактируемым источником
2. `npm run content:sync` переносит материалы в `content/`
3. `npm run search:build` пересобирает `public/search-index.json`
2. `npm run content:manifest` собирает `.cache/content-manifest.json`
3. `npm run search:build` пересобирает `public/search-index.json` из manifest
4. `npm run prepare:content` выполняет оба шага

## Изменения В Коде
Expand Down
21 changes: 10 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ Production URL: https://minkinad.github.io/StackMIREA/

Актуально на 4 апреля 2026 года.

- 19 учебных треков в `content/`.
- 19 учебных треков в едином content manifest.
- 68 исходных Markdown/MDX-файлов в `docs/`.
- 71 синхронизированная Markdown/MDX-страница.
- 71 Markdown/MDX-страница в `.cache/content-manifest.json`.
- 52 отдельных учебных материала без учёта индексных страниц разделов.
- Крупнейшие треки: `java` (26 страниц), `ai` (9), `bigdata` (9), `python` (6), `procedural-programming` (6).
- Два workflow в CI/CD: `PR Checks` и `Deploy Docs to GitHub Pages`.
Expand All @@ -39,16 +39,16 @@ Production URL: https://minkinad.github.io/StackMIREA/
## Как устроен контент

- `docs/` - исходные материалы, которые редактируются вручную.
- `content/` - синхронизированный слой, который использует приложение.
- `.cache/content-manifest.json` - единый build-time manifest, который используют приложение, поиск и валидатор.
- `resources/` - дополнительные файлы, датасеты и артефакты практик.
- `scripts/` - генерация контента, поискового индекса и валидация ссылок.
- `scripts/` - сборка content manifest, поискового индекса и валидация ссылок.
- `public/search-index.json` - локальный поисковый индекс для страницы `/ask`.

Основной pipeline:

1. Материалы редактируются в `docs/`.
2. `npm run content:sync` переносит их в `content/`.
3. `npm run search:build` собирает поисковый индекс.
2. `npm run content:manifest` собирает `.cache/content-manifest.json` с slug, frontmatter, author, toc, preview, topics и hash.
3. `npm run search:build` собирает поисковый индекс из manifest.
4. `npm run prepare:content` объединяет оба шага.
5. `npm run build` запускает `prepare:content` автоматически через `prebuild`.

Expand Down Expand Up @@ -92,8 +92,9 @@ npm run dev
- `npm run start` - локальный запуск собранной статической версии на `:3000`.
- `npm run lint` - проверка ESLint.
- `npm run typecheck` - проверка TypeScript.
- `npm run prepare:content` - синхронизация контента и сборка поискового индекса.
- `npm run content:sync` - перенос `docs/` -> `content/`.
- `npm run prepare:content` - сборка content manifest и поискового индекса.
- `npm run content:manifest` - генерация `.cache/content-manifest.json` из `docs/`.
- `npm run content:sync` - compatibility alias для `content:manifest`.
- `npm run search:build` - генерация `public/search-index.json`.
- `npm run validate:content` - проверка markdown-ссылок, якорей и репозиторных ссылок в code fence.
- `npm run export` - информационный скрипт: static export выполняется внутри `next build`.
Expand All @@ -103,7 +104,6 @@ npm run dev
```text
app/
components/
content/
docs/
lib/
public/
Expand Down Expand Up @@ -136,7 +136,7 @@ SUPPORT.md
## Как вносить изменения

1. Добавьте или обновите материал в `docs/<track>/...`.
2. Запустите `npm run content:sync` или сразу `npm run prepare:content`.
2. Запустите `npm run content:manifest` или сразу `npm run prepare:content`.
3. Проверьте контент командой `npm run validate:content`.
4. Проверьте проект командами `npm run lint` и `npm run typecheck`.
5. Откройте Pull Request.
Expand All @@ -149,4 +149,3 @@ SUPPORT.md

- Код проекта распространяется по лицензии MIT. См. [LICENSE](./LICENSE).
- Контент сайта, статьи и учебные материалы - CC BY-NC-SA 4.0. См. [CC-BY-NC-SA-4.0](./CC-BY-NC-SA-4.0).

4 changes: 2 additions & 2 deletions app/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default async function DocPage({ params }: DocPageProps) {
const buildInfo = getBuildInfo();
const sidebarGroups = getSidebarGroups();
const pagination = getDocPagination(params.slug);
const { content, toc } = await compileDocMdx(doc.body);
const { content } = await compileDocMdx(doc.body, { collectToc: false });
const editUrl = doc.editPath ? `${GITHUB_EDIT_ROOT}/${doc.editPath}` : null;

return (
Expand Down Expand Up @@ -91,7 +91,7 @@ export default async function DocPage({ params }: DocPageProps) {
<Pagination prev={pagination.prev} next={pagination.next} />
</article>

<Toc items={toc} />
<Toc items={doc.toc} />
</div>
</>
);
Expand Down
2 changes: 1 addition & 1 deletion components/search/AskStackMirea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export function AskStackMirea() {
<section className="rounded-3xl border border-border/70 bg-card/70 p-5">
<p className="text-sm font-semibold tracking-tight">Как это работает</p>
<ul className="mt-4 space-y-3 text-sm leading-6 text-muted-foreground">
<li>Индекс собирается на build-time из `content/` и остаётся совместимым с GitHub Pages.</li>
<li>Индекс собирается на build-time из content manifest и остаётся совместимым с GitHub Pages.</li>
<li>Поиск учитывает title, description, секцию, чанки контента и словарь тематических синонимов.</li>
<li>Результаты ранжируются так, чтобы сверху были страницы с самым близким фрагментом по смыслу.</li>
</ul>
Expand Down
2 changes: 1 addition & 1 deletion docs/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@ slug: /intro
## Исходники

- Python: `pr_Python/`
- Java: `docs/java/` и `content/java/`
- Java: `docs/java/`
- GitHub: [minkinad/StackMIREA](https://github.com/minkinad/StackMIREA)
58 changes: 58 additions & 0 deletions lib/content-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import fs from "node:fs";
import path from "node:path";

import type { GitHubPerson } from "@/lib/authors";
import type { TocItem } from "@/lib/markdown";

export interface ContentManifestDoc {
slug: string[];
slugKey: string;
href: string;
title: string;
description: string;
author: GitHubPerson;
order: number;
editPath: string | null;
sourcePath: string | null;
virtualPath: string;
section: string;
sectionTitle: string;
body: string;
toc: TocItem[];
preview: string;
topics: string[];
anchors: string[];
hash: string;
isSectionIndex: boolean;
isGenerated: boolean;
}

export interface ContentManifest {
version: number;
generatedAt: string;
sourceRoot: string;
docs: ContentManifestDoc[];
}

const CONTENT_MANIFEST_PATH = path.join(process.cwd(), ".cache", "content-manifest.json");

let cachedManifest: ContentManifest | null = null;

export function getContentManifestPath() {
return CONTENT_MANIFEST_PATH;
}

export function getContentManifest() {
if (cachedManifest) {
return cachedManifest;
}

if (!fs.existsSync(CONTENT_MANIFEST_PATH)) {
throw new Error(
`Content manifest was not found at ${path.relative(process.cwd(), CONTENT_MANIFEST_PATH)}. Run "npm run prepare:content" first.`
);
}

cachedManifest = JSON.parse(fs.readFileSync(CONTENT_MANIFEST_PATH, "utf8")) as ContentManifest;
return cachedManifest;
}
9 changes: 7 additions & 2 deletions lib/mdx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,20 @@ const mdxComponents = {
CodeBlock
};

export async function compileDocMdx(source: string) {
interface CompileDocMdxOptions {
collectToc?: boolean;
}

export async function compileDocMdx(source: string, options: CompileDocMdxOptions = {}) {
const toc: TocItem[] = [];
const collectToc = options.collectToc ?? true;

const result = await compileMDX({
source,
options: {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [...getMarkdownRemarkPlugins(toc)],
remarkPlugins: [...getMarkdownRemarkPlugins(collectToc ? toc : [])],
rehypePlugins: [rehypeSlug]
}
},
Expand Down
116 changes: 7 additions & 109 deletions lib/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";

import type { GitHubPerson } from "@/lib/authors";
import { getDefaultDocAuthor, toGitHubPerson } from "@/lib/authors";
import { getContentManifest } from "@/lib/content-manifest";
import type { TocItem } from "@/lib/markdown";
import { getTrackOrder, getTrackTitle } from "@/lib/tracks";
import { toTitleCase } from "@/lib/utils";

export interface DocFrontmatter {
title?: string;
Expand All @@ -25,6 +21,10 @@ export interface DocEntry {
editPath: string | null;
section: string;
body: string;
toc: TocItem[];
preview: string;
topics: string[];
hash: string;
author: GitHubPerson;
isSectionIndex: boolean;
isGenerated: boolean;
Expand All @@ -47,52 +47,8 @@ interface DocsIndex {
sidebarGroups: SidebarGroup[];
}

const CONTENT_ROOT = path.join(process.cwd(), "content");
const DOCS_SOURCE_ROOT = path.join(process.cwd(), "docs");

let cachedDocsIndex: DocsIndex | null = null;

function walkMarkdownFiles(rootDirectory: string) {
const files: string[] = [];
const stack = [rootDirectory];

while (stack.length > 0) {
const currentDirectory = stack.pop();

if (!currentDirectory) {
break;
}

const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(currentDirectory, entry.name);

if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}

if (entry.isFile() && /\.(md|mdx)$/i.test(entry.name)) {
files.push(fullPath);
}
}
}

return files;
}

function normalizeSlug(relativePath: string) {
const withoutExtension = relativePath.replace(/\.(md|mdx)$/i, "");
const parts = withoutExtension.split(path.sep);

if (parts.at(-1) === "index") {
return parts.slice(0, -1);
}

return parts;
}

function getSectionOrder(section: string) {
return getTrackOrder(section);
}
Expand Down Expand Up @@ -122,55 +78,6 @@ function compareDocs(left: DocEntry, right: DocEntry) {
return left.title.localeCompare(right.title);
}

function resolveEditPath(relativePath: string) {
const candidates =
relativePath === path.join("algorithms", "getting-started.mdx")
? ["intro.mdx", "intro.md"]
: [relativePath, relativePath.replace(/\.mdx$/i, ".md")];

for (const candidate of candidates) {
const absoluteCandidatePath = path.join(DOCS_SOURCE_ROOT, candidate);

if (fs.existsSync(absoluteCandidatePath)) {
return candidate.replace(/\\/g, "/");
}
}

return null;
}

function createDocEntry(filePath: string): DocEntry {
const source = fs.readFileSync(filePath, "utf-8");
const parsed = matter(source);
const relativePath = path.relative(CONTENT_ROOT, filePath);
const slug = normalizeSlug(relativePath);
const slugKey = getSlugKey(slug);
const section = slug[0] ?? "docs";
const isSectionIndex = slug.length === 1;
const parsedOrder = Number(parsed.data.order ?? parsed.data.sidebar_position);
const safeOrder = Number.isFinite(parsedOrder) ? parsedOrder : isSectionIndex ? 0 : 9999;
const title = parsed.data.title?.toString().trim() || toTitleCase(slug.at(-1) ?? section);
const description = parsed.data.description?.toString().trim() || "";
const rawAuthor = parsed.data.author?.toString().trim();
const author = rawAuthor ? toGitHubPerson(rawAuthor) : getDefaultDocAuthor();
const editPath = resolveEditPath(relativePath);

return {
slug,
slugKey,
href: `/docs/${slug.join("/")}`,
title,
description,
order: safeOrder,
editPath,
section,
body: parsed.content,
author,
isSectionIndex,
isGenerated: editPath === null
};
}

function createSidebarGroups(docs: DocEntry[]) {
const groupsMap = new Map<string, SidebarGroup>();

Expand Down Expand Up @@ -215,16 +122,7 @@ function createSidebarGroups(docs: DocEntry[]) {
}

function buildDocsIndex(): DocsIndex {
if (!fs.existsSync(CONTENT_ROOT)) {
return {
docs: [],
docsBySlug: new Map(),
docOrderBySlug: new Map(),
sidebarGroups: []
};
}

const docs = walkMarkdownFiles(CONTENT_ROOT).map(createDocEntry).sort(compareDocs);
const docs = [...getContentManifest().docs].sort(compareDocs);
const docsBySlug = new Map<string, DocEntry>();
const docOrderBySlug = new Map<string, number>();

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
"start": "npx serve out -p 3000",
"lint": "next lint",
"typecheck": "tsc --noEmit --incremental false",
"prepare:content": "npm run content:sync && npm run search:build",
"content:sync": "node scripts/sync-content.mjs",
"prepare:content": "npm run content:manifest && npm run search:build",
"content:manifest": "node scripts/content-manifest.mjs",
"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"
Expand Down
2 changes: 1 addition & 1 deletion public/search-index.json

Large diffs are not rendered by default.

Loading
Loading