@ampernic/vitepress-theme-alt-docs 0.1.4 → 0.1.6

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