diff --git a/.github/ISSUE_TEMPLATE/content_update.yml b/.github/ISSUE_TEMPLATE/content_update.yml index e9378e9..57657b1 100644 --- a/.github/ISSUE_TEMPLATE/content_update.yml +++ b/.github/ISSUE_TEMPLATE/content_update.yml @@ -44,6 +44,6 @@ body: attributes: label: Чеклист Контрибьютора options: - - label: Я буду редактировать контент в docs/, а не в сгенерированном content/ + - label: Я буду редактировать контент в docs/ и пересоберу content manifest - label: Я запущу prepare:content и validate:content перед открытием PR - label: Я добавлю author metadata, если это уместно diff --git a/.gitignore b/.gitignore index 6d9a85a..2c0687e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ pnpm-debug.log* # Build artifacts *.tsbuildinfo +.cache/ # Python __pycache__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43d7784..a908df2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 и деплой ## Локальный Запуск @@ -54,7 +54,7 @@ npm run dev 1. Сделайте fork репозитория и создайте ветку от `main`. 2. Вносите изменения с понятной и ограниченной областью. -3. Если меняете документацию, редактируйте `docs/`, а не сгенерированный `content/`. +3. Если меняете документацию, редактируйте `docs/`; manifest пересобирается командой `prepare:content`. 4. Запустите нужные проверки локально. 5. Откройте pull request с понятным описанием и результатами проверки. @@ -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` выполняет оба шага ## Изменения В Коде diff --git a/README.md b/README.md index f94a812..0774670 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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`. @@ -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`. @@ -103,7 +104,6 @@ npm run dev ```text app/ components/ -content/ docs/ lib/ public/ @@ -136,7 +136,7 @@ SUPPORT.md ## Как вносить изменения 1. Добавьте или обновите материал в `docs//...`. -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. @@ -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). - diff --git a/app/docs/[...slug]/page.tsx b/app/docs/[...slug]/page.tsx index 23f9e2e..061d6bc 100644 --- a/app/docs/[...slug]/page.tsx +++ b/app/docs/[...slug]/page.tsx @@ -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 ( @@ -91,7 +91,7 @@ export default async function DocPage({ params }: DocPageProps) { - + ); diff --git a/components/search/AskStackMirea.tsx b/components/search/AskStackMirea.tsx index da37efb..782a5e5 100644 --- a/components/search/AskStackMirea.tsx +++ b/components/search/AskStackMirea.tsx @@ -257,7 +257,7 @@ export function AskStackMirea() {

Как это работает

    -
  • Индекс собирается на build-time из `content/` и остаётся совместимым с GitHub Pages.
  • +
  • Индекс собирается на build-time из content manifest и остаётся совместимым с GitHub Pages.
  • Поиск учитывает title, description, секцию, чанки контента и словарь тематических синонимов.
  • Результаты ранжируются так, чтобы сверху были страницы с самым близким фрагментом по смыслу.
diff --git a/docs/intro.md b/docs/intro.md index 845f31b..dadbe44 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -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) diff --git a/lib/content-manifest.ts b/lib/content-manifest.ts new file mode 100644 index 0000000..e79e0e1 --- /dev/null +++ b/lib/content-manifest.ts @@ -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; +} diff --git a/lib/mdx.ts b/lib/mdx.ts index 8c5b979..cb6fff6 100644 --- a/lib/mdx.ts +++ b/lib/mdx.ts @@ -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] } }, diff --git a/lib/navigation.ts b/lib/navigation.ts index 89587ad..bbdeae7 100644 --- a/lib/navigation.ts +++ b/lib/navigation.ts @@ -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; @@ -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; @@ -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); } @@ -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(); @@ -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(); const docOrderBySlug = new Map(); diff --git a/package.json b/package.json index e02c49b..75fd7ba 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/search-index.json b/public/search-index.json index 8bc6b43..8d3d579 100644 --- a/public/search-index.json +++ b/public/search-index.json @@ -1 +1 @@ -{"version":1,"generatedAt":"2026-04-08T20:02:48.908Z","docs":[{"id":"ai","href":"/docs/ai","slug":["ai"],"section":"ai","sectionTitle":"AI","title":"AI","description":"Рабочие тетради по искусственному интеллекту в формате MDX.","preview":"AI обзор Раздел объединяет 8 рабочих тетрадей по дисциплине «Искусственный интеллект», перенесенных в MDX формат. Материалы идут от базового Python и научных библиотек к классическим ML методам, нейросетям, эволюционным алгоритмам и кластеризации. Что внутри P","keywords":["notebook","python","ai","ml","данных","деревья","методы","раздел","решений","knn","mdx","numpy","pandas","алгоритмам","базового","библиотек","внутри","генетические"],"topics":["python","ai","knn","pandas","numpy","oop","algorithms","clustering","regression"],"chunks":[{"id":"chunk-0","heading":"AI обзор","text":"Раздел объединяет 8 рабочих тетрадей по дисциплине «Искусственный интеллект», перенесенных в MDX формат. Материалы идут от базового Python и научных библиотек к классическим ML методам, нейросетям, эволюционным алгоритмам и кластеризации.","keywords":["ai","mdx","mdx.","ml","python","алгоритмам","базового","библиотек","дисциплине","идут","интеллект","интеллекту"],"topics":["python","ai","oop","algorithms"]},{"id":"chunk-1","heading":"Что внутри","text":"Python, NumPy и pandas для подготовки данных; метрики расстояния, KNN и базовые техники классификации; регрессия и деревья решений; эволюционные методы, нейросети и кластеризация.","keywords":["ai","knn","mdx.","numpy","pandas","python","базовые","внутри","данных","деревья","интеллекту","искусственному"],"topics":["python","knn","pandas","numpy","oop","clustering","regression"]},{"id":"chunk-2","heading":"Ноутбуки","text":"Notebook 1 — Основа Python типы данных, условия, циклы и вводные примеры. Notebook 2 — NumPy и pandas массивы, таблицы и базовая подготовка данных. Notebook 3 — Метрики и KNN расстояния между объектами и классификация ближайших соседей. Notebook 4 — Регрессия линейные модели, аппроксимация и оценка качества. Notebook 5 — Деревья решений деревья решений и работа с классификаторами","keywords":["notebook","ai","деревья","решений","knn","mdx.","numpy","pandas","python","аппроксимация","базовая","ближайших"],"topics":["python","knn","pandas","numpy","oop","regression"]},{"id":"chunk-3","heading":"Ноутбуки","text":". Notebook 6 — Генетические и эволюционные методы оптимизация, генетические алгоритмы и отжиг. Notebook 7 — Нейронные сети персептрон, MLP и основы обучения сети. Notebook 8 — Кластеризация методы группировки данных без учителя.","keywords":["ai","notebook","генетические","методы","mdx.","mlp","алгоритмы","без","группировки","данных","интеллекту","искусственному"],"topics":["ai","algorithms","clustering"]},{"id":"chunk-4","heading":"Как читать раздел","text":"Начните с Notebook 1 и Notebook 2 , если нужно выровнять базу по Python и обработке данных. Для классического ML переходите к Notebook 3 , Notebook 4 и Notebook 5 . Темы оптимизации и более продвинутых подходов собраны в Notebook 6 , Notebook 7 и Notebook 8 .","keywords":["notebook","ai","mdx.","ml","python","базу","более","выровнять","данных.","если","интеллекту","искусственному"],"topics":["python","ai","oop"]}]},{"id":"ai/notebook-01-python-basics","href":"/docs/ai/notebook-01-python-basics","slug":["ai","notebook-01-python-basics"],"section":"ai","sectionTitle":"AI","title":"AI Notebook 1 — Основа Python","description":"Типы данных, условия, циклы и старт работы с NumPy.","preview":"Основа Python. Библиотеки. Дата 25.02.2023 1.1. Теоретический материал – Типы данных Типы данных Все типы данных в Python относятся к одной из 2 х категорий: изменяемые (mutable) и неизменяемые (immutable). Неизменяемые объекты: исловые данные (int, float), bo","keywords":["print","python","type","задача","10","elif","dev","от","plt.plot","range","данных","apple","df","if","import","rng","trapz","массива"],"topics":["python","ai","knn","numpy"],"chunks":[{"id":"chunk-0","heading":"","text":"Типы данных Все типы данных в Python относятся к одной из 2 х категорий: изменяемые (mutable) и неизменяемые (immutable). Неизменяемые объекты: исловые данные (int, float), bool, None, символьные строки (class 'str'), кортежи (tuple). Изменяемые объекты: списки (list), множества (set), словари (dict).","keywords":["ai","данных","типы","python","изменяемые","неизменяемые","объекты","bool","class","dict","float","immutable"],"topics":["python"]},{"id":"chunk-1","heading":"","text":"python x= 3+5.2 7 y= None z= 'a',5,12.345, (2,'b') df= [['Антонова Антонина',34,'ж'],['Борисов Борис',26,'м']] A={1,'title',2,'content'} print(x,' ',type(x),'\\n',y,' ',type(y),'\\n',df,' ',type(df),'\\n',A,' ',type(A),'\\n')","keywords":["type","ai","df","python","12.345","26","3+5.2","34","content","none","notebook","numpy."],"topics":["python"]},{"id":"chunk-2","heading":"","text":"code 39.4 None [['Антонова Антонина', 34, 'ж'], ['Борисов Борис', 26, 'м']] {'content', 1, 2, 'title'}","keywords":["ai","26","34","39.4","code","content","none","notebook","numpy.","python","title","антонина"],"topics":[]},{"id":"chunk-3","heading":"","text":"python x=5 =2 A={ 1,3,7,8} B={2,4,5,10,'apple'} C=A&B df= 'Антонова Антонина',34,'ж' z='type' D=[1,'title',2,'connect'] print(x,' ',type(x),'\\n',A,' ',type(A),'\\n',B,' ',type(B),'\\n',C,' ',type(C),'\\n',df,' ',type(df),'\\n',z,' ',type(z),'\\n',D,' ',type(D),'\\n')","keywords":["type","ai","df","python","10","34","apple","connect","notebook","numpy.","print","title"],"topics":["python"]},{"id":"chunk-4","heading":"","text":"text True {8, 1, 3, 7} {2, 'apple', 4, 5, 10} set() ('Антонова Антонина', 34, 'ж') type [1, 'title', 2, 'connect']","keywords":["ai","10","34","apple","connect","notebook","numpy.","python","set","text","title","true"],"topics":[]},{"id":"chunk-5","heading":"","text":"В коде часто приходится проверять выполнимость или невыполнимость каких то условий. Синтаксис:","keywords":["ai","notebook","numpy.","python","выполнимость","данных","каких","коде","невыполнимость","основа","приходится","проверять"],"topics":[]},{"id":"chunk-6","heading":"","text":"Обратите внимание, что код, который должен выполняться внутри каждого условия, записывается с отступом в 4 пробела от уровня if, elif и else: в питоне области видимости переменных обозначаются отступами.","keywords":["ai","условия","elif","else","if","notebook","numpy.","python","видимости","внимание","внутри","выполняться"],"topics":["python"]},{"id":"chunk-7","heading":"","text":"То есть, отступы позволяют понять, где начинается код, который должен выполняться при выполнении условия в if, и где заканчивается.","keywords":["ai","условия","if","notebook","numpy.","python","выполнении","выполняться","данных","должен","заканчивается.","код"],"topics":[]},{"id":"chunk-8","heading":"","text":"Задача:Вывести на экран является ли переменная х положительной, отрицательной или равна нулю.","keywords":["ai","notebook","numpy.","python","вывести","данных","задача","ли","нулю.","основа","отрицательной","переменная"],"topics":[]},{"id":"chunk-9","heading":"","text":"python x=125 if x<0: print('x отрицательный') elif x==0: print('x равен 0') else: print('x положительный')","keywords":["ai","print","python","125","elif","else","if","notebook","numpy.","данных","основа","отрицательный"],"topics":["python"]},{"id":"chunk-10","heading":"","text":"Задача: Напишите код. Задается х, напечатать какому из интервалов принадлежит: ( infinity, 5), [ 5, 5] или от (5, +infinity)","keywords":["ai","+infinity","infinity","notebook","numpy.","python","данных","задается","задача","интервалов","какому","код."],"topics":[]},{"id":"chunk-11","heading":"","text":"python x=int(input()) if x < 5: print('x пренадлежит интервалу от бесконечности до 5') elif 5