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

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.
package/README.md CHANGED
@@ -39,11 +39,24 @@ export default defineConfig({
39
39
  | `ADProducts` | Переключатель продуктов/дистрибутивов |
40
40
  | `ADProductsSidebar` | Боковая панель с выбором продукта |
41
41
  | `ADVersioning` | Переключатель версий документации |
42
- | `ADNavBarSearch` | Интеграция поиска в навбар |
43
- | `ADSearch` | Компонент поиска (Pagefind) |
42
+ | `ADNavBarSearch` | Кнопка открытия глобального поиска в навбаре |
43
+ | `ADGlobalSearch` | Глобальный поиск по всем дистрибутивам через Pagefind `mergeIndex` |
44
+ | `ADSearch` | Компонент поиска для одного сайта (Pagefind) |
44
45
  | `ADAssetLink` | Ссылка на файл-ассет |
45
46
  | `ADInlineImage` | Встроенное изображение с нормализацией пути |
46
47
 
48
+ ### ADGlobalSearch
49
+
50
+ Поисковая модалка для индексного сайта, объединяющая индексы всех задеплоенных дистрибутивов.
51
+
52
+ **Как работает:**
53
+ 1. Последовательно пробует `import(/{distro}/pagefind/pagefind.js)` для каждого дистрибутива из списка
54
+ 2. HEAD-проверяет `pagefind-entry.json` для остальных дистрибутивов — только подтверждённые идут в `mergeIndex`
55
+ > Важно: вызов `mergeIndex()` для несуществующего пути необратимо ломает SharedWorker Pagefind, все последующие запросы тоже падают
56
+ 3. Загружает `pagefind/distro-meta.json` с каждого сайта — получает точный список версий по дистрибутиву
57
+ > `pagefind.filters()` при объединённых индексах возвращает значения со всех сайтов без изоляции, поэтому для dropdown версий используется manifest, а не API Pagefind
58
+ 4. Отображает фильтры по дистрибутиву и версии; при смене дистрибутива версии обновляются из manifest
59
+
47
60
  ## Включённые плагины
48
61
 
49
62
  Тема автоматически подключает:
@@ -1,10 +1,38 @@
1
1
  <script setup lang="ts">
2
2
  import { onKeyStroke, useScrollLock } from '@vueuse/core'
3
- import { useData, useRouter } from 'vitepress'
4
- import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
3
+ import { useRouter } from 'vitepress'
4
+ import { nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
5
+
6
+ // ── Dropdown ──────────────────────────────────────────────────────────────────
7
+
8
+ interface DropdownRect { top: number; left: number; width: number }
9
+
10
+ function useDropdown() {
11
+ const open = ref(false)
12
+ const rect = ref<DropdownRect | null>(null)
13
+ const triggerRef = ref<HTMLElement | null>(null)
14
+
15
+ function toggle() {
16
+ if (!open.value && triggerRef.value) {
17
+ const r = triggerRef.value.getBoundingClientRect()
18
+ rect.value = { top: r.bottom + 4, left: r.left, width: r.width }
19
+ }
20
+ open.value = !open.value
21
+ }
22
+ function close() { open.value = false }
23
+ function onOutside(e: MouseEvent) {
24
+ if (triggerRef.value && !triggerRef.value.contains(e.target as Node)) close()
25
+ }
26
+ onMounted(() => document.addEventListener('mousedown', onOutside))
27
+ onUnmounted(() => document.removeEventListener('mousedown', onOutside))
28
+
29
+ return { open, rect, triggerRef, toggle, close }
30
+ }
31
+
32
+ const distroDropdown = useDropdown()
33
+ const versionDropdown = useDropdown()
5
34
 
6
35
  const emit = defineEmits<{ (e: 'close'): void }>()
7
- const { site } = useData()
8
36
  const router = useRouter()
9
37
 
10
38
  const ALL_DISTROS = [
@@ -41,19 +69,21 @@ const indexLoading = ref(true)
41
69
  const loadError = ref(false)
42
70
  const availableDistros = ref<string[]>([])
43
71
  const availableVersions = ref<string[]>([])
72
+ // Per-distro version map from distro-meta.json — pagefind's merged-index filter API
73
+ // leaks version values across distros, so we use this static manifest instead.
74
+ const distroVersionMap = ref<Record<string, string[]>>({})
44
75
 
45
76
  async function loadIndexes() {
46
- // base = '' for index site (/), '/alt-domain' for distro sites
47
- const base = site.value.base === '/' ? '' : site.value.base.replace(/\/+$/, '')
77
+ // Always use absolute paths from origin root current site base is irrelevant
48
78
 
49
79
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
80
  let pf: any = null
51
81
  let firstIdx = -1
52
82
 
83
+ // Find first available distro to get the pagefind module
53
84
  for (let i = 0; i < ALL_DISTROS.length; i++) {
54
85
  try {
55
- // @vite-ignore
56
- pf = await import(/* @vite-ignore */ `${base}/${ALL_DISTROS[i]}/pagefind/pagefind.js`)
86
+ pf = await import(/* @vite-ignore */ `/${ALL_DISTROS[i]}/pagefind/pagefind.js`)
57
87
  firstIdx = i
58
88
  break
59
89
  } catch { /* distro not deployed yet */ }
@@ -61,23 +91,60 @@ async function loadIndexes() {
61
91
 
62
92
  if (!pf) throw new Error('no pagefind index found')
63
93
 
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 */ }
94
+ // init() must be called before mergeIndex()
95
+ await pf.init()
96
+
97
+ // Pre-check which distros have a pagefind index before calling mergeIndex.
98
+ // A failed mergeIndex() permanently corrupts pagefind's SharedWorker state,
99
+ // making subsequent filters()/search() calls fail with "Failed to load Pagefind metadata".
100
+ const allDistros = [ALL_DISTROS[firstIdx], ...ALL_DISTROS.slice(firstIdx + 1)]
101
+ const metaResults = await Promise.all(
102
+ allDistros.map(async distro => {
103
+ try {
104
+ const r = await fetch(`/${distro}/pagefind/pagefind-entry.json`, { method: 'HEAD' })
105
+ if (!r.ok) return null
106
+ // Fetch distro-meta.json for accurate per-distro filter values.
107
+ // pagefind's merged-index filter API leaks values across distros.
108
+ const meta = await fetch(`/${distro}/pagefind/distro-meta.json`)
109
+ .then(r => r.ok ? r.json() : null)
110
+ .catch(() => null) as Record<string, string[]> | null
111
+ return { distro, meta }
112
+ } catch { return null }
113
+ }),
114
+ )
115
+
116
+ const confirmed = metaResults.filter((m): m is { distro: string; meta: Record<string, string[]> | null } => m !== null)
117
+ const available = confirmed.slice(1).map(m => m.distro)
118
+
119
+ // Build per-distro version map from distro-meta.json manifests
120
+ const versionMap: Record<string, string[]> = {}
121
+ const allVersionsSet = new Set<string>()
122
+ for (const { distro, meta } of confirmed) {
123
+ const versions = sortVersions(meta?.version ?? [])
124
+ versionMap[distro] = versions
125
+ for (const v of versions) allVersionsSet.add(v)
68
126
  }
127
+ distroVersionMap.value = versionMap
69
128
 
70
- await pf.init()
129
+ // Merge only confirmed indexes in parallel
130
+ await Promise.allSettled(
131
+ available.map(distro => pf.mergeIndex(`/${distro}/pagefind`)),
132
+ )
71
133
 
72
134
  const filters = await pf.filters() as Record<string, Record<string, number>>
73
135
  availableDistros.value = Object.keys(filters.distro ?? {}).sort()
74
- availableVersions.value = Object.keys(filters.version ?? {}).sort((a, b) => {
136
+ // Use versions from manifests (accurate) rather than pagefind filters (leaky across indexes)
137
+ availableVersions.value = sortVersions([...allVersionsSet])
138
+
139
+ return pf
140
+ }
141
+
142
+ function sortVersions(versions: string[]): string[] {
143
+ return versions.sort((a, b) => {
75
144
  const [aMaj, aMin] = a.split('.').map(Number)
76
145
  const [bMaj, bMin] = b.split('.').map(Number)
77
146
  return bMaj !== aMaj ? bMaj - aMaj : bMin - aMin
78
147
  })
79
-
80
- return pf
81
148
  }
82
149
 
83
150
  // ── Filters ───────────────────────────────────────────────────────────────────
@@ -85,7 +152,20 @@ async function loadIndexes() {
85
152
  const selectedDistro = ref('')
86
153
  const selectedVersion = ref('')
87
154
 
88
- watch(selectedDistro, () => { selectedVersion.value = '' })
155
+ // When distro changes: reset version and update available versions from the manifest
156
+ watch(selectedDistro, () => {
157
+ selectedVersion.value = ''
158
+ if (!selectedDistro.value) {
159
+ // Show union of all versions across all distros
160
+ const all = new Set<string>()
161
+ for (const versions of Object.values(distroVersionMap.value)) {
162
+ for (const v of versions) all.add(v)
163
+ }
164
+ availableVersions.value = sortVersions([...all])
165
+ } else {
166
+ availableVersions.value = distroVersionMap.value[selectedDistro.value] ?? []
167
+ }
168
+ })
89
169
 
90
170
  // ── Search ────────────────────────────────────────────────────────────────────
91
171
 
@@ -160,6 +240,13 @@ onMounted(async () => {
160
240
  inputEl.value?.focus()
161
241
  try {
162
242
  pagefind.value = await loadIndexes()
243
+ // Re-trigger search if user already typed while indexes were loading
244
+ if (query.value.trim()) {
245
+ const saved = query.value
246
+ query.value = ''
247
+ await nextTick()
248
+ query.value = saved
249
+ }
163
250
  } catch {
164
251
  loadError.value = true
165
252
  } finally {
@@ -199,16 +286,41 @@ function onOverlayClick(e: MouseEvent) { if (e.target === e.currentTarget) close
199
286
 
200
287
  <!-- Filters row (shown once indexes loaded and have data) -->
201
288
  <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>
289
+
290
+ <!-- Distro dropdown -->
291
+ <div class="gs-dd" :class="{ 'is-open': distroDropdown.open.value }">
292
+ <button :ref="(el) => { distroDropdown.triggerRef.value = el as HTMLElement | null }" class="gs-dd-trigger" :class="{ 'is-active': selectedDistro }" @click="versionDropdown.close(); distroDropdown.toggle()">
293
+ <span>{{ selectedDistro ? (DISTRO_NAMES[selectedDistro] ?? selectedDistro) : 'Все дистрибутивы' }}</span>
294
+ <svg class="gs-dd-chevron" width="12" height="12" viewBox="0 0 24 24"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg>
295
+ </button>
296
+ <Teleport to="body">
297
+ <ul v-if="distroDropdown.open.value && distroDropdown.rect.value" class="gs-dd-list gs-dd-list--fixed"
298
+ :style="{ top: distroDropdown.rect.value.top + 'px', left: distroDropdown.rect.value.left + 'px', width: distroDropdown.rect.value.width + 'px' }">
299
+ <li class="gs-dd-item" :class="{ 'is-selected': selectedDistro === '' }"
300
+ @click="selectedDistro = ''; distroDropdown.close()">Все дистрибутивы</li>
301
+ <li v-for="d in availableDistros" :key="d" class="gs-dd-item" :class="{ 'is-selected': selectedDistro === d }"
302
+ @click="selectedDistro = d; distroDropdown.close()">{{ DISTRO_NAMES[d] ?? d }}</li>
303
+ </ul>
304
+ </Teleport>
305
+ </div>
306
+
307
+ <!-- Version dropdown -->
308
+ <div class="gs-dd" :class="{ 'is-open': versionDropdown.open.value }">
309
+ <button :ref="(el) => { versionDropdown.triggerRef.value = el as HTMLElement | null }" class="gs-dd-trigger" :class="{ 'is-active': selectedVersion }" @click="distroDropdown.close(); versionDropdown.toggle()">
310
+ <span>{{ selectedVersion || 'Все версии' }}</span>
311
+ <svg class="gs-dd-chevron" width="12" height="12" viewBox="0 0 24 24"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg>
312
+ </button>
313
+ <Teleport to="body">
314
+ <ul v-if="versionDropdown.open.value && versionDropdown.rect.value" class="gs-dd-list gs-dd-list--fixed"
315
+ :style="{ top: versionDropdown.rect.value.top + 'px', left: versionDropdown.rect.value.left + 'px', width: versionDropdown.rect.value.width + 'px' }">
316
+ <li class="gs-dd-item" :class="{ 'is-selected': selectedVersion === '' }"
317
+ @click="selectedVersion = ''; versionDropdown.close()">Все версии</li>
318
+ <li v-for="v in availableVersions" :key="v" class="gs-dd-item" :class="{ 'is-selected': selectedVersion === v }"
319
+ @click="selectedVersion = v; versionDropdown.close()">{{ v }}</li>
320
+ </ul>
321
+ </Teleport>
322
+ </div>
323
+
212
324
  </div>
213
325
 
214
326
  <!-- Results -->
@@ -326,26 +438,107 @@ function onOverlayClick(e: MouseEvent) { if (e.target === e.currentTarget) close
326
438
  .gs-filters {
327
439
  display: flex;
328
440
  gap: 8px;
329
- padding: 8px 16px;
441
+ padding: 10px 16px;
330
442
  border-bottom: 1px solid var(--vp-c-divider);
331
- background: var(--vp-c-default-soft);
332
443
  }
333
444
 
334
- .gs-select {
445
+ /* ── Custom dropdown ── */
446
+ .gs-dd {
447
+ position: relative;
335
448
  flex: 1;
336
449
  min-width: 0;
337
- padding: 5px 8px;
450
+ }
451
+
452
+ .gs-dd-trigger {
453
+ width: 100%;
454
+ display: flex;
455
+ align-items: center;
456
+ justify-content: space-between;
457
+ gap: 6px;
458
+ padding: 6px 10px;
338
459
  font-size: 13px;
339
- background: var(--vp-c-bg);
460
+ font-family: inherit;
461
+ background: var(--vp-c-bg-soft);
340
462
  border: 1px solid var(--vp-c-divider);
341
- border-radius: 6px;
342
- color: var(--vp-c-text-1);
463
+ border-radius: 8px;
464
+ color: var(--vp-c-text-2);
343
465
  cursor: pointer;
344
466
  outline: none;
467
+ text-align: left;
468
+ transition: border-color 0.15s, color 0.15s;
469
+ white-space: nowrap;
470
+ overflow: hidden;
471
+ }
472
+
473
+ .gs-dd-trigger span {
474
+ overflow: hidden;
475
+ text-overflow: ellipsis;
476
+ }
477
+
478
+ .gs-dd-trigger.is-active {
479
+ color: var(--vp-c-text-1);
480
+ border-color: var(--vp-c-brand-1);
481
+ }
482
+
483
+ .gs-dd-trigger:hover {
484
+ border-color: var(--vp-c-text-2);
485
+ color: var(--vp-c-text-1);
345
486
  }
346
487
 
347
- .gs-select:focus {
488
+ .gs-dd.is-open .gs-dd-trigger {
348
489
  border-color: var(--vp-c-brand-1);
490
+ box-shadow: 0 0 0 2px var(--vp-c-brand-soft);
491
+ color: var(--vp-c-text-1);
492
+ }
493
+
494
+ .gs-dd-chevron {
495
+ flex-shrink: 0;
496
+ color: var(--vp-c-text-3);
497
+ transition: transform 0.15s;
498
+ }
499
+
500
+ .gs-dd.is-open .gs-dd-chevron {
501
+ transform: rotate(180deg);
502
+ }
503
+
504
+ .gs-dd-list {
505
+ list-style: none;
506
+ margin: 0;
507
+ padding: 4px;
508
+ background: var(--vp-c-bg-elv);
509
+ border: 1px solid var(--vp-c-divider);
510
+ border-radius: 8px;
511
+ box-shadow: var(--vp-shadow-3);
512
+ max-height: 220px;
513
+ overflow-y: auto;
514
+ }
515
+
516
+ .gs-dd-list--fixed {
517
+ position: fixed;
518
+ z-index: 200;
519
+ }
520
+
521
+ .gs-dd-item {
522
+ padding: 6px 10px;
523
+ font-size: 13px;
524
+ border-radius: 6px;
525
+ cursor: pointer;
526
+ color: var(--vp-c-text-2);
527
+ transition: background 0.1s, color 0.1s;
528
+ white-space: nowrap;
529
+ overflow: hidden;
530
+ text-overflow: ellipsis;
531
+ }
532
+
533
+ .gs-dd-item:hover {
534
+ background: var(--vp-c-default-soft);
535
+ color: var(--vp-c-text-1);
536
+ }
537
+
538
+ .gs-dd-item.is-selected {
539
+ background: var(--vp-c-brand-soft);
540
+ color: var(--vp-c-brand-1);
541
+ font-weight: 500;
349
542
  }
350
543
 
351
544
  .gs-results {
@@ -456,16 +649,17 @@ function onOverlayClick(e: MouseEvent) { if (e.target === e.currentTarget) close
456
649
  padding: 8px 16px;
457
650
  border-top: 1px solid var(--vp-c-divider);
458
651
  font-size: 12px;
459
- color: var(--vp-c-text-3);
652
+ color: var(--vp-c-text-2);
460
653
  }
461
654
 
462
655
  kbd {
463
- background: var(--vp-c-default-soft);
464
- border: 1px solid var(--vp-c-divider);
656
+ background: var(--vp-c-bg-soft);
657
+ border: 1px solid var(--vp-c-text-3);
465
658
  border-radius: 4px;
466
659
  padding: 1px 5px;
467
660
  font-size: 11px;
468
661
  font-family: inherit;
662
+ color: var(--vp-c-text-1);
469
663
  }
470
664
  </style>
471
665
 
@@ -74,6 +74,7 @@ function createSharedConfig(options) {
74
74
  light: "/branding/basealt/logo-col.svg",
75
75
  alt: "ALT Linux Documentation"
76
76
  },
77
+ logoLink: "/",
77
78
  siteTitle: "ALT Linux Docs",
78
79
  outline: {
79
80
  level: [2, 3]
@@ -72,6 +72,7 @@ export function createSharedConfig(options) {
72
72
  light: "/branding/basealt/logo-col.svg",
73
73
  alt: "ALT Linux Documentation"
74
74
  },
75
+ logoLink: "/",
75
76
  siteTitle: "ALT Linux Docs",
76
77
  outline: { level: [2, 3] },
77
78
  socialLinks: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampernic/vitepress-theme-alt-docs",
3
- "version": "0.1.5",
3
+ "version": "0.1.10",
4
4
  "description": "Shared VitePress theme for ALT Linux documentation",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "author": "Ampernic",
@@ -34,9 +34,9 @@
34
34
  "markdown-it-kbd": "^1.0.0",
35
35
  "vitepress-plugin-tabs": "^0.6.0",
36
36
  "@ampernic/vitepress-plugin-alt-docs-versioning": "0.1.2",
37
+ "@ampernic/vitepress-plugin-cross-site-router": "0.1.2",
37
38
  "@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"
39
+ "@ampernic/vitepress-plugin-pagefind": "0.1.8"
40
40
  },
41
41
  "devDependencies": {
42
42
  "builtin-modules": "^3.3.0",