@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,416 @@
1
+ // ============================================================================
2
+ // Раскладка сайта документации: страница, сайдбар, оглавление, «Назад / Дальше»
3
+ // ============================================================================
4
+ // Шапку, сайдбар и мобильное меню рисует layoutClient martyrs; здесь —
5
+ // визуальные пресеты наших компонентов и ширины колонок. Отступы — шкала
6
+ // martyrs (--thin … --big), цвета — токены martyrs.
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Страница: содержимое + оглавление справа (≥960px, как у VitePress)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ .docs-layout {
13
+ display: flex;
14
+ justify-content: center;
15
+ gap: var(--big);
16
+ padding: var(--medium) var(--medium) var(--big);
17
+ }
18
+
19
+ .docs-layout__content {
20
+ width: 100%;
21
+ max-width: var(--doc-content-width);
22
+ min-width: 0;
23
+ }
24
+
25
+ .docs-layout__outline {
26
+ display: none;
27
+ width: var(--doc-outline-width);
28
+ flex-shrink: 0;
29
+ }
30
+
31
+ @media (min-width: 960px) {
32
+ .docs-layout {
33
+ padding: var(--semi) var(--big) var(--big);
34
+ }
35
+
36
+ .docs-layout__outline {
37
+ display: block;
38
+ }
39
+ }
40
+
41
+ .docs-layout__top {
42
+ font-size: var(--fs-small);
43
+ color: rgb(var(--text-secondary));
44
+ background: transparent;
45
+ cursor: pointer;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Шапка: логотип-текст и ссылки nav
50
+ // ---------------------------------------------------------------------------
51
+
52
+ .docs-site-title {
53
+ font-size: var(--fs-semi);
54
+ color: rgb(var(--text-primary));
55
+ }
56
+
57
+ .docs-header-nav__link {
58
+ font-size: var(--fs-small);
59
+ color: rgb(var(--text-primary));
60
+ transition: color 0.2s ease;
61
+
62
+ &:hover,
63
+ &--active {
64
+ color: rgb(var(--doc-link));
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Сайдбар: дерево папок
70
+ // ---------------------------------------------------------------------------
71
+
72
+ .docs-sidebar__list--nested {
73
+ padding-left: var(--regular);
74
+ }
75
+
76
+ .docs-sidebar__link {
77
+ display: block;
78
+ flex: 1 1 auto;
79
+ min-width: 0;
80
+ padding: var(--thin) 0;
81
+ font-size: var(--fs-small);
82
+ line-height: 1.5;
83
+ text-align: left;
84
+ color: rgb(var(--text-secondary));
85
+ background: transparent;
86
+ cursor: pointer;
87
+ transition: color 0.2s ease;
88
+
89
+ &:hover {
90
+ color: rgb(var(--text-primary));
91
+ }
92
+
93
+ &--active,
94
+ &--active:hover {
95
+ color: rgb(var(--doc-link));
96
+ }
97
+ }
98
+
99
+ // Папка — узел дерева: подпись ярче страниц.
100
+ .docs-sidebar__item--folder > .docs-sidebar__row > .docs-sidebar__link:not(.docs-sidebar__link--active) {
101
+ color: rgb(var(--text-primary));
102
+ }
103
+
104
+ .docs-sidebar__caret {
105
+ flex-shrink: 0;
106
+ width: var(--medium);
107
+ height: var(--medium);
108
+ background: transparent;
109
+ cursor: pointer;
110
+ border-radius: var(--thin);
111
+ transform: rotate(-90deg);
112
+ transition: transform 0.2s ease, background 0.2s ease;
113
+
114
+ &:hover {
115
+ background: rgb(var(--bg-hover));
116
+ }
117
+
118
+ &--open {
119
+ transform: none;
120
+ }
121
+ }
122
+
123
+ .docs-sidebar__nav {
124
+ padding-bottom: var(--medium);
125
+ border-bottom: 1px solid var(--border-subtle);
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Переключатель темы (низ сайдбара)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ .docs-theme-toggle {
133
+ border-top: 1px solid var(--border-subtle);
134
+ }
135
+
136
+ .docs-theme-toggle__button {
137
+ font-size: var(--fs-small);
138
+ color: rgb(var(--text-secondary));
139
+ background: transparent;
140
+ cursor: pointer;
141
+
142
+ &:hover {
143
+ background: rgb(var(--bg-hover));
144
+ color: rgb(var(--text-primary));
145
+ }
146
+ }
147
+
148
+ // Значок по теме <html>: светлая — солнце, тёмная (явная или системная) — луна.
149
+ .docs-theme-toggle__icon--dark {
150
+ display: none;
151
+ }
152
+
153
+ :root[data-theme='dark'] {
154
+ .docs-theme-toggle__icon--dark { display: inline; }
155
+ .docs-theme-toggle__icon--light { display: none; }
156
+ }
157
+
158
+ @media (prefers-color-scheme: dark) {
159
+ :root:not([data-theme='light']) {
160
+ .docs-theme-toggle__icon--dark { display: inline; }
161
+ .docs-theme-toggle__icon--light { display: none; }
162
+ }
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Оглавление страницы
167
+ // ---------------------------------------------------------------------------
168
+
169
+ .docs-outline {
170
+ position: sticky;
171
+ top: var(--semi);
172
+ padding-left: var(--regular);
173
+ border-left: 1px solid var(--border-subtle);
174
+ }
175
+
176
+ // Заголовок оглавления: кегль на ступень выше пунктов и отступ снизу, чтобы
177
+ // читался как заголовок, а не как первый пункт списка.
178
+ .docs-outline__label {
179
+ font-size: var(--fs-regular);
180
+ color: rgb(var(--text-primary));
181
+ margin-bottom: var(--small);
182
+ }
183
+
184
+ .docs-outline__link {
185
+ display: block;
186
+ font-size: var(--fs-small);
187
+ line-height: 1.5;
188
+ color: rgb(var(--text-secondary));
189
+ transition: color 0.2s ease;
190
+
191
+ &:hover {
192
+ color: rgb(var(--text-primary));
193
+ }
194
+
195
+ &--active,
196
+ &--active:hover {
197
+ color: rgb(var(--doc-link));
198
+ }
199
+ }
200
+
201
+ .docs-outline__level-1 {
202
+ padding-left: var(--regular);
203
+ }
204
+
205
+ .docs-outline__level-2 {
206
+ padding-left: var(--medium);
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // «Назад / Дальше»
211
+ // ---------------------------------------------------------------------------
212
+
213
+ .docs-prev-next {
214
+ display: grid;
215
+ grid-template-columns: 1fr 1fr;
216
+ border-top: 1px solid var(--border-subtle);
217
+ }
218
+
219
+ .docs-prev-next__link {
220
+ border: 1px solid var(--border-subtle);
221
+ transition: border-color 0.2s ease;
222
+
223
+ &:hover {
224
+ border-color: rgb(var(--doc-link));
225
+ }
226
+
227
+ &--next {
228
+ text-align: right;
229
+ }
230
+ }
231
+
232
+ .docs-prev-next__label {
233
+ font-size: var(--fs-micro);
234
+ color: rgb(var(--text-secondary));
235
+ }
236
+
237
+ .docs-prev-next__title {
238
+ font-size: var(--fs-small);
239
+ color: rgb(var(--doc-link));
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Поиск: кнопка в шапке (SearchButton) и окно (SearchBox на Popup martyrs)
244
+ // ---------------------------------------------------------------------------
245
+
246
+ .docs-search-button {
247
+ font-size: var(--fs-small);
248
+ color: rgb(var(--text-secondary));
249
+ background: rgb(var(--bg-card));
250
+ border: 1px solid transparent;
251
+ cursor: pointer;
252
+ transition: border-color 0.2s ease, color 0.2s ease;
253
+
254
+ &:hover {
255
+ border-color: rgb(var(--doc-link));
256
+ color: rgb(var(--text-primary));
257
+ }
258
+ }
259
+
260
+ .docs-search-button__keys {
261
+ font-family: var(--font);
262
+ font-size: var(--fs-small);
263
+ color: rgb(var(--text-muted));
264
+ border: 1px solid var(--border-subtle);
265
+ }
266
+
267
+ // Окно: ширина как у VitePress (до 900px), на телефоне — весь экран. Popup
268
+ // ставит панели h-max-100 и o-y-scroll; здесь прокручивается только список
269
+ // результатов, поэтому селектор сильнее одиночных JIT-классов.
270
+ .popup-content.docs-search {
271
+ display: flex;
272
+ flex-direction: column;
273
+ width: min(100vw - 2 * var(--medium), 900px);
274
+ max-height: min(100vh - 2 * var(--big), 900px);
275
+ margin-top: var(--big);
276
+ overflow: hidden;
277
+ border: 1px solid var(--border-subtle);
278
+ color: rgb(var(--text-primary));
279
+ }
280
+
281
+ @media (max-width: 767px) {
282
+ .popup-content.docs-search {
283
+ width: 100vw;
284
+ height: 100vh;
285
+ max-height: none;
286
+ margin-top: 0;
287
+ border: none;
288
+ border-radius: 0;
289
+ }
290
+ }
291
+
292
+ .docs-search__shell {
293
+ flex: 1 1 auto;
294
+ min-height: 0;
295
+ }
296
+
297
+ .docs-search__bar {
298
+ flex-shrink: 0;
299
+ background: rgb(var(--bg-input));
300
+ border: 1px solid var(--border-subtle);
301
+ cursor: text;
302
+ }
303
+
304
+ .docs-search__icon {
305
+ flex-shrink: 0;
306
+ color: rgb(var(--text-muted));
307
+ }
308
+
309
+ .docs-search__input {
310
+ min-width: 0;
311
+ font-size: var(--fs-regular);
312
+ color: rgb(var(--text-primary));
313
+ background: transparent;
314
+ border: none;
315
+ outline: none;
316
+
317
+ &::placeholder {
318
+ color: rgb(var(--text-muted));
319
+ }
320
+
321
+ // Свой крестик браузера у type="search" — лишний: сброс делает кнопка рядом.
322
+ &::-webkit-search-cancel-button {
323
+ appearance: none;
324
+ }
325
+ }
326
+
327
+ .docs-search__reset {
328
+ flex-shrink: 0;
329
+ background: transparent;
330
+ cursor: pointer;
331
+
332
+ &:hover .docs-search__icon {
333
+ color: rgb(var(--doc-link));
334
+ }
335
+ }
336
+
337
+ .docs-search__results {
338
+ min-height: 0;
339
+ overflow-x: hidden;
340
+ overflow-y: auto;
341
+ overscroll-behavior: contain;
342
+ }
343
+
344
+ .docs-search__result {
345
+ font-size: var(--fs-small);
346
+ line-height: 1.5;
347
+ color: rgb(var(--text-secondary));
348
+ background: rgb(var(--bg-card));
349
+ border: 1px solid transparent;
350
+ outline: none;
351
+
352
+ &--selected {
353
+ color: rgb(var(--text-primary));
354
+ border-color: rgb(var(--doc-link));
355
+ }
356
+ }
357
+
358
+ .docs-search__hash {
359
+ flex-shrink: 0;
360
+ color: rgb(var(--doc-link));
361
+ opacity: 0.6;
362
+ }
363
+
364
+ .docs-search__title--main {
365
+ color: rgb(var(--text-primary));
366
+ }
367
+
368
+ .docs-search__separator {
369
+ color: rgb(var(--text-muted));
370
+ }
371
+
372
+ .docs-search__mark {
373
+ color: rgb(var(--text-primary));
374
+ background: rgba(var(--doc-link), 0.25);
375
+ border-radius: var(--thin);
376
+ }
377
+
378
+ .docs-search__result--selected .docs-search__title--main {
379
+ color: rgb(var(--doc-link));
380
+ }
381
+
382
+ .docs-search__empty {
383
+ font-size: var(--fs-small);
384
+ color: rgb(var(--text-secondary));
385
+ }
386
+
387
+ .docs-search__keys {
388
+ flex-shrink: 0;
389
+ font-size: var(--fs-small);
390
+ color: rgb(var(--text-muted));
391
+ }
392
+
393
+ .docs-search__key {
394
+ min-width: var(--medium);
395
+ padding: 0 var(--thin);
396
+ font-family: var(--font);
397
+ text-align: center;
398
+ border: 1px solid var(--border-subtle);
399
+ border-radius: var(--thin);
400
+ background: rgb(var(--bg-card));
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // 404
405
+ // ---------------------------------------------------------------------------
406
+
407
+ .docs-not-found {
408
+ max-width: var(--doc-content-width);
409
+ margin: 0 auto;
410
+ text-align: center;
411
+ align-items: center;
412
+ }
413
+
414
+ .docs-not-found__home {
415
+ color: rgb(var(--doc-link));
416
+ }
@@ -0,0 +1,45 @@
1
+ // ============================================================================
2
+ // Scriptorium — тема сайта документации
3
+ // ============================================================================
4
+ // База и UI-кит дизайн-системы martyrs здесь НЕ импортятся: они приходят через
5
+ // JS-энтрипоинт '@ozdao/martyrs/design-system' в ./index.js (до
6
+ // этой темы). Цвета и тёмная/светлая темы — токены martyrs (brand.scss:
7
+ // --text-*, --bg-*, --border-*, --color-*), переключение — data-theme на :root.
8
+ // Здесь только то, чего в martyrs нет: шрифты сайта, ширины колонок
9
+ // документации и кегли содержимого.
10
+
11
+ :root {
12
+ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
13
+ --font-main: var(--font);
14
+ --font-second: var(--font);
15
+ --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
16
+
17
+ // Ширины колонок — как у VitePress: содержимое до 688px, оглавление 224px
18
+ // (сайдбар 272px задаёт w-17r в meta роута).
19
+ --doc-content-width: 688px;
20
+ --doc-outline-width: 224px;
21
+
22
+ // Заголовки содержимого: шкала h1–h5 martyrs рассчитана на лендинги и для
23
+ // текста документации крупна.
24
+ --doc-h1: 2rem;
25
+ --doc-h2: var(--fs-extra);
26
+ --doc-h3: var(--fs-semi);
27
+ --doc-h4: var(--fs-medium);
28
+
29
+ // Ссылки в тексте: синий акцент martyrs читается и на светлом, и на тёмном фоне.
30
+ --doc-link: var(--color-accent-secondary);
31
+ }
32
+
33
+ html {
34
+ scroll-behavior: smooth;
35
+ }
36
+
37
+ body {
38
+ font-family: var(--font);
39
+ background: rgb(var(--bg-base));
40
+ color: rgb(var(--text-primary));
41
+ min-height: 100vh;
42
+ line-height: 1.5;
43
+ -webkit-font-smoothing: antialiased;
44
+ -moz-osx-font-smoothing: grayscale;
45
+ }
@@ -0,0 +1,27 @@
1
+ import { createI18n } from 'vue-i18n';
2
+ import { i18nManager } from '@ozdao/martyrs/modules/core/client';
3
+
4
+ import site_config from '../configs/site.config.js';
5
+
6
+ // Язык сайта один — из config.lang потребителя ('ru-RU' → 'ru'). Языковых
7
+ // префиксов в адресах нет: адреса страниц повторяют папки репозитория.
8
+ // Словарь — только интерфейс martyrs (core.*, его регистрирует core.app.js);
9
+ // мета-теги страницы — в app.messages (configs/client.config.js).
10
+ const locale = site_config.locale;
11
+
12
+ export function getLocales() {
13
+ i18nManager.setSupportedLocales({ [locale]: locale.toUpperCase() });
14
+ i18nManager.setDefaultLocale(locale);
15
+
16
+ return createI18n({
17
+ legacy: false,
18
+ locale,
19
+ // Интерфейс martyrs (core.*) переведён на en и ru: для другого языка сайта
20
+ // его подписи берутся английскими.
21
+ fallbackLocale: [locale, 'en'],
22
+ messages: i18nManager.getAllMessages(),
23
+ globalInjection: true,
24
+ availableLocales: [locale],
25
+ pluralRules: i18nManager.pluralRules,
26
+ });
27
+ }
package/src/main.js ADDED
@@ -0,0 +1,8 @@
1
+ // Публичный API пакета для темы потребителя: import { Badge, useDocs } from '@ozdao/scriptorium'.
2
+ // Файл собирается бандлом сайта (алиас '@docs-theme' ставит bin/scriptorium.js),
3
+ // поэтому импортировать его можно только из documentation/ потребителя.
4
+ export { default as Badge } from './components/Badge.vue';
5
+ export { default as ThemeToggle } from './components/ThemeToggle.vue';
6
+ export { default as SearchButton } from './components/SearchButton.vue';
7
+ export { default as SearchBox } from './components/SearchBox.vue';
8
+ export { useDocs } from './content/use-docs.js';
@@ -0,0 +1,101 @@
1
+ import {
2
+ createRouter,
3
+ createMemoryHistory,
4
+ createWebHistory,
5
+ } from 'vue-router';
6
+
7
+ import { scrollBehavior, layoutClient, Header, Sidebar } from '@ozdao/martyrs/modules/core/client';
8
+
9
+ import theme from '@docs-theme/theme/index.js';
10
+
11
+ import { findPage, openPage } from '../content/current-page.js';
12
+
13
+ import Page from '../components/Page.vue';
14
+ import SidebarTree from '../components/SidebarTree.vue';
15
+ import HeaderNav from '../components/HeaderNav.vue';
16
+ import ThemeToggle from '../components/ThemeToggle.vue';
17
+ import SiteTitle from '../components/SiteTitle.vue';
18
+
19
+ // Имя 'Home' у корневого роута обязательно: core.app.js вешает на него
20
+ // заглушки backoffice/organizations/users через router.addRoute('Home', ...).
21
+ // Раскладка — layoutClient martyrs: шапка, сайдбар и мобильное меню задаются
22
+ // meta роута. Все страницы документации — один дочерний роут на Page.vue.
23
+ const routes = [{
24
+ path: '/',
25
+
26
+ components: {
27
+ default: layoutClient,
28
+ },
29
+
30
+ name: 'Home',
31
+
32
+ meta: {
33
+ // Один ключ раскладки на все страницы сайта: Client.vue martyrs ключует
34
+ // router-view по route.meta.layoutKey (иначе по route.name), и при смене
35
+ // ключа проигрывает scaleTransition с лоадером в паузе. Главная и остальные
36
+ // страницы — разные маршруты (Index / Page), без общего ключа переход между
37
+ // ними шёл с анимацией поверх уже подменённого содержимого.
38
+ layoutKey: 'docs',
39
+ header: Header,
40
+ // Логотип потребителя (theme.Logo) вместо текстового заголовка.
41
+ logotype: theme?.Logo ?? SiteTitle,
42
+ location: false,
43
+ header_show_profile: false,
44
+ header_show_notifications: false,
45
+ header_right_component: HeaderNav,
46
+
47
+ sidebar: Sidebar,
48
+ sidebar_navigation: SidebarTree,
49
+ sidebar_bottom_component: ThemeToggle,
50
+ // 272px, как у VitePress.
51
+ sidebar_width: 'w-17r',
52
+ // На десктопе сайдбар открыт всегда; на мобильном он выезжает по кнопке меню.
53
+ sidebarOpenOnEnter: true,
54
+ },
55
+
56
+ children: [{
57
+ // Главная ('/'): без пустого ребёнка адрес '/' достаётся самому 'Home' —
58
+ // статичный путь весит больше catch-all ниже, и страница не рендерится.
59
+ path: '',
60
+ name: 'Index',
61
+ component: Page,
62
+ }, {
63
+ // Любой адрес: страницу по нему ищет гард ниже. Core martyrs добавляет свой
64
+ // catch-all (NotFound) позже — при равном весе пути побеждает объявленный раньше.
65
+ path: ':path(.*)*',
66
+ name: 'Page',
67
+ component: Page,
68
+ }],
69
+ }];
70
+
71
+ export function getRouter(context) {
72
+ const router = createRouter({
73
+ routes,
74
+ // История — на каждый вызов: в SSR getRouter зовётся на каждый запрос,
75
+ // общая memory-история смешала бы навигацию разных запросов.
76
+ history: typeof window === 'undefined' ? createMemoryHistory() : createWebHistory(),
77
+ scrollBehavior: scrollBehavior(),
78
+ });
79
+
80
+ // Страница грузится до рендера: на сервере — внутри router.push запроса, на
81
+ // клиенте — до гидратации (router.isReady) и до каждого перехода. Стор —
82
+ // стор этого запроса из контекста.
83
+ router.beforeResolve(async (to) => {
84
+ const state = context.store.core.state;
85
+ const { page, redirect_url } = findPage(to.path);
86
+
87
+ if (redirect_url) return { path: redirect_url, query: to.query, hash: to.hash, replace: true };
88
+
89
+ if (!page) {
90
+ state.docs = null;
91
+ // 404 в ответе сервера: vue-app-renderer martyrs берёт статус из notFound.
92
+ state.notFound = true;
93
+ return;
94
+ }
95
+
96
+ state.docs = await openPage(page);
97
+ state.notFound = false;
98
+ });
99
+
100
+ return router;
101
+ }
@@ -0,0 +1,56 @@
1
+ // Переходы по обычным <a> внутри сайта — роутером, как у VitePress: HTML
2
+ // страниц (markdown) и компоненты темы потребителя (логотип, PageMeta) пишут
3
+ // ссылки тегом <a>, и без перехвата каждый клик перезагружал бы страницу.
4
+ // Якорь на текущей странице прокручивает скроллер раскладки martyrs (#scrollview).
5
+ import { findPage } from '../content/current-page.js';
6
+
7
+ // Прокрутка к заголовку внутри #scrollview: окно документа у раскладки martyrs
8
+ // не скроллится, скроллится этот контейнер. Отступ — чтобы заголовок не лип к краю.
9
+ export function scrollToAnchor(id, behavior = 'smooth') {
10
+ const scrollview = document.getElementById('scrollview');
11
+ const target = document.getElementById(id);
12
+ if (!scrollview || !target) return false;
13
+
14
+ const top = target.getBoundingClientRect().top - scrollview.getBoundingClientRect().top + scrollview.scrollTop;
15
+ scrollview.scrollTo({ top: Math.max(top - 24, 0), behavior });
16
+ return true;
17
+ }
18
+
19
+ function decodeHash(hash) {
20
+ try {
21
+ return decodeURIComponent(hash.slice(1));
22
+ } catch {
23
+ return hash.slice(1);
24
+ }
25
+ }
26
+
27
+ export function interceptLinks(router) {
28
+ document.addEventListener('click', (event) => {
29
+ if (event.defaultPrevented || event.button !== 0) return;
30
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
31
+
32
+ const link = event.target.closest?.('a[href]');
33
+ if (!link || link.hasAttribute('download')) return;
34
+ if (link.target && link.target !== '_self') return;
35
+
36
+ const url = new URL(link.href, window.location.href);
37
+ if (url.origin !== window.location.origin) return;
38
+
39
+ const same_page = url.pathname === window.location.pathname && url.search === window.location.search;
40
+ if (same_page && url.hash) {
41
+ // Якорь текущей страницы: прокрутка и адрес с якорем без новой навигации.
42
+ event.preventDefault();
43
+ scrollToAnchor(decodeHash(url.hash));
44
+ window.history.replaceState(window.history.state, '', url.hash);
45
+ return;
46
+ }
47
+
48
+ // Роутером — только страницы сайта. Ссылка на файл (картинка, PDF, статика
49
+ // из public) уходит браузеру как есть.
50
+ const { page, redirect_url } = findPage(url.pathname);
51
+ if (!page && !redirect_url) return;
52
+
53
+ event.preventDefault();
54
+ router.push(url.pathname + url.search + url.hash);
55
+ });
56
+ }