@ozdao/scriptorium 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +119 -2
  2. package/bin/scriptorium.js +180 -0
  3. package/package.json +40 -4
  4. package/public/favicon/favicon.svg +1 -0
  5. package/src/client.js +29 -0
  6. package/src/components/Badge.vue +29 -0
  7. package/src/components/HeaderNav.vue +46 -0
  8. package/src/components/Layout.vue +34 -0
  9. package/src/components/Outline.vue +74 -0
  10. package/src/components/Page.vue +33 -0
  11. package/src/components/PrevNext.vue +37 -0
  12. package/src/components/SearchBox.vue +381 -0
  13. package/src/components/SearchButton.vue +36 -0
  14. package/src/components/SidebarTree.vue +114 -0
  15. package/src/components/SiteTitle.vue +11 -0
  16. package/src/components/ThemeToggle.vue +31 -0
  17. package/src/configs/client.config.js +38 -0
  18. package/src/configs/site.config.js +8 -0
  19. package/src/content/current-page.js +54 -0
  20. package/src/content/markdown.js +182 -0
  21. package/src/content/markdown.loader.js +95 -0
  22. package/src/content/pages.js +5 -0
  23. package/src/content/pages.loader.js +33 -0
  24. package/src/content/scan.js +101 -0
  25. package/src/content/search-index.js +7 -0
  26. package/src/content/search-index.loader.js +144 -0
  27. package/src/content/search-options.js +16 -0
  28. package/src/content/sidebar.js +99 -0
  29. package/src/content/site-config.js +95 -0
  30. package/src/content/use-docs.js +21 -0
  31. package/src/design-system/doc.scss +257 -0
  32. package/src/design-system/index.js +19 -0
  33. package/src/design-system/layout.scss +416 -0
  34. package/src/design-system/theme.scss +45 -0
  35. package/src/localization/index.js +27 -0
  36. package/src/main.js +8 -0
  37. package/src/router/index.js +101 -0
  38. package/src/router/links.js +56 -0
  39. package/src/server.js +59 -0
@@ -0,0 +1,182 @@
1
+ // Рендер markdown страницы документации в HTML — один для loader'а страниц
2
+ // (markdown.loader.js) и индекса поиска (search-index.loader.js): текст в поиске
3
+ // и на странице обязан совпадать, вплоть до якорей заголовков.
4
+ //
5
+ // markdown-it и подсветка shiki — по одному экземпляру на процесс: highlighter
6
+ // грузит грамматики и темы асинхронно и дорого, создавать его на каждый файл
7
+ // нельзя. Языки кода догружаются по мере появления в документах.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import matter from 'gray-matter';
11
+ import MarkdownIt from 'markdown-it';
12
+ import anchor from 'markdown-it-anchor';
13
+ import container from 'markdown-it-container';
14
+ import { createHighlighter, bundledLanguages } from 'shiki';
15
+
16
+ import { pageUrl } from './scan.js';
17
+
18
+ const code_themes = { light: 'github-light', dark: 'github-dark' };
19
+
20
+ // Подписи блоков по умолчанию — как у VitePress; своя подпись пишется после имени: ::: tip Совет
21
+ const block_titles = {
22
+ tip: 'TIP',
23
+ warning: 'WARNING',
24
+ danger: 'DANGER',
25
+ info: 'INFO',
26
+ details: 'Details',
27
+ };
28
+
29
+ // Якорь заголовка — тот же, что строит VitePress (@mdit-vue/shared): ссылки
30
+ // вида #заголовок, написанные под старый сайт, продолжают вести куда вели.
31
+ const control_characters = /[\u0000-\u001f]/g;
32
+ const special_characters = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
33
+ const combining_marks = /[̀-ͯ]/g;
34
+
35
+ function slugify(text) {
36
+ return text
37
+ .normalize('NFKD')
38
+ .replace(combining_marks, '')
39
+ .replace(control_characters, '')
40
+ .replace(special_characters, '-')
41
+ .replace(/-{2,}/g, '-')
42
+ .replace(/^-+|-+$/g, '')
43
+ .replace(/^(\d)/, '_$1')
44
+ .toLowerCase();
45
+ }
46
+
47
+ function escapeHtml(text) {
48
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
49
+ }
50
+
51
+ let highlighter_promise = null;
52
+
53
+ function getHighlighter() {
54
+ highlighter_promise ??= createHighlighter({ themes: Object.values(code_themes), langs: [] });
55
+ return highlighter_promise;
56
+ }
57
+
58
+ // Язык блока кода: первое слово info-строки ('js {1,3}' → 'js'). Неизвестный
59
+ // shiki язык красится как текст, а не роняет сборку.
60
+ function fenceLanguage(info) {
61
+ const name = (info || '').trim().split(/\s+/)[0].toLowerCase();
62
+ return name in bundledLanguages ? name : 'text';
63
+ }
64
+
65
+ let markdown = null;
66
+
67
+ function getMarkdown(highlighter) {
68
+ if (markdown) return markdown;
69
+
70
+ markdown = new MarkdownIt({
71
+ html: true,
72
+ linkify: true,
73
+ highlight: (code, info) => highlighter.codeToHtml(code, {
74
+ lang: fenceLanguage(info),
75
+ themes: code_themes,
76
+ defaultColor: false,
77
+ }),
78
+ });
79
+
80
+ markdown.use(anchor, {
81
+ level: [2, 3, 4, 5, 6],
82
+ slugify,
83
+ permalink: anchor.permalink.linkInsideHeader({
84
+ symbol: '#',
85
+ placement: 'before',
86
+ class: 'header-anchor',
87
+ }),
88
+ });
89
+
90
+ for (const name of ['tip', 'warning', 'danger', 'info']) {
91
+ markdown.use(container, name, {
92
+ render(tokens, index) {
93
+ const token = tokens[index];
94
+ if (token.nesting !== 1) return '</div>\n';
95
+ const title = token.info.trim().slice(name.length).trim() || block_titles[name];
96
+ return `<div class="${name} custom-block"><p class="custom-block-title">${escapeHtml(title)}</p>\n`;
97
+ },
98
+ });
99
+ }
100
+
101
+ markdown.use(container, 'details', {
102
+ render(tokens, index) {
103
+ const token = tokens[index];
104
+ if (token.nesting !== 1) return '</details>\n';
105
+ const title = token.info.trim().slice('details'.length).trim() || block_titles.details;
106
+ return `<details class="details custom-block"><summary>${escapeHtml(title)}</summary>\n`;
107
+ },
108
+ });
109
+
110
+ // Таблицы — блоком .table дизайн-системы martyrs.
111
+ markdown.renderer.rules.table_open = (tokens, index, options, env, self) => {
112
+ tokens[index].attrJoin('class', 'table');
113
+ return self.renderToken(tokens, index, options);
114
+ };
115
+
116
+ // Ссылки: на .md страницы сайта → адрес страницы; внешние — в новой вкладке.
117
+ // Ссылка на .md в пропущенной папке (шаблоны, ../marketing) страницей не
118
+ // является и остаётся как есть; на несуществующий файл — предупреждение сборки.
119
+ markdown.renderer.rules.link_open = (tokens, index, options, env, self) => {
120
+ const token = tokens[index];
121
+ const href = token.attrGet('href');
122
+
123
+ if (href && /^(https?:)?\/\//i.test(href)) {
124
+ token.attrSet('target', '_blank');
125
+ token.attrSet('rel', 'noreferrer');
126
+ } else if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href)) {
127
+ const clean = href.replace(/[?#].*$/, '');
128
+ if (clean.endsWith('.md')) {
129
+ const decoded = safeDecode(clean);
130
+ const target = decoded.startsWith('/')
131
+ ? path.posix.normalize(decoded.slice(1))
132
+ : path.posix.normalize(path.posix.join(path.posix.dirname(env.file), decoded));
133
+
134
+ if (env.page_files.has(target)) {
135
+ token.attrSet('href', encodeURI(pageUrl(target)) + href.slice(clean.length));
136
+ } else if (!fs.existsSync(path.join(env.docs_root, target))) {
137
+ env.warnings.push(`битая ссылка "${href}": файла ${target} нет`);
138
+ }
139
+ }
140
+ }
141
+
142
+ return self.renderToken(tokens, index, options);
143
+ };
144
+
145
+ return markdown;
146
+ }
147
+
148
+ export function safeDecode(value) {
149
+ try {
150
+ return decodeURI(value);
151
+ } catch {
152
+ return value;
153
+ }
154
+ }
155
+
156
+ // Файл документации → frontmatter, токены markdown-it и HTML.
157
+ // source — текст файла;
158
+ // file — путь относительно корня репозитория (для ссылок и сообщений);
159
+ // page_files — Set путей всех страниц сайта: ссылки на .md → адреса страниц.
160
+ // warnings — предупреждения сборки (битые ссылки); выводит их вызывающий.
161
+ export async function renderMarkdown({ source, file, docs_root, page_files }) {
162
+ let parsed;
163
+ try {
164
+ parsed = matter(source);
165
+ } catch (error) {
166
+ throw new Error(`[scriptorium] битый frontmatter в ${path.join(docs_root, file)}: ${error.message}`);
167
+ }
168
+
169
+ const highlighter = await getHighlighter();
170
+ const md = getMarkdown(highlighter);
171
+
172
+ const env = { docs_root, file, page_files, warnings: [] };
173
+ const tokens = md.parse(parsed.content, env);
174
+
175
+ const languages = new Set(tokens.filter((token) => token.type === 'fence').map((token) => fenceLanguage(token.info)));
176
+ const missing = [...languages].filter((name) => name !== 'text' && !highlighter.getLoadedLanguages().includes(name));
177
+ if (missing.length) await highlighter.loadLanguage(...missing);
178
+
179
+ const html = md.renderer.render(tokens, md.options, env);
180
+
181
+ return { frontmatter: parsed.data, tokens, html, warnings: env.warnings };
182
+ }
@@ -0,0 +1,95 @@
1
+ // Rspack-loader для .md: страница документации → JS-модуль
2
+ // export const frontmatter — данные frontmatter (PageMeta потребителя)
3
+ // export const title — первый h1 (заголовок вкладки)
4
+ // export const headers — заголовки для оглавления [{ level, title, slug }]
5
+ // export default — Vue-компонент с готовым HTML
6
+ //
7
+ // Сам рендер markdown (markdown-it, подсветка shiki, ссылки на страницы) — в
8
+ // markdown.js: его же зовёт индекс поиска (search-index.loader.js).
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ import { config_file, docs_root, listMarkdown, loadSiteConfig } from './scan.js';
13
+ import { renderMarkdown, safeDecode } from './markdown.js';
14
+
15
+ // Текст заголовка без разметки: так его видит читатель и так он встаёт в оглавление.
16
+ function inlineText(token) {
17
+ return (token.children || [])
18
+ .filter((child) => child.type === 'text' || child.type === 'code_inline')
19
+ .map((child) => child.content)
20
+ .join('')
21
+ .trim();
22
+ }
23
+
24
+ // Относительные картинки → import: rspack кладёт файл в сборку (asset/resource)
25
+ // и подставляет его адрес. HTML режется на куски, между ними — переменные импортов.
26
+ function splitImages(html, md_dir, warnings) {
27
+ const imports = [];
28
+ const parts = [];
29
+ const image_source = /(<img\b[^>]*?\ssrc=")([^"]+)(")/gi;
30
+ let last_index = 0;
31
+
32
+ for (const match of html.matchAll(image_source)) {
33
+ const source = match[2];
34
+ if (/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(source)) continue;
35
+
36
+ const file = path.resolve(md_dir, safeDecode(source.replace(/&amp;/g, '&')).replace(/[?#].*$/, ''));
37
+ if (!fs.existsSync(file)) {
38
+ warnings.push(`картинка "${source}" не найдена: ${file}`);
39
+ continue;
40
+ }
41
+
42
+ const start = match.index + match[1].length;
43
+ parts.push(JSON.stringify(html.slice(last_index, start)));
44
+ parts.push(`image_${imports.length}`);
45
+ imports.push(`import image_${imports.length} from ${JSON.stringify(file)};`);
46
+ last_index = start + source.length;
47
+ }
48
+
49
+ parts.push(JSON.stringify(html.slice(last_index)));
50
+ return { imports, html_code: parts.join(' + ') };
51
+ }
52
+
53
+ export default function markdownLoader(source) {
54
+ const callback = this.async();
55
+ const md_file = this.resourcePath;
56
+
57
+ (async () => {
58
+ const config = await loadSiteConfig();
59
+ this.addDependency(config_file);
60
+
61
+ const file = path.relative(docs_root, md_file).split(path.sep).join('/');
62
+ const { frontmatter, tokens, html, warnings } = await renderMarkdown({
63
+ source,
64
+ file,
65
+ docs_root,
66
+ page_files: new Set(listMarkdown(docs_root, config)),
67
+ });
68
+
69
+ const [level_from, level_to] = config.outline.level;
70
+ const headers = [];
71
+ let title = null;
72
+
73
+ tokens.forEach((token, index) => {
74
+ if (token.type !== 'heading_open') return;
75
+ const level = Number(token.tag.slice(1));
76
+ const text = inlineText(tokens[index + 1]);
77
+ if (level === 1 && title === null) title = text;
78
+ const slug = token.attrGet('id');
79
+ if (slug && level >= level_from && level <= level_to) headers.push({ level, title: text, slug });
80
+ });
81
+
82
+ const { imports, html_code } = splitImages(html, path.dirname(md_file), warnings);
83
+
84
+ for (const warning of warnings) this.emitWarning(new Error(`[scriptorium] ${file}: ${warning}`));
85
+
86
+ return `import { h } from 'vue';
87
+ ${imports.join('\n')}
88
+ export const frontmatter = ${JSON.stringify(frontmatter)};
89
+ export const title = ${JSON.stringify(title)};
90
+ export const headers = ${JSON.stringify(headers)};
91
+ const html = ${html_code};
92
+ export default { name: 'DocPage', render() { return h('div', { class: 'doc', innerHTML: html }); } };
93
+ `;
94
+ })().then((code) => callback(null, code), (error) => callback(error));
95
+ }
@@ -0,0 +1,5 @@
1
+ // Список страниц сайта. Содержимое модуля целиком генерирует pages.loader.js при
2
+ // сборке (правило в bin/scriptorium.js): скан папок документации →
3
+ // export const pages = [{ url, file, title, frontmatter, load }]
4
+ // Код ниже — только на случай, если файл импортирован мимо сборки.
5
+ export const pages = [];
@@ -0,0 +1,33 @@
1
+ // Rspack-loader модуля pages.js: сканирует репозиторий документации и отдаёт
2
+ // список страниц с ленивой загрузкой каждой ('@docs/<файл>.md' → чанк страницы).
3
+ //
4
+ // Зависимости для пересборки списка:
5
+ // - контекст корня репозитория — добавление и удаление .md;
6
+ // - каждый .md — правка frontmatter меняет подпись в сайдбаре;
7
+ // - config.js — skipped_names и прочее меняют состав страниц.
8
+ import path from 'node:path';
9
+
10
+ import { config_file, docs_root, loadSiteConfig, scanPages } from './scan.js';
11
+
12
+ export default function pagesLoader() {
13
+ const callback = this.async();
14
+
15
+ (async () => {
16
+ const config = await loadSiteConfig();
17
+ const pages = scanPages(docs_root, config);
18
+
19
+ this.addContextDependency(docs_root);
20
+ this.addDependency(config_file);
21
+ for (const page of pages) this.addDependency(path.join(docs_root, page.file));
22
+
23
+ const entries = pages.map((page) => ` {
24
+ url: ${JSON.stringify(page.url)},
25
+ file: ${JSON.stringify(page.file)},
26
+ title: ${JSON.stringify(page.title)},
27
+ frontmatter: ${JSON.stringify(page.frontmatter)},
28
+ load: () => import(${JSON.stringify(`@docs/${page.file}`)}),
29
+ }`);
30
+
31
+ return `export const pages = [\n${entries.join(',\n')}\n];\n`;
32
+ })().then((code) => callback(null, code), (error) => callback(error));
33
+ }
@@ -0,0 +1,101 @@
1
+ // Обход репозитория документации: какие .md становятся страницами и по каким
2
+ // адресам. Работает в node (loader'ы сборки), в бандл сайта не попадает.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import matter from 'gray-matter';
7
+
8
+ import { resolveSiteConfig } from './site-config.js';
9
+
10
+ // CLI запускается из папки documentation/ (как vitepress из папки сайта): она же
11
+ // корень приложения martyrs — package.json, node_modules, config.js, theme/,
12
+ // public/, builds/. Корень репозитория с .md — папка над ней. cwd общий на
13
+ // процесс: его видят и loader'ы сборки, и server.js в dev-воркере.
14
+ export const docs_dir = process.cwd();
15
+ export const docs_root = path.resolve(docs_dir, '..');
16
+ export const config_file = path.join(docs_dir, 'config.js');
17
+
18
+ // Папки, которых на сайте нет никогда, что бы ни стояло в конфиге: зависимости
19
+ // и папка самого сайта (конфиг и тема, как .vitepress у VitePress).
20
+ const always_skipped_names = ['node_modules'];
21
+ const always_skipped_paths = [path.relative(docs_root, docs_dir).split(path.sep).join('/')];
22
+
23
+ // Конфиг читается заново при каждой правке файла: query с mtime обходит кэш
24
+ // ESM-модулей node, иначе правка skipped_names не доехала бы до сборки.
25
+ export async function loadSiteConfig() {
26
+ let stat;
27
+ try {
28
+ stat = fs.statSync(config_file);
29
+ } catch {
30
+ throw new Error(`[scriptorium] нет ${config_file} — конфиг сайта обязателен`);
31
+ }
32
+ const module = await import(`${pathToFileURL(config_file).href}?t=${stat.mtimeMs}`);
33
+ return resolveSiteConfig(module.default);
34
+ }
35
+
36
+ // путь файла относительно корня репозитория → путь страницы: AGENTS.md становится индексом своей папки
37
+ export function pagePath(file) {
38
+ return file.replace(/(^|\/)AGENTS\.md$/, '$1index.md');
39
+ }
40
+
41
+ // путь файла относительно корня репозитория → адрес страницы на сайте: без .md, AGENTS.md папки — адрес папки со слешем
42
+ export function pageUrl(file) {
43
+ return '/' + pagePath(file).replace(/index\.md$/, '').replace(/\.md$/, '');
44
+ }
45
+
46
+ // все .md, из которых строятся страницы, пути относительно корня репозитория с разделителем '/'.
47
+ // Скрытые папки (.git, .claude, .codex) пропускаются; skipped_names — на любой глубине,
48
+ // skipped_paths — пути от корня.
49
+ export function listMarkdown(docs_root, config) {
50
+ const skipped_names = [...always_skipped_names, ...config.skipped_names];
51
+ const skipped_paths = [...always_skipped_paths, ...config.skipped_paths];
52
+
53
+ const walk = (dir, prefix) => {
54
+ const files = [];
55
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
56
+ if (entry.isDirectory()) {
57
+ if (entry.name.startsWith('.') || skipped_names.includes(entry.name) || skipped_paths.includes(prefix + entry.name)) continue;
58
+ files.push(...walk(path.join(dir, entry.name), prefix + entry.name + '/'));
59
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
60
+ files.push(prefix + entry.name);
61
+ }
62
+ }
63
+ return files;
64
+ };
65
+
66
+ return walk(docs_root, '');
67
+ }
68
+
69
+ // Страницы сайта: файл, адрес, подпись в сайдбаре (frontmatter.title, иначе имя
70
+ // файла) и frontmatter. Два файла на один адрес (AGENTS.md и index.md одной
71
+ // папки) — ошибка: какая из страниц настоящая, решает автор, а не порядок обхода.
72
+ export function scanPages(docs_root, config) {
73
+ const pages = [];
74
+ const file_of_url = new Map();
75
+
76
+ for (const file of listMarkdown(docs_root, config)) {
77
+ const url = pageUrl(file);
78
+ if (file_of_url.has(url)) {
79
+ throw new Error(`[scriptorium] два файла на один адрес ${url}: ${file_of_url.get(url)} и ${file} — оставь один`);
80
+ }
81
+ file_of_url.set(url, file);
82
+
83
+ let frontmatter;
84
+ try {
85
+ frontmatter = matter(fs.readFileSync(path.join(docs_root, file), 'utf-8')).data;
86
+ } catch (error) {
87
+ throw new Error(`[scriptorium] битый frontmatter в ${file}: ${error.message}`);
88
+ }
89
+
90
+ pages.push({
91
+ file,
92
+ url,
93
+ title: frontmatter.title !== undefined && frontmatter.title !== null && frontmatter.title !== ''
94
+ ? String(frontmatter.title)
95
+ : path.posix.basename(file, '.md'),
96
+ frontmatter,
97
+ });
98
+ }
99
+
100
+ return pages;
101
+ }
@@ -0,0 +1,7 @@
1
+ // Индекс поиска по документации — JSON-строка MiniSearch. Содержимое модуля
2
+ // целиком генерирует search-index.loader.js при клиентской сборке (правило в
3
+ // bin/scriptorium.js): export default '<JSON индекса>'. Клиент грузит модуль лениво
4
+ // (import() в SearchBox.vue) отдельным чанком.
5
+ // Код ниже — для render-бандла сервера, где правила нет: поиск открывается
6
+ // только в браузере, и индекс в SSR-бандл не попадает.
7
+ export default '';
@@ -0,0 +1,144 @@
1
+ // Rspack-loader модуля search-index.js: индекс локального поиска по всем
2
+ // страницам сайта — порт localSearchPlugin VitePress 1.6.4.
3
+ //
4
+ // Страница рендерится тем же renderMarkdown, что и сама страница, HTML режется
5
+ // на секции по заголовкам h1–h6. Секция — документ индекса:
6
+ // id — адрес страницы + '#якорь' (h1 и текст до первого заголовка — адрес страницы без якоря);
7
+ // title — текст заголовка секции;
8
+ // titles — заголовки-предки (крошки в результатах);
9
+ // text — текст секции без разметки.
10
+ // Модуль отдаёт JSON индекса строкой: export default '<JSON>'.
11
+ //
12
+ // Зависимости для пересборки: контекст корня репозитория (новые и удалённые .md),
13
+ // каждый .md (правка текста) и config.js (состав страниц).
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import MiniSearch from 'minisearch';
17
+
18
+ import { config_file, docs_root, loadSiteConfig, scanPages } from './scan.js';
19
+ import { renderMarkdown } from './markdown.js';
20
+ import { search_options } from './search-options.js';
21
+
22
+ const heading_pattern = /<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi;
23
+ const id_pattern = /\bid="([^"]*)"/i;
24
+ // Значок '#' внутри заголовка (markdown-it-anchor, linkInsideHeader) — не часть текста заголовка.
25
+ const header_anchor_pattern = /<a\b[^>]*\bclass="header-anchor"[^>]*>[\s\S]*?<\/a>/gi;
26
+
27
+ const named_entities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
28
+
29
+ function decodeEntities(text) {
30
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
31
+ if (name[0] !== '#') return named_entities[name.toLowerCase()] ?? entity;
32
+ const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
33
+ return Number.isNaN(code) ? entity : String.fromCodePoint(code);
34
+ });
35
+ }
36
+
37
+ // HTML → текст, как его читает человек: без тегов, с раскрытыми сущностями,
38
+ // пробельные последовательности (переносы строк кода, отступы) — одним пробелом.
39
+ function plainText(html) {
40
+ return decodeEntities(html.replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
41
+ }
42
+
43
+ // HTML страницы → секции { level, anchor, title, text } в порядке документа.
44
+ // Заголовок без якоря (h2–h6 из сырого HTML) секцию не открывает — его текст
45
+ // остаётся в тексте текущей секции, как у VitePress. h1 якоря не имеет
46
+ // (markdown-it-anchor ставит якоря h2–h6) и открывает секцию верха страницы.
47
+ // Текст до первого заголовка — секция уровня 0.
48
+ function splitSections(html) {
49
+ const sections = [];
50
+ let current = { level: 0, anchor: '', title: '' };
51
+ let last_index = 0;
52
+
53
+ for (const match of html.matchAll(heading_pattern)) {
54
+ const level = Number(match[1]);
55
+ const anchor = decodeEntities(match[2].match(id_pattern)?.[1] ?? '');
56
+ if (!anchor && level !== 1) continue;
57
+
58
+ sections.push({ ...current, text: plainText(html.slice(last_index, match.index)) });
59
+ current = { level, anchor, title: plainText(match[3].replace(header_anchor_pattern, '')) };
60
+ last_index = match.index + match[0].length;
61
+ }
62
+
63
+ sections.push({ ...current, text: plainText(html.slice(last_index)) });
64
+ return sections;
65
+ }
66
+
67
+ // Секции страницы → документы индекса. Цепочка предков — как splitPageIntoSections
68
+ // VitePress: заголовок уровня N заменяет N-й элемент цепочки и отрезает глубже.
69
+ // Документы с одним id (второй h1, текст до h1) сливаются: MiniSearch требует
70
+ // уникальный id, а ведут они в одно место — верх страницы.
71
+ function pageDocuments(page, html) {
72
+ const documents = new Map();
73
+ let parent_titles = [];
74
+
75
+ for (const section of splitSections(html)) {
76
+ let title = section.title;
77
+ let titles = [];
78
+
79
+ if (section.level === 0) {
80
+ if (!section.text) continue;
81
+ // Текст до первого заголовка — под подписью страницы из сайдбара.
82
+ title = page.title;
83
+ } else {
84
+ if (!title) continue;
85
+ const depth = section.level - 1;
86
+ titles = parent_titles.slice(0, depth);
87
+ titles[depth] = title;
88
+ titles = titles.filter(Boolean).slice(0, -1);
89
+
90
+ if (depth === 0) parent_titles = [title];
91
+ else parent_titles[depth] = title;
92
+ }
93
+
94
+ const id = section.anchor ? `${page.url}#${section.anchor}` : page.url;
95
+ const existing = documents.get(id);
96
+ if (existing) {
97
+ existing.text = [existing.text, section.text].filter(Boolean).join(' ');
98
+ continue;
99
+ }
100
+ documents.set(id, { id, title, titles, text: section.text });
101
+ }
102
+
103
+ return [...documents.values()];
104
+ }
105
+
106
+ // Индекс всего сайта. Страница с frontmatter search: false в индекс не идёт — как у VitePress.
107
+ // Отдельной функцией, чтобы индекс можно было собрать и проверить из node без rspack.
108
+ export async function buildSearchIndex(docs_root, config) {
109
+ const pages = scanPages(docs_root, config);
110
+ const page_files = new Set(pages.map((page) => page.file));
111
+ const index = new MiniSearch(search_options);
112
+
113
+ const documents = await Promise.all(pages.map(async (page) => {
114
+ const source = fs.readFileSync(path.join(docs_root, page.file), 'utf-8');
115
+ const { frontmatter, html } = await renderMarkdown({ source, file: page.file, docs_root, page_files });
116
+ if (frontmatter.search === false) return [];
117
+ return pageDocuments(page, html);
118
+ }));
119
+
120
+ index.addAll(documents.flat());
121
+ return { index, pages };
122
+ }
123
+
124
+ export default function searchIndexLoader() {
125
+ const callback = this.async();
126
+
127
+ (async () => {
128
+ // Корень и конфиг — до сборки: если она упадёт на битом файле, правка
129
+ // файла всё равно перезапустит loader.
130
+ this.addContextDependency(docs_root);
131
+ this.addDependency(config_file);
132
+
133
+ const config = await loadSiteConfig();
134
+ const { index, pages } = await buildSearchIndex(docs_root, config);
135
+
136
+ for (const page of pages) this.addDependency(path.join(docs_root, page.file));
137
+
138
+ // В dev модуль принимает своё обновление сам: правка .md не перезагружает
139
+ // страницу, новый индекс SearchBox берёт следующим import().
140
+ return `export default ${JSON.stringify(JSON.stringify(index))};
141
+ if (import.meta.webpackHot) import.meta.webpackHot.accept();
142
+ `;
143
+ })().then((code) => callback(null, code), (error) => callback(error));
144
+ }
@@ -0,0 +1,16 @@
1
+ // Опции MiniSearch — те же, что у локального поиска VitePress. Один объект на
2
+ // сборку индекса (search-index.loader.js, node) и его загрузку в браузере
3
+ // (SearchBox.vue): MiniSearch.loadJSON требует те же поля, что были при сборке.
4
+ //
5
+ // Токенизатор — по умолчанию MiniSearch 7: режет по пробелам и пунктуации
6
+ // Юникода (/[\n\r\p{Z}\p{P}]+/u) и приводит к нижнему регистру, кириллицу
7
+ // режет верно — своя функция не нужна.
8
+ export const search_options = {
9
+ fields: ['title', 'titles', 'text'],
10
+ storeFields: ['title', 'titles'],
11
+ searchOptions: {
12
+ fuzzy: 0.2,
13
+ prefix: true,
14
+ boost: { title: 4, text: 2, titles: 1 },
15
+ },
16
+ };