@ozdao/scriptorium 0.0.0-stage → 0.1.1

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 +262 -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
package/README.md CHANGED
@@ -1,3 +1,120 @@
1
- # Temporary Holding Version
1
+ # @ozdao/scriptorium
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Сайт документации из папок markdown на @ozdao/martyrs (Vue 3, rspack, SSR). Все `.md` репозитория — страницы; `AGENTS.md` — индекс своей папки; адреса без `.md`.
4
+
5
+ ## Раскладка у пользователя
6
+
7
+ Как у VitePress: сайт живёт в одной папке `documentation/` в корне репозитория документации, команды запускаются из неё. Страницы — все `.md` репозитория (папка над `documentation/`); сама `documentation/` в сайт не входит.
8
+
9
+ ```
10
+ documentation/
11
+ ├── package.json зависимости и скрипты сайта
12
+ ├── node_modules/ единственный node_modules репозитория документации
13
+ ├── config.js конфиг сайта (обязателен)
14
+ ├── theme/ логотип, блок над страницей, стили (необязательно)
15
+ ├── public/ статика сайта: своё (favicon) + генерируемое сборкой
16
+ └── builds/ сборка
17
+ ```
18
+
19
+ Код сайта целиком в пакете (`node_modules/@ozdao/scriptorium`), туда при работе ничего не пишется. В корне репозитория документации `package.json` и `node_modules` нет.
20
+
21
+ ## Установка
22
+
23
+ `documentation/package.json`:
24
+
25
+ ```json
26
+ {
27
+ "name": "docs",
28
+ "private": true,
29
+ "type": "module",
30
+ "packageManager": "pnpm@10.8.0",
31
+ "scripts": {
32
+ "preinstall": "npx only-allow pnpm",
33
+ "dev": "scriptorium dev",
34
+ "build": "scriptorium build",
35
+ "start": "scriptorium start"
36
+ },
37
+ "devDependencies": {
38
+ "@ozdao/scriptorium": "0.1.0",
39
+ "@ozdao/martyrs": "0.2.609",
40
+ "vue": "3.5.42",
41
+ "vue-i18n": "11.4.10",
42
+ "vue-router": "5.3.0"
43
+ }
44
+ }
45
+ ```
46
+
47
+ `@ozdao/martyrs`, `vue`, `vue-router`, `vue-i18n` — peer-зависимости пакета, и указывать их обязательно. Сборка martyrs берёт `vue` из `documentation/node_modules/vue`, а шаблон service worker — из `documentation/node_modules/@ozdao/martyrs`. pnpm кладёт наверх `node_modules` только прямые зависимости проекта. Без явного указания этих пакетов там нет, и сборка падает.
48
+
49
+ `packageManager: pnpm@10.8.0` нужен, как в шаблоне create-martyrs: pnpm 11 отказывается ставить git-зависимость martyrs (`uWebSockets.js`, `blockExoticSubdeps`) и передаёт установку pnpm 10.
50
+
51
+ ```bash
52
+ cd documentation && pnpm install
53
+ ```
54
+
55
+ ## Запуск
56
+
57
+ ```bash
58
+ cd documentation
59
+ pnpm dev # разработка, http://localhost:8200 (порт — PORT)
60
+ pnpm build # сборка в builds/
61
+ pnpm start # сервер по сборке
62
+ ```
63
+
64
+ Команда запускается только из `documentation/`: там должны лежать `config.js` и `package.json`.
65
+
66
+ ## Конфиг
67
+
68
+ `documentation/config.js` — обязателен:
69
+
70
+ ```js
71
+ export default {
72
+ title: 'Docs', description: '', lang: 'ru-RU',
73
+ appearance: 'dark', // 'dark' | 'light' | 'auto'
74
+ skipped_names: ['node_modules', 'builds'], // папки, пропускаемые на любой глубине
75
+ skipped_paths: [], // пути от корня репозитория; documentation/ пропускается всегда
76
+ nav: [{ text: 'Обзор', link: '/' }],
77
+ sidebar: { priority: ['AGENTS.md'], folders_to_bottom: true, collapse_depth: 2, hidden: ['templates'] },
78
+ outline: { level: [2, 3], label: 'На странице' },
79
+ labels: { menu: 'Меню', theme: 'Тема', top: 'Наверх', prev: 'Назад', next: 'Дальше', not_found: 'Страница не найдена', home: 'На главную' },
80
+ }
81
+ ```
82
+
83
+ `documentation/theme/index.js` — необязателен: `export default { Logo, DocBefore }` (логотип в шапке, блок над содержимым страницы). Стили темы — импортом из этого файла. В компонентах темы:
84
+
85
+ ```js
86
+ import { Badge, useDocs } from '@ozdao/scriptorium'
87
+ const { page, frontmatter, config, pages } = useDocs()
88
+ ```
89
+
90
+ `documentation/public/` отдаётся статикой раньше статики пакета: свой `favicon/` перекрывает умолчание.
91
+
92
+ ## Что генерируется
93
+
94
+ Сборка martyrs пишет в `documentation/`. В `.gitignore` репозитория документации:
95
+
96
+ ```
97
+ documentation/node_modules
98
+ documentation/builds
99
+ documentation/public/sw.js
100
+ documentation/public/icon-sprite.svg
101
+ documentation/public/fonts/
102
+ documentation/.cache
103
+ documentation/logs
104
+ ```
105
+
106
+ - `builds/` — клиентская и серверная сборка;
107
+ - `public/sw.js` — service worker в dev (в prod он лежит в `builds/web/client/`);
108
+ - `public/icon-sprite.svg`, `public/fonts/` — спрайт иконок и шрифты martyrs; у документации своих исходников нет, файлы не появляются;
109
+ - `.cache/martyrs-jit.css` — JIT-утилиты martyrs;
110
+ - `logs/testid-duplicates.txt` — отчёт дублей `data-testid` dev-сборки.
111
+
112
+ ## Разработка пакета
113
+
114
+ ```bash
115
+ pnpm install # в корне пакета: pnpm-workspace.yaml разрешает install-скрипты
116
+ ```
117
+
118
+ Подключение к репозиторию документации без публикации — `"@ozdao/scriptorium": "link:<путь к пакету>"` в `documentation/package.json`. При `link:` pnpm не ставит зависимости пакета в проект, поэтому peer-зависимости выше там тоже обязательны.
119
+
120
+ Устройство: `bin/scriptorium.js` — CLI и сборка конфигов rspack поверх builder'а martyrs (корень приложения = `documentation/`, вход и сервер — файлы пакета); `src/` — приложение сайта (клиент, `server.js`, loader'ы markdown, списка страниц и индекса поиска).
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ // CLI сайта документации: scriptorium <dev|build|start>, запуск из папки
3
+ // documentation/ репозитория документации (как vitepress из папки сайта).
4
+ //
5
+ // documentation/ — корень приложения martyrs (builder/overlay.js берёт
6
+ // process.cwd()): там package.json и node_modules с vue. Код сайта целиком
7
+ // в пакете (src/), в node_modules ничего не пишется: сборка (builds/),
8
+ // генерируемая статика (public/), кэш JIT (.cache/) — в documentation/.
9
+ // Корень репозитория с .md — папка над documentation/.
10
+ //
11
+ // Этот же файл — скрипт dev-воркера бэкенда martyrs (builder/dev.js берёт
12
+ // process.argv[1]). Воркер получает NODE_ENV и PORT в env от главного потока,
13
+ // register — через --import; проверки и env ставит только главный поток.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { isMainThread } from 'node:worker_threads';
18
+
19
+ const environment_of_command = {
20
+ dev: 'development',
21
+ build: 'production',
22
+ start: 'production',
23
+ };
24
+
25
+ const command = process.argv[2];
26
+
27
+ const docs_dir = process.cwd();
28
+ const docs_root = path.resolve(docs_dir, '..');
29
+ const public_dir = path.join(docs_dir, 'public');
30
+ const theme_dir = path.join(docs_dir, 'theme');
31
+ // Реальный путь: rspack сравнивает правила и include JIT с путями модулей
32
+ // после разворота симлинков (pnpm, link:).
33
+ const package_src = fs.realpathSync(fileURLToPath(new URL('../src', import.meta.url)));
34
+
35
+ if (isMainThread) {
36
+ if (!environment_of_command[command]) {
37
+ console.error(`[scriptorium] неизвестная команда "${command ?? ''}". Запуск: scriptorium <${Object.keys(environment_of_command).join('|')}>`);
38
+ process.exit(1);
39
+ }
40
+
41
+ if (!fs.existsSync(path.join(docs_dir, 'config.js'))) {
42
+ console.error(`[scriptorium] в ${docs_dir} нет config.js — запускай из папки documentation: cd documentation && pnpm ${command}`);
43
+ process.exit(1);
44
+ }
45
+
46
+ process.env.NODE_ENV = environment_of_command[command];
47
+ process.env.PORT = process.env.PORT || '8200';
48
+ }
49
+
50
+ // Бутстрап martyrs (env + резолв-хуки) — ПОСЛЕ NODE_ENV: register читает его
51
+ // при загрузке. Хуки действуют на модули, загруженные после регистрации,
52
+ // поэтому всё из martyrs — динамическими импортами.
53
+ await import('@ozdao/martyrs/register');
54
+
55
+ const { run, ssrClientConfig, ssrServerConfig } = await import('@ozdao/martyrs/builder');
56
+
57
+ const configs = {
58
+ // Файл сервера — в пакете; раннер martyrs (builder/start.js) грузит его по
59
+ // абсолютному пути, корнем приложения остаётся documentation/.
60
+ server: path.join(package_src, 'server.js'),
61
+ ssr: {
62
+ client: ssrClientConfig(docs_dir, {
63
+ entry: path.join(package_src, 'client.js'),
64
+ output: path.join(docs_dir, 'builds/web/client'),
65
+ }),
66
+ server: ssrServerConfig(docs_dir, {
67
+ entry: path.join(package_src, 'client.js'),
68
+ output: path.join(docs_dir, 'builds/web/server'),
69
+ }),
70
+ },
71
+ };
72
+
73
+ const theme_file = path.join(theme_dir, 'index.js');
74
+
75
+ for (const config of Object.values(configs.ssr)) {
76
+ // Loader'ы страниц и поиска следят за всем корнем репозитория (новые .md), а
77
+ // documentation/ лежит внутри него. Сборка сама пишет в builds/ (stats.json
78
+ // на каждую пересборку клиента), public/ и logs/ (отчёт testid) — без
79
+ // исключения клиент пересобирался бы по кругу. .cache/ не исключается:
80
+ // JIT-утилиты доезжают до сборки именно правкой файла .cache/martyrs-jit.css.
81
+ // node_modules и .git — умолчание rspack, при своём ignored его надо повторить.
82
+ config.watchOptions = {
83
+ ...config.watchOptions,
84
+ ignored: [
85
+ '**/node_modules/**',
86
+ '**/.git/**',
87
+ path.join(docs_dir, 'builds') + '/**',
88
+ path.join(docs_dir, 'public') + '/**',
89
+ path.join(docs_dir, 'logs') + '/**',
90
+ ],
91
+ };
92
+
93
+ // '@' базового конфига martyrs — <корень приложения>/src, у документации
94
+ // такой папки нет: алиас ведёт в исходники пакета.
95
+ config.resolve.alias['@'] = package_src;
96
+
97
+ // Тема потребителя необязательна: без файла импорт отдаёт пустой модуль, и
98
+ // сайт работает с текстовым заголовком вместо логотипа. Ключ — ДО
99
+ // '@docs-theme': алиасы проверяются в порядке объявления, и общий ключ
100
+ // перехватил бы запрос раньше («Cannot find module … for matched aliased key»).
101
+ if (!fs.existsSync(theme_file)) {
102
+ config.resolve.alias['@docs-theme/theme/index.js$'] = false;
103
+ }
104
+
105
+ // @docs — корень репозитория документации: страницы грузятся как '@docs/<файл>.md'.
106
+ // @docs-theme — конфиг и тема потребителя (documentation/).
107
+ config.resolve.alias['@docs'] = docs_root;
108
+ config.resolve.alias['@docs-theme'] = docs_dir;
109
+
110
+ config.module.rules.push(
111
+ // .md репозитория → Vue-компонент страницы + frontmatter и заголовки.
112
+ {
113
+ test: /\.md$/,
114
+ type: 'javascript/auto',
115
+ use: [{ loader: path.join(package_src, 'content/markdown.loader.js') }],
116
+ },
117
+ // Список страниц генерирует loader при сборке: модуль-заглушка pages.js
118
+ // получает код из скана папок документации.
119
+ {
120
+ test: path.join(package_src, 'content/pages.js'),
121
+ type: 'javascript/auto',
122
+ use: [{ loader: path.join(package_src, 'content/pages.loader.js') }],
123
+ },
124
+ );
125
+
126
+ // Индекс поиска — только в клиентской сборке: модуль-заглушка search-index.js
127
+ // получает JSON индекса по всем страницам. Render-бандл сервера берёт заглушку
128
+ // как есть — поиск открывается только в браузере.
129
+ if (config === configs.ssr.client) {
130
+ config.module.rules.push({
131
+ test: path.join(package_src, 'content/search-index.js'),
132
+ type: 'javascript/auto',
133
+ use: [{ loader: path.join(package_src, 'content/search-index.loader.js') }],
134
+ });
135
+ }
136
+
137
+ // Плагины martyrs узнаём по имени класса: экземпляры созданы builder'ом
138
+ // martyrs, а импорт классов отсюда мог бы дать другую копию модуля.
139
+ config.plugins = config.plugins.filter((plugin) => {
140
+ const name = plugin?.constructor?.name;
141
+
142
+ // Спрайт иконок и шрифты пишутся в documentation/public, а не в
143
+ // <корень приложения>/../public (корень репозитория). Исходников у
144
+ // документации нет (src/design-system/icons|fonts) — плагины ничего не
145
+ // пишут, путь нужен на случай, если появятся.
146
+ if (name === 'IconSpritePlugin' || name === 'FontsPlugin') plugin.options.publicDir = public_dir;
147
+
148
+ // JIT-утилиты martyrs собираются из файлов, путь которых содержит одну из
149
+ // строк include, и не содержит строк exclude (сравнение подстрокой).
150
+ // Умолчания рассчитаны на приложение с src/: 'src' и 'martyrs' в include,
151
+ // 'node_modules' в exclude. У документации код — в пакете, а пакет при
152
+ // установке из npm лежит в node_modules: исключение 'node_modules' убрало
153
+ // бы все его классы. Поэтому include — точные папки: исходники пакета,
154
+ // тема потребителя и сам martyrs; 'node_modules' из exclude убран. Весь
155
+ // documentation/ в include нельзя: туда входит documentation/node_modules.
156
+ if (name === 'MartyrsJitPlugin') {
157
+ plugin.options.include = ['@ozdao/martyrs', package_src, theme_dir];
158
+ plugin.options.exclude = plugin.options.exclude.filter((part) => part !== 'node_modules');
159
+ }
160
+
161
+ // Service worker сайту документации не нужен: его регистрирует только модуль
162
+ // notifications martyrs, которого здесь нет. Плагин в dev писал бы sw.js в
163
+ // <корень приложения>/../public — корень репозитория документации.
164
+ const is_service_worker = plugin?.constructor === Object && String(plugin.apply).includes('service-worker');
165
+ return !is_service_worker;
166
+ });
167
+ }
168
+
169
+ // Адреса ассетов (картинки .md, логотип темы) в SSR-HTML — от корня, как у
170
+ // клиентской сборки (publicPath '/'). Без этого render-бандл печатал имя файла
171
+ // относительным адресом: на /code/core/ картинка искалась в /code/core/, а
172
+ // клиент при гидратации ставил другой адрес.
173
+ configs.ssr.server.output.publicPath = '/';
174
+
175
+ await run(command, configs);
176
+
177
+ // Воркер бэкенда в dev (тот же файл, не главный поток) и сборка адрес не печатают.
178
+ if (isMainThread && command !== 'build') {
179
+ console.log(`Docs: http://localhost:${process.env.PORT}`);
180
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@ozdao/scriptorium",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Documentation site generator on @ozdao/martyrs: markdown folders to an SSR site",
5
+ "author": "OZ DAO <hello@ozdao.com>",
6
+ "license": "GPL-3.0-or-later",
7
+ "type": "module",
8
+ "bin": {
9
+ "scriptorium": "./bin/scriptorium.js"
10
+ },
11
+ "exports": {
12
+ ".": "./src/main.js"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/",
17
+ "public/",
18
+ "README.md"
19
+ ],
20
+ "dependencies": {
21
+ "@ozdao/martyrs": "0.2.609",
22
+ "@rspack/core": "2.2.2",
23
+ "@unhead/vue": "3.4.0",
24
+ "cookie-parser": "1.4.6",
25
+ "express": "5.2.1",
26
+ "gray-matter": "4.0.3",
27
+ "markdown-it": "15.0.2",
28
+ "markdown-it-anchor": "10.0.0",
29
+ "markdown-it-container": "4.0.0",
30
+ "minisearch": "7.2.0",
31
+ "shiki": "4.4.3",
32
+ "vue": "3.5.42",
33
+ "vue-i18n": "11.4.10",
34
+ "vue-router": "5.3.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@ozdao/martyrs": "0.2.609",
38
+ "vue": "3.5.42",
39
+ "vue-i18n": "11.4.10",
40
+ "vue-router": "5.3.0"
41
+ }
42
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#121212"/><path d="M9 8h11l3 3v13H9z" fill="none" stroke="#f5f5f5" stroke-width="2" stroke-linejoin="round"/><path d="M12 14h8M12 18h8M12 22h5" stroke="#f5f5f5" stroke-width="2" stroke-linecap="round"/></svg>
package/src/client.js ADDED
@@ -0,0 +1,29 @@
1
+ // Дизайн-система сайта: база martyrs (слои @layer) + JIT-утилиты + тема сайта
2
+ // + тема потребителя. Всё внутри design-system/index.js.
3
+ import './design-system/index.js';
4
+
5
+ import { createUniversalApp } from '@ozdao/martyrs/modules/core/client';
6
+
7
+ import { getConfig } from './configs/client.config.js';
8
+ import { getRouter } from './router/index.js';
9
+ import { getLocales } from './localization/index.js';
10
+ import { interceptLinks } from './router/links.js';
11
+
12
+ const appPromise = createUniversalApp({
13
+ getConfig,
14
+ getRouter,
15
+ getLocales,
16
+ getHooks: {
17
+ // Ссылки внутри HTML страниц (markdown) и логотип потребителя — обычные <a>:
18
+ // переходы по ним внутри сайта идут роутером, без перезагрузки страницы.
19
+ afterInitialize: async (context) => {
20
+ if (typeof window !== 'undefined') interceptLinks(context.router);
21
+ },
22
+ },
23
+ });
24
+
25
+ export async function _renderApp({ url, cookies, headers, languages, ssrContext }) {
26
+ const { renderApp } = await appPromise;
27
+
28
+ return renderApp({ url, cookies, headers, languages, ssrContext });
29
+ }
@@ -0,0 +1,29 @@
1
+ <script setup>
2
+ // Бейдж как у VitePress (type: info | tip | warning | danger, text или слот) —
3
+ // на бейдже дизайн-системы martyrs (.badge, elements.scss).
4
+ import { computed } from 'vue';
5
+
6
+ const props = defineProps({
7
+ type: {
8
+ type: String,
9
+ default: 'tip',
10
+ },
11
+ text: {
12
+ type: String,
13
+ default: '',
14
+ },
15
+ });
16
+
17
+ const modifier_of_type = {
18
+ info: 'info',
19
+ tip: 'success',
20
+ warning: 'warning',
21
+ danger: 'danger',
22
+ };
23
+
24
+ const modifier = computed(() => modifier_of_type[props.type] ?? 'default');
25
+ </script>
26
+
27
+ <template>
28
+ <span class="badge" :class="`badge--${modifier}`"><slot>{{ text }}</slot></span>
29
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup>
2
+ // Ссылки nav из конфига — справа в шапке martyrs (header_right_component).
3
+ // Активна ссылка текущего адреса, как у VitePress (точное совпадение). На
4
+ // мобильном ссылки уходят в меню (SidebarTree).
5
+ // Перед ссылками — кнопка поиска; окно поиска (SearchBox) живёт здесь же, как
6
+ // VPNavBarSearch у VitePress: оно слушает горячие клавиши на любой странице.
7
+ import { computed } from 'vue';
8
+ import { useRoute } from 'vue-router';
9
+
10
+ import site_config from '../configs/site.config.js';
11
+
12
+ import SearchButton from './SearchButton.vue';
13
+ import SearchBox from './SearchBox.vue';
14
+
15
+ // Шапка martyrs передаёт theme — ссылкам он не нужен.
16
+ defineOptions({ inheritAttrs: false });
17
+
18
+ const route = useRoute();
19
+
20
+ const current_url = computed(() => {
21
+ try {
22
+ return decodeURI(route.path);
23
+ } catch {
24
+ return route.path;
25
+ }
26
+ });
27
+ </script>
28
+
29
+ <template>
30
+ <div class="flex flex-nowrap flex-v-center gap-medium">
31
+ <SearchButton />
32
+ <SearchBox />
33
+
34
+ <nav v-if="site_config.nav.length" class="docs-header-nav desktop-only">
35
+ <ul class="flex flex-nowrap flex-v-center gap-medium">
36
+ <li v-for="item in site_config.nav" :key="item.link">
37
+ <router-link
38
+ :to="item.link"
39
+ class="docs-header-nav__link"
40
+ :class="{ 'docs-header-nav__link--active': item.link === current_url }"
41
+ >{{ item.text }}</router-link>
42
+ </li>
43
+ </ul>
44
+ </nav>
45
+ </div>
46
+ </template>
@@ -0,0 +1,34 @@
1
+ <script setup>
2
+ // Раскладка страницы внутри layoutClient martyrs (шапку и сайдбар рисует он по
3
+ // meta роута): колонка содержимого до 688px, оглавление справа на широком
4
+ // экране, внизу «Назад / Дальше». Над содержимым — DocBefore темы потребителя.
5
+ import theme from '@docs-theme/theme/index.js';
6
+
7
+ import site_config from '../configs/site.config.js';
8
+
9
+ import Outline from './Outline.vue';
10
+ import PrevNext from './PrevNext.vue';
11
+
12
+ const DocBefore = theme?.DocBefore ?? null;
13
+
14
+ function scrollToTop() {
15
+ document.getElementById('scrollview')?.scrollTo({ top: 0, behavior: 'smooth' });
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <div class="docs-layout">
21
+ <article class="docs-layout__content">
22
+ <component v-if="DocBefore" :is="DocBefore" />
23
+ <slot />
24
+ <PrevNext />
25
+ <button type="button" class="docs-layout__top mobile-only mn-t-medium" @click="scrollToTop">
26
+ {{ site_config.labels.top }}
27
+ </button>
28
+ </article>
29
+
30
+ <aside class="docs-layout__outline">
31
+ <Outline />
32
+ </aside>
33
+ </div>
34
+ </template>
@@ -0,0 +1,74 @@
1
+ <script setup>
2
+ // Оглавление страницы: заголовки из loader'а (outline.level конфига, по
3
+ // умолчанию h2–h3). Активный пункт — последний заголовок, верх которого уже
4
+ // прошёл верхнюю кромку скроллера раскладки (#scrollview).
5
+ import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
6
+
7
+ import site_config from '../configs/site.config.js';
8
+ import { useDocs } from '../content/use-docs.js';
9
+ import { scrollToAnchor } from '../router/links.js';
10
+
11
+ const { page } = useDocs();
12
+
13
+ const active_slug = ref(null);
14
+ let scrollview = null;
15
+
16
+ // Заголовок считается прочитанным, когда он ближе этого к верху скроллера.
17
+ const reading_offset = 96;
18
+
19
+ function updateActive() {
20
+ if (!scrollview || !page.value) return;
21
+ const top = scrollview.getBoundingClientRect().top;
22
+ let current = null;
23
+
24
+ for (const header of page.value.headers) {
25
+ const element = document.getElementById(header.slug);
26
+ if (!element) continue;
27
+ if (element.getBoundingClientRect().top - top <= reading_offset) current = header.slug;
28
+ else break;
29
+ }
30
+
31
+ // Дочитали до конца — активен последний заголовок, даже если он не дошёл до верха.
32
+ if (scrollview.scrollTop + scrollview.clientHeight >= scrollview.scrollHeight - 2 && page.value.headers.length) {
33
+ current = page.value.headers[page.value.headers.length - 1].slug;
34
+ }
35
+
36
+ active_slug.value = current;
37
+ }
38
+
39
+ function openHeader(slug) {
40
+ if (scrollToAnchor(slug)) window.history.replaceState(window.history.state, '', `#${encodeURIComponent(slug)}`);
41
+ }
42
+
43
+ onMounted(() => {
44
+ scrollview = document.getElementById('scrollview');
45
+ scrollview?.addEventListener('scroll', updateActive, { passive: true });
46
+ updateActive();
47
+ });
48
+
49
+ onBeforeUnmount(() => {
50
+ scrollview?.removeEventListener('scroll', updateActive);
51
+ });
52
+
53
+ watch(() => page.value?.url, () => nextTick(updateActive));
54
+ </script>
55
+
56
+ <template>
57
+ <nav v-if="page && page.headers.length" class="docs-outline flex flex-column gap-thin" :aria-label="site_config.outline.label">
58
+ <p class="docs-outline__label">{{ site_config.outline.label }}</p>
59
+ <ul class="flex flex-column gap-thin">
60
+ <li
61
+ v-for="header in page.headers"
62
+ :key="header.slug"
63
+ :class="`docs-outline__level-${header.level - site_config.outline.level[0]}`"
64
+ >
65
+ <a
66
+ :href="`#${header.slug}`"
67
+ class="docs-outline__link"
68
+ :class="{ 'docs-outline__link--active': header.slug === active_slug }"
69
+ @click.prevent="openHeader(header.slug)"
70
+ >{{ header.title }}</a>
71
+ </li>
72
+ </ul>
73
+ </nav>
74
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup>
2
+ // Страница документации по адресу. Модуль страницы уже загружен гардом роутера
3
+ // (router/index.js) — и на сервере, и на клиенте до гидратации, — здесь только
4
+ // рендер. Адреса без страницы — 404 со ссылкой на главную.
5
+ import { computed } from 'vue';
6
+ import { useHead } from '@unhead/vue';
7
+
8
+ import site_config from '../configs/site.config.js';
9
+ import { useDocs } from '../content/use-docs.js';
10
+ import { pageComponent } from '../content/current-page.js';
11
+
12
+ import Layout from './Layout.vue';
13
+
14
+ const { page } = useDocs();
15
+
16
+ const content = computed(() => (page.value ? pageComponent(page.value.url) : null));
17
+
18
+ useHead({
19
+ title: computed(() => `${page.value ? page.value.title : '404'} — ${site_config.title}`),
20
+ });
21
+ </script>
22
+
23
+ <template>
24
+ <Layout v-if="content" :key="page.url">
25
+ <component :is="content" />
26
+ </Layout>
27
+
28
+ <section v-else class="docs-not-found flex flex-column gap-medium pd-semi">
29
+ <h1>404</h1>
30
+ <p class="t-secondary">{{ site_config.labels.not_found }}</p>
31
+ <router-link to="/" class="docs-not-found__home">{{ site_config.labels.home }}</router-link>
32
+ </section>
33
+ </template>
@@ -0,0 +1,37 @@
1
+ <script setup>
2
+ // «Назад / Дальше» в порядке сайдбара — как у VitePress: плоский список ссылок
3
+ // сайдбара (ссылка папки перед её содержимым). Страница вне сайдбара (главная,
4
+ // скрытые папки) получает только «Дальше» — первый пункт сайдбара.
5
+ import { computed } from 'vue';
6
+
7
+ import site_config from '../configs/site.config.js';
8
+ import { useDocs } from '../content/use-docs.js';
9
+ import { buildSidebar, flattenSidebar } from '../content/sidebar.js';
10
+
11
+ const { page, pages } = useDocs();
12
+
13
+ const links = flattenSidebar(buildSidebar(pages, site_config));
14
+
15
+ const neighbours = computed(() => {
16
+ const index = links.findIndex((link) => link.url === page.value?.url);
17
+ return {
18
+ prev: index > 0 ? links[index - 1] : null,
19
+ next: index === -1 ? links[0] ?? null : links[index + 1] ?? null,
20
+ };
21
+ });
22
+ </script>
23
+
24
+ <template>
25
+ <nav v-if="neighbours.prev || neighbours.next" class="docs-prev-next mn-t-semi pd-t-medium gap-small">
26
+ <router-link v-if="neighbours.prev" :to="neighbours.prev.url" class="docs-prev-next__link flex flex-column gap-thin pd-small radius-small">
27
+ <span class="docs-prev-next__label">{{ site_config.labels.prev }}</span>
28
+ <span class="docs-prev-next__title">{{ neighbours.prev.text }}</span>
29
+ </router-link>
30
+ <span v-else />
31
+
32
+ <router-link v-if="neighbours.next" :to="neighbours.next.url" class="docs-prev-next__link docs-prev-next__link--next flex flex-column gap-thin pd-small radius-small">
33
+ <span class="docs-prev-next__label">{{ site_config.labels.next }}</span>
34
+ <span class="docs-prev-next__title">{{ neighbours.next.text }}</span>
35
+ </router-link>
36
+ </nav>
37
+ </template>