@ampernic/vitepress-theme-alt-docs 0.1.3 → 0.1.5

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.
@@ -0,0 +1,478 @@
1
+ <script setup lang="ts">
2
+ import { onKeyStroke, useScrollLock } from '@vueuse/core'
3
+ import { useData, useRouter } from 'vitepress'
4
+ import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
5
+
6
+ const emit = defineEmits<{ (e: 'close'): void }>()
7
+ const { site } = useData()
8
+ const router = useRouter()
9
+
10
+ const ALL_DISTROS = [
11
+ 'alt-domain', 'alt-education', 'alt-kworkstation', 'alt-mobile',
12
+ 'alt-platform', 'alt-server', 'alt-server-v', 'alt-virtualisation-one',
13
+ 'alt-virtualization-pve', 'alt-workstation', 'group-policy', 'simply-linux',
14
+ 'alt-education-e2k', 'alt-server-e2k', 'alt-workstation-e2k', 'simply-linux-e2k',
15
+ ]
16
+
17
+ const DISTRO_NAMES: Record<string, string> = {
18
+ 'alt-domain': 'Альт Домен',
19
+ 'alt-education': 'Альт Образование',
20
+ 'alt-education-e2k': 'Альт Образование для Эльбрус',
21
+ 'alt-server': 'Альт Сервер',
22
+ 'alt-server-e2k': 'Альт Сервер для Эльбрус',
23
+ 'alt-server-v': 'Альт Сервер Виртуализации',
24
+ 'alt-workstation': 'Альт Рабочая станция',
25
+ 'alt-workstation-e2k': 'Альт Рабочая станция для Эльбрус',
26
+ 'alt-kworkstation': 'Альт Рабочая станция K',
27
+ 'alt-platform': 'Альт Платформа',
28
+ 'alt-mobile': 'Альт Мобильный',
29
+ 'alt-virtualisation-one': 'Альт Виртуализация ONE',
30
+ 'alt-virtualization-pve': 'Альт Виртуализация PVE',
31
+ 'simply-linux': 'Симпли Линукс',
32
+ 'simply-linux-e2k': 'Симпли Линукс для Эльбрус',
33
+ 'group-policy': 'Групповые политики',
34
+ }
35
+
36
+ // ── Pagefind ──────────────────────────────────────────────────────────────────
37
+
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ const pagefind = shallowRef<any>(null)
40
+ const indexLoading = ref(true)
41
+ const loadError = ref(false)
42
+ const availableDistros = ref<string[]>([])
43
+ const availableVersions = ref<string[]>([])
44
+
45
+ async function loadIndexes() {
46
+ // base = '' for index site (/), '/alt-domain' for distro sites
47
+ const base = site.value.base === '/' ? '' : site.value.base.replace(/\/+$/, '')
48
+
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ let pf: any = null
51
+ let firstIdx = -1
52
+
53
+ for (let i = 0; i < ALL_DISTROS.length; i++) {
54
+ try {
55
+ // @vite-ignore
56
+ pf = await import(/* @vite-ignore */ `${base}/${ALL_DISTROS[i]}/pagefind/pagefind.js`)
57
+ firstIdx = i
58
+ break
59
+ } catch { /* distro not deployed yet */ }
60
+ }
61
+
62
+ if (!pf) throw new Error('no pagefind index found')
63
+
64
+ for (let i = firstIdx + 1; i < ALL_DISTROS.length; i++) {
65
+ try {
66
+ await pf.mergeIndex(`${base}/${ALL_DISTROS[i]}/pagefind`)
67
+ } catch { /* skip */ }
68
+ }
69
+
70
+ await pf.init()
71
+
72
+ const filters = await pf.filters() as Record<string, Record<string, number>>
73
+ availableDistros.value = Object.keys(filters.distro ?? {}).sort()
74
+ availableVersions.value = Object.keys(filters.version ?? {}).sort((a, b) => {
75
+ const [aMaj, aMin] = a.split('.').map(Number)
76
+ const [bMaj, bMin] = b.split('.').map(Number)
77
+ return bMaj !== aMaj ? bMaj - aMaj : bMin - aMin
78
+ })
79
+
80
+ return pf
81
+ }
82
+
83
+ // ── Filters ───────────────────────────────────────────────────────────────────
84
+
85
+ const selectedDistro = ref('')
86
+ const selectedVersion = ref('')
87
+
88
+ watch(selectedDistro, () => { selectedVersion.value = '' })
89
+
90
+ // ── Search ────────────────────────────────────────────────────────────────────
91
+
92
+ interface ResultData {
93
+ url: string
94
+ meta: { title: string }
95
+ excerpt: string
96
+ }
97
+
98
+ const query = ref('')
99
+ const selectedIndex = ref(0)
100
+ const results = ref<ResultData[]>([])
101
+ const searching = ref(false)
102
+
103
+ let searchTimer: ReturnType<typeof setTimeout> | null = null
104
+
105
+ watch([query, selectedDistro, selectedVersion], () => {
106
+ selectedIndex.value = 0
107
+ if (searchTimer) clearTimeout(searchTimer)
108
+ if (!query.value.trim() || !pagefind.value) {
109
+ results.value = []
110
+ searching.value = false
111
+ return
112
+ }
113
+ searching.value = true
114
+ searchTimer = setTimeout(async () => {
115
+ const f: Record<string, string> = {}
116
+ if (selectedDistro.value) f.distro = selectedDistro.value
117
+ if (selectedVersion.value) f.version = selectedVersion.value
118
+ const search = await pagefind.value.search(query.value, Object.keys(f).length ? { filters: f } : {})
119
+ results.value = await Promise.all(
120
+ search.results.slice(0, 12).map((r: { data: () => Promise<ResultData> }) => r.data()),
121
+ )
122
+ searching.value = false
123
+ }, 200)
124
+ })
125
+
126
+ function parseUrl(url: string) {
127
+ const m = url.match(/\/([a-z][a-z0-9-]+)\/(\d+\.\d+(?:-\w+)?)\//)
128
+ return m ? { distro: m[1], version: m[2] } : null
129
+ }
130
+
131
+ function go(url: string) {
132
+ router.go(url)
133
+ close()
134
+ }
135
+
136
+ // ── Keyboard ──────────────────────────────────────────────────────────────────
137
+
138
+ onKeyStroke('ArrowDown', (e) => {
139
+ e.preventDefault()
140
+ selectedIndex.value = (selectedIndex.value + 1) % Math.max(results.value.length, 1)
141
+ })
142
+ onKeyStroke('ArrowUp', (e) => {
143
+ e.preventDefault()
144
+ selectedIndex.value = (selectedIndex.value - 1 + Math.max(results.value.length, 1)) % Math.max(results.value.length, 1)
145
+ })
146
+ onKeyStroke('Enter', () => {
147
+ const r = results.value[selectedIndex.value]
148
+ if (r) go(r.url)
149
+ })
150
+ onKeyStroke('Escape', () => close())
151
+
152
+ // ── Lifecycle ─────────────────────────────────────────────────────────────────
153
+
154
+ const inputEl = shallowRef<HTMLInputElement>()
155
+ const scrollLock = useScrollLock(typeof document !== 'undefined' ? document.body : null)
156
+
157
+ onMounted(async () => {
158
+ scrollLock.value = true
159
+ await nextTick()
160
+ inputEl.value?.focus()
161
+ try {
162
+ pagefind.value = await loadIndexes()
163
+ } catch {
164
+ loadError.value = true
165
+ } finally {
166
+ indexLoading.value = false
167
+ }
168
+ })
169
+
170
+ onUnmounted(() => { scrollLock.value = false })
171
+
172
+ function close() { emit('close') }
173
+ function onOverlayClick(e: MouseEvent) { if (e.target === e.currentTarget) close() }
174
+ </script>
175
+
176
+ <template>
177
+ <Teleport to="body">
178
+ <div class="gs-overlay" @click="onOverlayClick">
179
+ <div class="gs-modal" role="dialog" aria-modal="true">
180
+
181
+ <!-- Input row -->
182
+ <div class="gs-input-row">
183
+ <span class="gs-icon">
184
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
185
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
186
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
187
+ </svg>
188
+ </span>
189
+ <input
190
+ ref="inputEl"
191
+ v-model="query"
192
+ class="gs-input"
193
+ placeholder="Поиск по всей документации…"
194
+ autocomplete="off"
195
+ spellcheck="false"
196
+ />
197
+ <button class="gs-close" aria-label="Закрыть" @click="close"><kbd>Esc</kbd></button>
198
+ </div>
199
+
200
+ <!-- Filters row (shown once indexes loaded and have data) -->
201
+ <div v-if="!indexLoading && (availableDistros.length || availableVersions.length)" class="gs-filters">
202
+ <select v-model="selectedDistro" class="gs-select">
203
+ <option value="">Все дистрибутивы</option>
204
+ <option v-for="d in availableDistros" :key="d" :value="d">
205
+ {{ DISTRO_NAMES[d] ?? d }}
206
+ </option>
207
+ </select>
208
+ <select v-model="selectedVersion" class="gs-select">
209
+ <option value="">Все версии</option>
210
+ <option v-for="v in availableVersions" :key="v" :value="v">{{ v }}</option>
211
+ </select>
212
+ </div>
213
+
214
+ <!-- Results -->
215
+ <ul v-if="results.length" class="gs-results">
216
+ <li
217
+ v-for="(result, i) in results"
218
+ :key="result.url"
219
+ class="gs-result"
220
+ :class="{ 'is-selected': i === selectedIndex }"
221
+ @mouseenter="selectedIndex = i"
222
+ @click="go(result.url)"
223
+ >
224
+ <div class="gs-result-header">
225
+ <span class="gs-result-title">{{ result.meta.title }}</span>
226
+ <span v-if="parseUrl(result.url)" class="gs-result-badges">
227
+ <span class="gs-badge gs-badge--distro">
228
+ {{ DISTRO_NAMES[parseUrl(result.url)!.distro] ?? parseUrl(result.url)!.distro }}
229
+ </span>
230
+ <span class="gs-badge gs-badge--version">{{ parseUrl(result.url)!.version }}</span>
231
+ </span>
232
+ </div>
233
+ <!-- eslint-disable-next-line vue/no-v-html -->
234
+ <div class="gs-result-excerpt" v-html="result.excerpt" />
235
+ </li>
236
+ </ul>
237
+
238
+ <div v-else-if="indexLoading" class="gs-status">
239
+ <span class="gs-spinner" />
240
+ Загружаем индексы…
241
+ </div>
242
+ <div v-else-if="loadError" class="gs-status gs-status--error">
243
+ Индекс не найден. Запустите сборку документации.
244
+ </div>
245
+ <div v-else-if="searching" class="gs-status">Поиск…</div>
246
+ <div v-else-if="query && !searching" class="gs-status">
247
+ Ничего не найдено для «<strong>{{ query }}</strong>»
248
+ </div>
249
+ <div v-else-if="!query && !indexLoading" class="gs-status gs-status--hint">
250
+ Начните вводить запрос
251
+ </div>
252
+
253
+ <!-- Footer -->
254
+ <div class="gs-footer">
255
+ <span><kbd>↑</kbd><kbd>↓</kbd> навигация</span>
256
+ <span><kbd>Enter</kbd> открыть</span>
257
+ <span><kbd>Esc</kbd> закрыть</span>
258
+ </div>
259
+
260
+ </div>
261
+ </div>
262
+ </Teleport>
263
+ </template>
264
+
265
+ <style scoped>
266
+ .gs-overlay {
267
+ position: fixed;
268
+ inset: 0;
269
+ z-index: 100;
270
+ background: rgba(0, 0, 0, 0.5);
271
+ backdrop-filter: blur(4px);
272
+ display: flex;
273
+ align-items: flex-start;
274
+ justify-content: center;
275
+ padding-top: 10vh;
276
+ }
277
+
278
+ .gs-modal {
279
+ background: var(--vp-c-bg);
280
+ border: 1px solid var(--vp-c-divider);
281
+ border-radius: 12px;
282
+ box-shadow: var(--vp-shadow-4);
283
+ width: min(680px, calc(100vw - 32px));
284
+ max-height: 72vh;
285
+ display: flex;
286
+ flex-direction: column;
287
+ overflow: hidden;
288
+ }
289
+
290
+ .gs-input-row {
291
+ display: flex;
292
+ align-items: center;
293
+ gap: 8px;
294
+ padding: 12px 16px;
295
+ border-bottom: 1px solid var(--vp-c-divider);
296
+ }
297
+
298
+ .gs-icon {
299
+ color: var(--vp-c-text-3);
300
+ flex-shrink: 0;
301
+ display: flex;
302
+ }
303
+
304
+ .gs-input {
305
+ flex: 1;
306
+ background: transparent;
307
+ border: none;
308
+ outline: none;
309
+ font-size: 16px;
310
+ color: var(--vp-c-text-1);
311
+ min-width: 0;
312
+ }
313
+
314
+ .gs-input::placeholder {
315
+ color: var(--vp-c-text-3);
316
+ }
317
+
318
+ .gs-close {
319
+ background: none;
320
+ border: none;
321
+ cursor: pointer;
322
+ padding: 2px;
323
+ color: var(--vp-c-text-3);
324
+ }
325
+
326
+ .gs-filters {
327
+ display: flex;
328
+ gap: 8px;
329
+ padding: 8px 16px;
330
+ border-bottom: 1px solid var(--vp-c-divider);
331
+ background: var(--vp-c-default-soft);
332
+ }
333
+
334
+ .gs-select {
335
+ flex: 1;
336
+ min-width: 0;
337
+ padding: 5px 8px;
338
+ font-size: 13px;
339
+ background: var(--vp-c-bg);
340
+ border: 1px solid var(--vp-c-divider);
341
+ border-radius: 6px;
342
+ color: var(--vp-c-text-1);
343
+ cursor: pointer;
344
+ outline: none;
345
+ }
346
+
347
+ .gs-select:focus {
348
+ border-color: var(--vp-c-brand-1);
349
+ }
350
+
351
+ .gs-results {
352
+ overflow-y: auto;
353
+ list-style: none;
354
+ margin: 0;
355
+ padding: 8px;
356
+ flex: 1;
357
+ }
358
+
359
+ .gs-result {
360
+ padding: 8px 12px;
361
+ border-radius: 8px;
362
+ cursor: pointer;
363
+ transition: background 0.1s;
364
+ }
365
+
366
+ .gs-result.is-selected,
367
+ .gs-result:hover {
368
+ background: var(--vp-c-default-soft);
369
+ }
370
+
371
+ .gs-result-header {
372
+ display: flex;
373
+ align-items: baseline;
374
+ gap: 8px;
375
+ margin-bottom: 4px;
376
+ flex-wrap: wrap;
377
+ }
378
+
379
+ .gs-result-title {
380
+ font-size: 14px;
381
+ color: var(--vp-c-text-1);
382
+ font-weight: 500;
383
+ }
384
+
385
+ .gs-result-badges {
386
+ display: flex;
387
+ gap: 4px;
388
+ flex-shrink: 0;
389
+ }
390
+
391
+ .gs-badge {
392
+ font-size: 11px;
393
+ padding: 1px 6px;
394
+ border-radius: 4px;
395
+ font-weight: 500;
396
+ white-space: nowrap;
397
+ }
398
+
399
+ .gs-badge--distro {
400
+ background: var(--vp-c-brand-soft);
401
+ color: var(--vp-c-brand-1);
402
+ }
403
+
404
+ .gs-badge--version {
405
+ background: var(--vp-c-default-soft);
406
+ color: var(--vp-c-text-2);
407
+ border: 1px solid var(--vp-c-divider);
408
+ }
409
+
410
+ .gs-result-excerpt {
411
+ font-size: 12px;
412
+ color: var(--vp-c-text-2);
413
+ line-height: 1.5;
414
+ overflow: hidden;
415
+ display: -webkit-box;
416
+ -webkit-line-clamp: 2;
417
+ -webkit-box-orient: vertical;
418
+ }
419
+
420
+ .gs-status {
421
+ padding: 24px;
422
+ text-align: center;
423
+ color: var(--vp-c-text-2);
424
+ font-size: 14px;
425
+ display: flex;
426
+ align-items: center;
427
+ justify-content: center;
428
+ gap: 8px;
429
+ }
430
+
431
+ .gs-status--error {
432
+ color: var(--vp-c-danger-1);
433
+ }
434
+
435
+ .gs-status--hint {
436
+ color: var(--vp-c-text-3);
437
+ font-style: italic;
438
+ }
439
+
440
+ .gs-spinner {
441
+ width: 16px;
442
+ height: 16px;
443
+ border: 2px solid var(--vp-c-divider);
444
+ border-top-color: var(--vp-c-brand-1);
445
+ border-radius: 50%;
446
+ animation: spin 0.7s linear infinite;
447
+ }
448
+
449
+ @keyframes spin {
450
+ to { transform: rotate(360deg); }
451
+ }
452
+
453
+ .gs-footer {
454
+ display: flex;
455
+ gap: 16px;
456
+ padding: 8px 16px;
457
+ border-top: 1px solid var(--vp-c-divider);
458
+ font-size: 12px;
459
+ color: var(--vp-c-text-3);
460
+ }
461
+
462
+ kbd {
463
+ background: var(--vp-c-default-soft);
464
+ border: 1px solid var(--vp-c-divider);
465
+ border-radius: 4px;
466
+ padding: 1px 5px;
467
+ font-size: 11px;
468
+ font-family: inherit;
469
+ }
470
+ </style>
471
+
472
+ <style>
473
+ .gs-result-excerpt mark {
474
+ background: transparent;
475
+ color: var(--vp-c-brand-1);
476
+ font-weight: 600;
477
+ }
478
+ </style>
@@ -1,25 +1,34 @@
1
1
  <script setup lang="ts">
2
2
  import { onKeyStroke } from '@vueuse/core'
3
- import { useRoute } from 'vitepress'
3
+ import { useData, useRoute } from 'vitepress'
4
4
  import { computed, defineAsyncComponent, ref } from 'vue'
5
5
 
6
6
  const ADSearch = defineAsyncComponent(() => import('./ADSearch.vue'))
7
+ const ADGlobalSearch = defineAsyncComponent(() => import('./ADGlobalSearch.vue'))
7
8
 
9
+ const { site } = useData()
8
10
  const route = useRoute()
9
11
  const open = ref(false)
10
12
 
11
- // Show search on all versioned pages (including version root /11.1/)
12
- // Hidden only on site root and distro-selection landing pages
13
+ // Strip base prefix from route.path before testing
14
+ // (route.path includes base, e.g. /alt-domain/11.1/... when base=/alt-domain/)
15
+ const contentPath = computed(() => {
16
+ const base = site.value.base
17
+ const path = route.path
18
+ return base.length > 1 && path.startsWith(base) ? path.slice(base.length - 1) : path
19
+ })
20
+
21
+ // Versioned content page — per-distro search
13
22
  const isContentPage = computed(() =>
14
- /^\/\d+\.\d+(?:-\w+)?\//.test(route.path)
23
+ /^\/\d+\.\d+(?:-\w+)?\//.test(contentPath.value)
15
24
  )
16
25
 
17
26
  function openSearch() {
18
- if (isContentPage.value) open.value = true
27
+ open.value = true
19
28
  }
20
29
 
21
30
  onKeyStroke('k', (e) => {
22
- if ((e.metaKey || e.ctrlKey) && isContentPage.value) {
31
+ if (e.metaKey || e.ctrlKey) {
23
32
  e.preventDefault()
24
33
  e.stopPropagation()
25
34
  open.value = true
@@ -29,7 +38,7 @@ onKeyStroke('k', (e) => {
29
38
  onKeyStroke('/', (e) => {
30
39
  const tag = (e.target as HTMLElement).tagName
31
40
  const editing = (e.target as HTMLElement).isContentEditable || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
32
- if (!editing && isContentPage.value) {
41
+ if (!editing) {
33
42
  e.preventDefault()
34
43
  open.value = true
35
44
  }
@@ -40,7 +49,6 @@ onKeyStroke('/', (e) => {
40
49
  <!-- Wrapper mimics .VPNavBarSearch layout (flex: 1 on ≥768px) -->
41
50
  <div class="ad-search-wrap">
42
51
  <button
43
- v-if="isContentPage"
44
52
  type="button"
45
53
  class="DocSearch DocSearch-Button"
46
54
  aria-label="Поиск"
@@ -56,7 +64,8 @@ onKeyStroke('/', (e) => {
56
64
  </span>
57
65
  </button>
58
66
 
59
- <ADSearch v-if="open" @close="open = false" />
67
+ <ADSearch v-if="open && isContentPage" @close="open = false" />
68
+ <ADGlobalSearch v-if="open && !isContentPage" @close="open = false" />
60
69
  </div>
61
70
  </template>
62
71
 
@@ -85,4 +94,3 @@ onKeyStroke('/', (e) => {
85
94
  }
86
95
  }
87
96
  </style>
88
-
@@ -15,6 +15,7 @@ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e
15
15
  function createSharedConfig(options) {
16
16
  const base = process.env.VITE_BASE ?? "/";
17
17
  const pagefind = (0, _vitepressPluginPagefind.PagefindPlugin)({
18
+ distroName: options.distroName,
18
19
  extractFilters: id => {
19
20
  const m = id.match(/[/\\](\d+\.\d+(?:-\w+)?)[/\\]/);
20
21
  return m ? {
@@ -8,6 +8,7 @@ import { PagefindPlugin } from "@ampernic/vitepress-plugin-pagefind";
8
8
  export function createSharedConfig(options) {
9
9
  const base = process.env.VITE_BASE ?? "/";
10
10
  const pagefind = PagefindPlugin({
11
+ distroName: options.distroName,
11
12
  extractFilters: (id) => {
12
13
  const m = id.match(/[/\\](\d+\.\d+(?:-\w+)?)[/\\]/);
13
14
  return m ? { version: m[1] } : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampernic/vitepress-theme-alt-docs",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Shared VitePress theme for ALT Linux documentation",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "author": "Ampernic",
@@ -33,10 +33,10 @@
33
33
  "@nolebase/vitepress-plugin-enhanced-readabilities": "^2.14.0",
34
34
  "markdown-it-kbd": "^1.0.0",
35
35
  "vitepress-plugin-tabs": "^0.6.0",
36
- "@ampernic/vitepress-plugin-pagefind": "0.1.2",
37
36
  "@ampernic/vitepress-plugin-alt-docs-versioning": "0.1.2",
38
- "@ampernic/vitepress-plugin-cross-site-router": "0.1.2",
39
- "@ampernic/vitepress-plugin-html-image": "0.1.2"
37
+ "@ampernic/vitepress-plugin-html-image": "0.1.2",
38
+ "@ampernic/vitepress-plugin-pagefind": "0.1.5",
39
+ "@ampernic/vitepress-plugin-cross-site-router": "0.1.2"
40
40
  },
41
41
  "devDependencies": {
42
42
  "builtin-modules": "^3.3.0",