@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
@@ -0,0 +1,381 @@
1
+ <script setup>
2
+ // Окно локального поиска — порт VPLocalSearchBox VitePress 1.6.4 (MIT) на
3
+ // martyrs: модалка — Popup дизайн-системы (телепорт только на клиенте, подложка
4
+ // закрывает окно по клику вне, блокировка прокрутки страницы), поле, список
5
+ // результатов с подсветкой совпадений и крошками заголовков, клавиши ↑ ↓ Enter
6
+ // Esc, фокус-ловушка. Открыто ли окно — store.core.state.search_open: его
7
+ // ставят SearchButton и горячие клавиши (⌘K / Ctrl+K, /).
8
+ //
9
+ // Индекс (content/search-index.js) и MiniSearch грузятся лениво при первом
10
+ // открытии отдельными чанками: в основной бандл и в SSR они не попадают. В dev
11
+ // правка .md пересобирает индекс, новый модуль приходит по HMR, и при
12
+ // следующем открытии окно берёт его.
13
+ //
14
+ // Отличия от VitePress: нет подробного вида (выдержки текста под заголовком —
15
+ // VitePress монтирует для них компоненты страниц в браузере), нет сохранения
16
+ // запроса в sessionStorage (запрос живёт, пока открыта вкладка) и нет возврата
17
+ // «Назад» через pushState.
18
+ import { ref, shallowRef, computed, watch, nextTick, onMounted, onBeforeUnmount, markRaw } from 'vue';
19
+ import { useRoute, useRouter } from 'vue-router';
20
+ import { useStore } from '@ozdao/martyrs/modules/core/client';
21
+ import { Popup } from '@ozdao/martyrs';
22
+ import { IconSearch, IconCross } from '@ozdao/martyrs/modules/icons/client';
23
+
24
+ import site_config from '../configs/site.config.js';
25
+ import { search_options } from '../content/search-options.js';
26
+ import { scrollToAnchor } from '../router/links.js';
27
+
28
+ // Сколько результатов показывать — как у VitePress.
29
+ const result_limit = 16;
30
+
31
+ const store = useStore();
32
+ const route = useRoute();
33
+ const router = useRouter();
34
+
35
+ const shell = ref(null);
36
+ const search_input = ref(null);
37
+ const results_list = ref(null);
38
+
39
+ const query = ref('');
40
+ const search_index = shallowRef(null);
41
+ const results = shallowRef([]);
42
+ const selected_index = ref(-1);
43
+ // Поиск по текущему запросу уже прошёл: «Ничего не найдено» не мигает, пока грузится индекс.
44
+ const searched = ref(false);
45
+
46
+ // JSON индекса, из которого собран search_index: новый модуль после HMR — новая строка.
47
+ let index_json = null;
48
+ // Элемент, с которого открыли окно: фокус возвращается на него при закрытии.
49
+ let opener = null;
50
+
51
+ async function loadIndex() {
52
+ const [{ default: MiniSearch }, { default: json }] = await Promise.all([
53
+ import('minisearch'),
54
+ import('../content/search-index.js'),
55
+ ]);
56
+ if (json === index_json) return;
57
+ index_json = json;
58
+ search_index.value = markRaw(MiniSearch.loadJSON(json, search_options));
59
+ }
60
+
61
+ function runSearch() {
62
+ if (!search_index.value) return;
63
+ results.value = search_index.value.search(query.value).slice(0, result_limit);
64
+ searched.value = true;
65
+ }
66
+
67
+ watch(query, () => {
68
+ searched.value = false;
69
+ runSearch();
70
+ });
71
+
72
+ watch(search_index, runSearch);
73
+
74
+ watch(results, (list) => {
75
+ selected_index.value = list.length ? 0 : -1;
76
+ scrollToSelected();
77
+ });
78
+
79
+ // Текст → куски с отметкой совпадения. Совпавшие термины — ключи result.match
80
+ // (термины индекса, уже в нижнем регистре); длинные — раньше, как у VitePress.
81
+ function escapeRegExp(text) {
82
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
83
+ }
84
+
85
+ function highlightParts(text, pattern) {
86
+ const parts = [];
87
+ let last_index = 0;
88
+
89
+ for (const match of text.matchAll(pattern)) {
90
+ if (!match[0]) continue;
91
+ if (match.index > last_index) parts.push({ text: text.slice(last_index, match.index), mark: false });
92
+ parts.push({ text: match[0], mark: true });
93
+ last_index = match.index + match[0].length;
94
+ }
95
+
96
+ if (last_index < text.length) parts.push({ text: text.slice(last_index), mark: false });
97
+ return parts;
98
+ }
99
+
100
+ const result_items = computed(() => results.value.map((result) => {
101
+ const terms = Object.keys(result.match).sort((a, b) => b.length - a.length);
102
+ const pattern = terms.length ? new RegExp(terms.map(escapeRegExp).join('|'), 'giu') : null;
103
+ const partsOf = (text) => (pattern ? highlightParts(text, pattern) : [{ text, mark: false }]);
104
+
105
+ return {
106
+ id: result.id,
107
+ label: [...result.titles, result.title].join(' > '),
108
+ title_parts: partsOf(result.title),
109
+ titles_parts: result.titles.map(partsOf),
110
+ };
111
+ }));
112
+
113
+ function scrollToSelected() {
114
+ nextTick(() => {
115
+ results_list.value?.querySelector('.docs-search__result--selected')?.scrollIntoView({ block: 'nearest' });
116
+ });
117
+ }
118
+
119
+ function moveSelection(step) {
120
+ const count = results.value.length;
121
+ if (!count) return;
122
+ selected_index.value = (selected_index.value + step + count) % count;
123
+ scrollToSelected();
124
+ }
125
+
126
+ function focusInput(select = true) {
127
+ search_input.value?.focus();
128
+ if (select) search_input.value?.select();
129
+ }
130
+
131
+ function open() {
132
+ store.core.state.search_open = true;
133
+ }
134
+
135
+ function close() {
136
+ store.core.state.search_open = false;
137
+ }
138
+
139
+ function resetSearch() {
140
+ query.value = '';
141
+ nextTick(() => focusInput(false));
142
+ }
143
+
144
+ // Переход по результату роутером; якорь прокручивает скроллер раскладки martyrs
145
+ // (#scrollview), а не окно — как ссылки на заголовки в router/links.js.
146
+ async function openResult(item) {
147
+ close();
148
+ const hash_index = item.id.indexOf('#');
149
+ const anchor = hash_index === -1 ? '' : item.id.slice(hash_index + 1);
150
+
151
+ await router.push(item.id);
152
+ await nextTick();
153
+
154
+ if (anchor) scrollToAnchor(anchor);
155
+ else document.getElementById('scrollview')?.scrollTo({ top: 0, behavior: 'smooth' });
156
+ }
157
+
158
+ // Клик с модификатором или средней кнопкой — новая вкладка браузера, как у обычной ссылки.
159
+ function onResultClick(event, item) {
160
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
161
+ event.preventDefault();
162
+ openResult(item);
163
+ }
164
+
165
+ // Выбор мышью — по движению, а не по mouseenter: прокрутка списка клавишами
166
+ // подводит результаты под неподвижный курсор и не должна сбивать выбор.
167
+ function onResultsMousemove(event) {
168
+ const index = Number(event.target.closest?.('[data-index]')?.dataset.index);
169
+ if (Number.isInteger(index) && index !== selected_index.value) selected_index.value = index;
170
+ }
171
+
172
+ function isEditingContent(event) {
173
+ const element = event.target;
174
+ return Boolean(element?.isContentEditable) || ['INPUT', 'SELECT', 'TEXTAREA'].includes(element?.tagName);
175
+ }
176
+
177
+ // Фокус-ловушка: Tab и Shift+Tab ходят по кругу внутри окна (поле, сброс,
178
+ // результаты). Кнопка закрытия Popup выключена (options.close_button):
179
+ // закрытие — Esc, клик вне окна, сброс поля.
180
+ function trapFocus(event) {
181
+ const panel = shell.value?.closest('.popup-content') ?? shell.value;
182
+ if (!panel) return;
183
+
184
+ const focusable = [...panel.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')]
185
+ .filter((element) => element.getClientRects().length > 0);
186
+ if (!focusable.length) return;
187
+
188
+ const first = focusable[0];
189
+ const last = focusable[focusable.length - 1];
190
+ const active = document.activeElement;
191
+
192
+ if (!panel.contains(active)) {
193
+ event.preventDefault();
194
+ first.focus();
195
+ } else if (event.shiftKey && active === first) {
196
+ event.preventDefault();
197
+ last.focus();
198
+ } else if (!event.shiftKey && active === last) {
199
+ event.preventDefault();
200
+ first.focus();
201
+ }
202
+ }
203
+
204
+ function onKeydown(event) {
205
+ const key = event.key;
206
+ if (typeof key !== 'string') return;
207
+
208
+ const is_shortcut = key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey);
209
+
210
+ if (!store.core.state.search_open) {
211
+ if (is_shortcut || (key === '/' && !isEditingContent(event))) {
212
+ event.preventDefault();
213
+ open();
214
+ }
215
+ return;
216
+ }
217
+
218
+ if (is_shortcut) {
219
+ event.preventDefault();
220
+ focusInput();
221
+ return;
222
+ }
223
+
224
+ if (key === 'ArrowUp' || key === 'ArrowDown') {
225
+ event.preventDefault();
226
+ moveSelection(key === 'ArrowUp' ? -1 : 1);
227
+ } else if (key === 'Enter') {
228
+ if (event.isComposing) return;
229
+ // Enter на кнопке нажимает её, на результате — открывает ссылку (onResultClick).
230
+ if (event.target instanceof HTMLButtonElement || event.target instanceof HTMLAnchorElement) return;
231
+ event.preventDefault();
232
+ const item = result_items.value[selected_index.value];
233
+ if (item) openResult(item);
234
+ } else if (key === 'Escape') {
235
+ event.preventDefault();
236
+ close();
237
+ } else if (key === 'Tab') {
238
+ trapFocus(event);
239
+ }
240
+ }
241
+
242
+ watch(() => store.core.state.search_open, (is_open) => {
243
+ if (is_open) {
244
+ opener = document.activeElement;
245
+ loadIndex().catch((error) => console.error('[scriptorium] индекс поиска не загрузился', error));
246
+ return;
247
+ }
248
+ opener?.focus?.();
249
+ opener = null;
250
+ });
251
+
252
+ // Поле появляется вместе с окном (Popup монтирует содержимое на открытии).
253
+ watch(search_input, (input) => {
254
+ if (input) nextTick(() => focusInput());
255
+ });
256
+
257
+ // Переход по истории (Назад/Вперёд) при открытом окне закрывает его.
258
+ watch(() => route.fullPath, close);
259
+
260
+ onMounted(() => window.addEventListener('keydown', onKeydown));
261
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
262
+ </script>
263
+
264
+ <template>
265
+ <Popup
266
+ :isPopupOpen="Boolean(store.core.state.search_open)"
267
+ :options="{ close_button: false }"
268
+ align="top center"
269
+ class="docs-search bg-base radius-medium pd-small"
270
+ role="dialog"
271
+ aria-modal="true"
272
+ :aria-label="site_config.labels.search.button"
273
+ @close-popup="close"
274
+ >
275
+ <div ref="shell" class="docs-search__shell flex flex-column gap-regular">
276
+ <form class="docs-search__bar field-wrapper flex flex-nowrap flex-v-center gap-thin pd-l-small radius-small" role="search" @submit.prevent>
277
+ <label id="docs-search-label" for="docs-search-input" class="flex flex-v-center" :title="site_config.labels.search.button">
278
+ <IconSearch class="docs-search__icon i-regular" aria-hidden="true" />
279
+ </label>
280
+
281
+ <input
282
+ id="docs-search-input"
283
+ ref="search_input"
284
+ v-model="query"
285
+ type="search"
286
+ class="docs-search__input w-100 pd-t-small pd-b-small"
287
+ :placeholder="site_config.labels.search.placeholder"
288
+ aria-labelledby="docs-search-label"
289
+ aria-autocomplete="list"
290
+ :aria-controls="results.length ? 'docs-search-list' : undefined"
291
+ :aria-activedescendant="selected_index > -1 ? `docs-search-item-${selected_index}` : undefined"
292
+ autocapitalize="off"
293
+ autocomplete="off"
294
+ autocorrect="off"
295
+ spellcheck="false"
296
+ enterkeyhint="go"
297
+ maxlength="64"
298
+ >
299
+
300
+ <!-- Сброс есть только при введённом запросе, как у VitePress: пустому полю
301
+ выключенная кнопка ни к чему. -->
302
+ <button
303
+ v-if="query"
304
+ type="button"
305
+ class="docs-search__reset flex flex-center pd-small radius-small"
306
+ :title="site_config.labels.search.reset"
307
+ :aria-label="site_config.labels.search.reset"
308
+ @click="resetSearch"
309
+ >
310
+ <IconCross class="docs-search__icon i-small" aria-hidden="true" />
311
+ </button>
312
+ </form>
313
+
314
+ <ul
315
+ v-if="results.length"
316
+ id="docs-search-list"
317
+ ref="results_list"
318
+ role="listbox"
319
+ aria-labelledby="docs-search-label"
320
+ class="docs-search__results flex flex-column gap-thin"
321
+ @mousemove="onResultsMousemove"
322
+ >
323
+ <li
324
+ v-for="(item, index) in result_items"
325
+ :id="`docs-search-item-${index}`"
326
+ :key="item.id"
327
+ role="option"
328
+ :aria-selected="index === selected_index ? 'true' : 'false'"
329
+ >
330
+ <a
331
+ :href="item.id"
332
+ :data-index="index"
333
+ class="docs-search__result flex flex-nowrap flex-v-center gap-small pd-small radius-small"
334
+ :class="{ 'docs-search__result--selected': index === selected_index }"
335
+ :aria-label="item.label"
336
+ @focusin="selected_index = index"
337
+ @click="onResultClick($event, item)"
338
+ >
339
+ <span class="docs-search__hash" aria-hidden="true">#</span>
340
+ <span class="flex flex-wrap flex-v-center gap-thin">
341
+ <template v-for="(parts, title_index) in item.titles_parts" :key="title_index">
342
+ <span class="docs-search__title">
343
+ <template v-for="(part, part_index) in parts" :key="part_index">
344
+ <mark v-if="part.mark" class="docs-search__mark">{{ part.text }}</mark>
345
+ <template v-else>{{ part.text }}</template>
346
+ </template>
347
+ </span>
348
+ <span class="docs-search__separator" aria-hidden="true">›</span>
349
+ </template>
350
+ <span class="docs-search__title docs-search__title--main">
351
+ <template v-for="(part, part_index) in item.title_parts" :key="part_index">
352
+ <mark v-if="part.mark" class="docs-search__mark">{{ part.text }}</mark>
353
+ <template v-else>{{ part.text }}</template>
354
+ </template>
355
+ </span>
356
+ </span>
357
+ </a>
358
+ </li>
359
+ </ul>
360
+
361
+ <p v-else-if="query && searched" class="docs-search__empty t-center pd-small">
362
+ {{ site_config.labels.search.no_results }} «<strong>{{ query }}</strong>»
363
+ </p>
364
+
365
+ <div class="docs-search__keys desktop-only flex flex-wrap gap-regular">
366
+ <span class="flex flex-nowrap flex-v-center gap-thin">
367
+ <kbd class="docs-search__key">↑</kbd><kbd class="docs-search__key">↓</kbd>
368
+ {{ site_config.labels.search.navigate }}
369
+ </span>
370
+ <span class="flex flex-nowrap flex-v-center gap-thin">
371
+ <kbd class="docs-search__key">↵</kbd>
372
+ {{ site_config.labels.search.select }}
373
+ </span>
374
+ <span class="flex flex-nowrap flex-v-center gap-thin">
375
+ <kbd class="docs-search__key">esc</kbd>
376
+ {{ site_config.labels.search.close }}
377
+ </span>
378
+ </div>
379
+ </div>
380
+ </Popup>
381
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup>
2
+ // Кнопка поиска в шапке — порт VPNavBarSearchButton VitePress: лупа, подпись и
3
+ // подсказка клавиш. На мобильном — только лупа. Открывает SearchBox через стор
4
+ // (store.core.state.search_open).
5
+ //
6
+ // Подсказка ⌘K / Ctrl K: платформу знает только браузер. SSR и гидратация
7
+ // печатают Ctrl K, на Mac подпись меняется после монтирования — так разметка
8
+ // сервера и клиента при гидратации совпадает.
9
+ import { ref, onMounted } from 'vue';
10
+ import { useStore } from '@ozdao/martyrs/modules/core/client';
11
+ import { IconSearch } from '@ozdao/martyrs/modules/icons/client';
12
+
13
+ import site_config from '../configs/site.config.js';
14
+
15
+ const store = useStore();
16
+
17
+ const is_mac = ref(false);
18
+
19
+ onMounted(() => {
20
+ is_mac.value = /Mac|iPhone|iPad|iPod/i.test(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent);
21
+ });
22
+ </script>
23
+
24
+ <template>
25
+ <button
26
+ type="button"
27
+ class="docs-search-button flex flex-nowrap flex-v-center gap-thin pd-thin radius-small"
28
+ :aria-label="site_config.labels.search.button"
29
+ aria-keyshortcuts="Meta+K Control+K /"
30
+ @click="store.core.state.search_open = true"
31
+ >
32
+ <IconSearch class="docs-search-button__icon i-regular" aria-hidden="true" />
33
+ <span class="docs-search-button__label desktop-only">{{ site_config.labels.search.button }}</span>
34
+ <kbd class="docs-search-button__keys desktop-only pd-l-thin pd-r-thin radius-small">{{ is_mac ? '⌘K' : 'Ctrl K' }}</kbd>
35
+ </button>
36
+ </template>
@@ -0,0 +1,114 @@
1
+ <script setup>
2
+ // Дерево сайдбара (правила — content/sidebar.js). Компонент рекурсивный: корень
3
+ // строит дерево из страниц и вызывает себя для вложенных папок.
4
+ //
5
+ // Не Tree и не Spoiler дизайн-системы: Tree — список категорий с drag-n-drop, а
6
+ // Spoiler раскрывается только после монтирования, и SSR отдавал бы ветку
7
+ // активной страницы свёрнутой. Здесь раскрытие считается из адреса и совпадает
8
+ // на сервере и клиенте; свёрнутые ветки есть в HTML, скрыты стилем.
9
+ import { computed, reactive, onMounted } from 'vue';
10
+ import { useRoute } from 'vue-router';
11
+ import { useStore, mixins } from '@ozdao/martyrs/modules/core/client';
12
+ import { IconChevronBottom } from '@ozdao/martyrs/modules/icons/client';
13
+
14
+ import site_config from '../configs/site.config.js';
15
+ import { pages } from '../content/pages.js';
16
+ import { buildSidebar, containsUrl } from '../content/sidebar.js';
17
+
18
+ // Сайдбар martyrs передаёт навигации свои пропсы (theme, stateSidebar,
19
+ // navigationItems) — дереву они не нужны и атрибутами в разметку не идут.
20
+ defineOptions({ inheritAttrs: false });
21
+
22
+ const props = defineProps({
23
+ items: {
24
+ type: Array,
25
+ default: null,
26
+ },
27
+ depth: {
28
+ type: Number,
29
+ default: 0,
30
+ },
31
+ });
32
+
33
+ const route = useRoute();
34
+ const store = useStore();
35
+
36
+ const is_root = props.items === null;
37
+ const list = computed(() => props.items ?? buildSidebar(pages, site_config));
38
+
39
+ const current_url = computed(() => {
40
+ try {
41
+ return decodeURI(route.path);
42
+ } catch {
43
+ return route.path;
44
+ }
45
+ });
46
+
47
+ // Ручное раскрытие/сворачивание папки сильнее умолчаний; ключ — адрес или подпись.
48
+ const toggled = reactive({});
49
+
50
+ function itemKey(item) {
51
+ return item.url ?? item.text;
52
+ }
53
+
54
+ function isOpen(item) {
55
+ const key = itemKey(item);
56
+ if (key in toggled) return toggled[key];
57
+ return !item.collapsed || containsUrl(item, current_url.value);
58
+ }
59
+
60
+ function toggle(item) {
61
+ toggled[itemKey(item)] = !isOpen(item);
62
+ }
63
+
64
+ if (is_root) {
65
+ const { isPhone, isTablet } = mixins.useGlobalMixins();
66
+ // SSR открывает сайдбар (sidebarOpenOnEnter) — это десктопная раскладка. На
67
+ // телефоне и планшете сайдбар — выезжающее меню: закрываем его после
68
+ // гидратации, иначе первое нажатие кнопки меню только сбросило бы флаг.
69
+ onMounted(() => {
70
+ if (isPhone() || isTablet()) store.core.state.isOpenSidebar = false;
71
+ });
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <nav v-if="is_root" class="docs-sidebar pd-medium flex flex-column gap-medium" :aria-label="site_config.labels.menu">
77
+ <!-- На мобильном шапка без ссылок nav: они встают в начало меню, как у VitePress. -->
78
+ <ul v-if="site_config.nav.length" class="docs-sidebar__nav mobile-only flex flex-column gap-thin">
79
+ <li v-for="item in site_config.nav" :key="item.link">
80
+ <router-link :to="item.link" class="docs-sidebar__link">{{ item.text }}</router-link>
81
+ </li>
82
+ </ul>
83
+
84
+ <SidebarTree :items="list" :depth="1" />
85
+ </nav>
86
+
87
+ <ul v-else class="docs-sidebar__list flex flex-column" :class="{ 'docs-sidebar__list--nested': depth > 1 }">
88
+ <li v-for="item in list" :key="itemKey(item)" class="docs-sidebar__item" :class="{ 'docs-sidebar__item--folder': item.items }">
89
+ <div class="docs-sidebar__row flex flex-nowrap flex-v-center gap-thin">
90
+ <router-link
91
+ v-if="item.url"
92
+ :to="item.url"
93
+ class="docs-sidebar__link"
94
+ :class="{ 'docs-sidebar__link--active': item.url === current_url }"
95
+ >{{ item.text }}</router-link>
96
+ <button v-else type="button" class="docs-sidebar__link docs-sidebar__folder" @click="toggle(item)">{{ item.text }}</button>
97
+
98
+ <button
99
+ v-if="item.items"
100
+ type="button"
101
+ class="docs-sidebar__caret flex-center flex"
102
+ :class="{ 'docs-sidebar__caret--open': isOpen(item) }"
103
+ :aria-expanded="isOpen(item)"
104
+ :aria-label="item.text"
105
+ @click="toggle(item)"
106
+ >
107
+ <IconChevronBottom class="i-small" fill="rgb(var(--text-muted))" />
108
+ </button>
109
+ </div>
110
+
111
+ <SidebarTree v-if="item.items" v-show="isOpen(item)" :items="item.items" :depth="depth + 1" />
112
+ </li>
113
+ </ul>
114
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup>
2
+ // Заголовок сайта текстом — в шапке, когда тема потребителя не дала Logo.
3
+ import site_config from '../configs/site.config.js';
4
+
5
+ // Шапка martyrs передаёт логотипу theme и обработчик клика — ссылке они не нужны.
6
+ defineOptions({ inheritAttrs: false });
7
+ </script>
8
+
9
+ <template>
10
+ <router-link to="/" class="docs-site-title">{{ site_config.title }}</router-link>
11
+ </template>
@@ -0,0 +1,31 @@
1
+ <script setup>
2
+ // Переключатель темы — механизм martyrs (ds.theme): data-theme на <html> и
3
+ // cookie theme. По cookie сервер рендерит <html> уже в выбранной теме
4
+ // (vue-app-renderer.js), поэтому страница не мигает при загрузке. Тема по
5
+ // умолчанию — config.appearance, её подставляет server.js, пока cookie нет.
6
+ //
7
+ // Значок выбирается стилем по data-theme, а не из состояния: на сервере режим
8
+ // темы проставляется в стор уже после рендера, и значок из состояния разошёлся
9
+ // бы с разметкой при гидратации.
10
+ import { ds } from '@ozdao/martyrs/design-system';
11
+
12
+ import site_config from '../configs/site.config.js';
13
+
14
+ // Сайдбар martyrs передаёт theme и stateSidebar — переключателю они не нужны.
15
+ defineOptions({ inheritAttrs: false });
16
+ </script>
17
+
18
+ <template>
19
+ <div class="docs-theme-toggle pd-small">
20
+ <button
21
+ type="button"
22
+ class="docs-theme-toggle__button flex flex-nowrap flex-v-center gap-small w-100 pd-thin radius-small"
23
+ :aria-label="site_config.labels.theme"
24
+ @click="ds.theme.toggle()"
25
+ >
26
+ <span class="w-100 t-left">{{ site_config.labels.theme }}</span>
27
+ <span class="docs-theme-toggle__icon docs-theme-toggle__icon--dark" aria-hidden="true">🌙</span>
28
+ <span class="docs-theme-toggle__icon docs-theme-toggle__icon--light" aria-hidden="true">☀️</span>
29
+ </button>
30
+ </div>
31
+ </template>
@@ -0,0 +1,38 @@
1
+ // Клиентская проводка: getConfig() для martyrs. Конфиг сайта документации
2
+ // компоненты берут из configs/site.config.js напрямую.
3
+ import site_config from './site.config.js';
4
+
5
+ // Мета-теги страницы: App.vue martyrs печатает их по ключам meta.* из
6
+ // app.messages (свой словарь useI18n). Заголовок и описание — из конфига сайта,
7
+ // остальное пусто: без ключей в словаре в теги попадали бы сами ключи.
8
+ const meta = {
9
+ title: site_config.title,
10
+ description: site_config.description,
11
+ keywords: '',
12
+ og: { title: site_config.title, description: site_config.description, image: '' },
13
+ twitter: { title: site_config.title, description: site_config.description, image: '', card: 'summary' },
14
+ };
15
+
16
+ export async function getConfig() {
17
+ return {
18
+ env: {
19
+ isMobileApp: false,
20
+ },
21
+
22
+ // core.app.js поднимает WebSocket, если globals.websocket не false; у сайта
23
+ // документации сервера сокетов нет.
24
+ globals: {
25
+ websocket: false,
26
+ },
27
+
28
+ app: {
29
+ url: '',
30
+ messages: {
31
+ [site_config.locale]: { meta },
32
+ },
33
+ },
34
+
35
+ // Модулей martyrs (auth, orders…) у сайта документации нет.
36
+ modules: {},
37
+ };
38
+ }
@@ -0,0 +1,8 @@
1
+ // Конфиг сайта потребителя с умолчаниями — для бандла сайта. Алиас
2
+ // '@docs-theme' ставит bin/scriptorium.js (→ папка documentation/); в node этот
3
+ // файл не грузится, там конфиг читает content/scan.js.
4
+ import raw_config from '@docs-theme/config.js';
5
+
6
+ import { resolveSiteConfig } from '../content/site-config.js';
7
+
8
+ export default resolveSiteConfig(raw_config);
@@ -0,0 +1,54 @@
1
+ // Страница по адресу и её загрузка. Модуль страницы (чанк .md) грузится до
2
+ // рендера — в гарде роутера, — поэтому SSR отдаёт готовый HTML, а клиент
3
+ // гидрирует тот же HTML без ожидания чанка.
4
+ //
5
+ // В стор (store.core.state.docs) кладутся только данные страницы: стор уходит
6
+ // в data-state ответа JSON'ом, компонент туда не сериализуется. Сами модули
7
+ // лежат в карте ниже: это неизменяемые ES-модули, общие для всех запросов, как
8
+ // кэш импортов, — состояния запроса в них нет.
9
+ import { pages } from './pages.js';
10
+
11
+ const page_by_url = new Map(pages.map((page) => [page.url, page]));
12
+ const loaded_modules = new Map();
13
+
14
+ function decodePath(path) {
15
+ try {
16
+ return decodeURI(path);
17
+ } catch {
18
+ return path;
19
+ }
20
+ }
21
+
22
+ // Адрес → страница. Адрес папки без слеша ведёт на её индекс: redirect_url.
23
+ export function findPage(path) {
24
+ const page_path = decodePath(path);
25
+ const page = page_by_url.get(page_path);
26
+ if (page) return { page };
27
+
28
+ const folder_page = page_by_url.get(page_path.replace(/\/?$/, '/'));
29
+ if (folder_page) return { redirect_url: folder_page.url };
30
+
31
+ return {};
32
+ }
33
+
34
+ export async function openPage(page) {
35
+ let module = loaded_modules.get(page.url);
36
+ if (!module) {
37
+ module = await page.load();
38
+ loaded_modules.set(page.url, module);
39
+ }
40
+
41
+ return {
42
+ url: page.url,
43
+ file: page.file,
44
+ // Заголовок вкладки — h1 документа, как у VitePress; без h1 — подпись из сайдбара.
45
+ title: module.title || page.title,
46
+ frontmatter: module.frontmatter,
47
+ headers: module.headers,
48
+ };
49
+ }
50
+
51
+ // Компонент содержимого уже открытой страницы.
52
+ export function pageComponent(url) {
53
+ return loaded_modules.get(url)?.default ?? null;
54
+ }