@kirchdev/duxt 0.1.1-dev.15-83cfebe → 0.1.1-dev.17-509021b

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.
@@ -1,3 +1,5 @@
1
+ import type { DuxtResolvedSource } from '../../sources-resolve';
2
+ import { searchSources } from '../utils/search-scope';
1
3
  import type { DuxtSearchSection } from '@duxt/composables/useFuzzySearch';
2
4
 
3
5
  /** A hit, with the source it came out of. */
@@ -22,18 +24,19 @@ export interface DuxtSearchHit extends DuxtSearchSection {
22
24
  * The source being read goes first in each round, because a reader searching
23
25
  * inside a project usually means that project.
24
26
  *
25
- * ONE VERSION PER REPOSITORY. Searching every version returns each page as many
27
+ * ONE VERSION PER ARTEFACT. Searching every version returns each page as many
26
28
  * times as there are versions, which buries the answer under its own history.
27
- * So each repository contributes the version the reader is in, or its default.
29
+ * Each docs tree and generated declaration contributes its current or default edition.
28
30
  */
29
31
  export function useDuxtSearch() {
30
32
  const duxt = useDuxtConfig();
31
33
  const { source } = useDuxtCollection();
34
+ const { locale, fallbackLocale } = useI18n();
32
35
 
33
36
  const sources = computed(() => duxt.resolvedSources ?? []);
34
37
 
35
38
  /**
36
- * The collections to search, one per repository.
39
+ * The available collections; only selected editions are initialized.
37
40
  *
38
41
  * Fixed at setup, deliberately: each one needs its own `useSearchCollection`,
39
42
  * and a composable list that grows with the route is not a thing. The
@@ -79,38 +82,54 @@ export function useDuxtSearch() {
79
82
  }).search
80
83
  }));
81
84
 
82
- /** One entry per repository: the version being read, else that repo's default. */
83
- const active = computed(() => {
84
- const current = source.value;
85
- const byRepo = new Map<string, (typeof searchable)[number]>();
86
-
87
- for (const entry of searchable) {
88
- const key = entry.repo ?? '';
89
- const chosen = byRepo.get(key);
90
-
91
- if (entry.collection === current?.collection) {
92
- byRepo.set(key, entry);
93
- continue;
94
- }
95
-
96
- if (chosen?.collection === current?.collection) continue;
97
- if (!chosen || (entry.isDefault && !chosen.isDefault)) {
98
- byRepo.set(key, entry);
99
- }
85
+ /** Selected editions, ordered by artefact and then preferred language. */
86
+ const active = computed(() =>
87
+ searchSources(
88
+ searchable,
89
+ source.value,
90
+ locale.value,
91
+ fallbackLocale.value as string | string[] | undefined
92
+ )
93
+ );
94
+
95
+ const pagePaths = new Map<string, Promise<Set<string>>>();
96
+ function paths(collection: string) {
97
+ let pending = pagePaths.get(collection);
98
+ if (!pending) {
99
+ pending = queryCollection(collection as DuxtCollectionArg)
100
+ .select('path')
101
+ .all()
102
+ .then((pages) => new Set(pages.map((page) => page.path)))
103
+ .catch((error) => {
104
+ pagePaths.delete(collection);
105
+ throw error;
106
+ });
107
+ pagePaths.set(collection, pending);
100
108
  }
109
+ return pending;
110
+ }
101
111
 
102
- // The source being read leads; the rest keep the config's own order.
103
- return [...byRepo.values()].sort((a, b) =>
104
- a.collection === current?.collection
105
- ? -1
106
- : b.collection === current?.collection
107
- ? 1
108
- : 0
112
+ // A fallback may answer only for a PAGE absent from every better language,
113
+ // even when a heading or the search term exists only in the original.
114
+ async function visiblePages(wanted: DuxtResolvedSource[]) {
115
+ return Promise.all(
116
+ wanted.map(async (entry, index) => {
117
+ const preferred = wanted
118
+ .slice(0, index)
119
+ .filter((other) => other.prefix === entry.prefix);
120
+ return new Set(
121
+ (
122
+ await Promise.all(preferred.map((other) => paths(other.collection)))
123
+ ).flatMap((pages) => [...pages])
124
+ );
125
+ })
109
126
  );
110
- });
127
+ }
111
128
 
112
129
  /** Only worth a badge when there is more than one thing to tell apart. */
113
- const labelled = computed(() => active.value.length > 1);
130
+ const labelled = computed(
131
+ () => new Set(active.value.map((entry) => entry.prefix)).size > 1
132
+ );
114
133
 
115
134
  const labelOf = (entry: (typeof searchable)[number]) =>
116
135
  [entry.repo, entry.version].filter(Boolean).join(' ') ||
@@ -119,8 +138,8 @@ export function useDuxtSearch() {
119
138
 
120
139
  async function init() {
121
140
  await Promise.all(
122
- searches
123
- .filter((search) => active.value.includes(search.entry))
141
+ active.value
142
+ .map((entry) => searches.find((search) => search.entry === entry)!)
124
143
  .filter((search) => search.status.value === 'idle')
125
144
  .map((search) => search.init())
126
145
  );
@@ -153,17 +172,41 @@ export function useDuxtSearch() {
153
172
  term: string,
154
173
  limit = 20
155
174
  ): Promise<{ hits: DuxtSearchHit[]; approximate: boolean }> {
156
- if (!term.trim()) return { hits: [], approximate: false };
175
+ if (!term.trim() || limit <= 0) return { hits: [], approximate: false };
157
176
 
158
177
  const wanted = active.value;
178
+ const hidden = await visiblePages(wanted);
179
+ async function visible(
180
+ query: (requested: number) => Promise<DuxtSearchSection[]>,
181
+ index: number
182
+ ) {
183
+ let requested = limit;
184
+ while (true) {
185
+ const hits = await query(requested);
186
+ const shown = hits.filter(
187
+ (hit) => !hidden[index]!.has(hit.id.split('#')[0]!)
188
+ );
189
+ if (
190
+ shown.length >= limit ||
191
+ hits.length < requested ||
192
+ !hidden[index]!.size
193
+ )
194
+ return shown.slice(0, limit);
195
+ requested *= 2;
196
+ }
197
+ }
159
198
 
160
199
  const lists = await Promise.all(
161
- searches
162
- .filter((entry) => wanted.includes(entry.entry))
163
- .map(async (entry) => {
164
- const hits = (await entry.search(term, {
165
- limit
166
- })) as DuxtSearchSection[];
200
+ wanted
201
+ .map((source) => searches.find((entry) => entry.entry === source)!)
202
+ .map(async (entry, index) => {
203
+ const hits = await visible(
204
+ async (requested) =>
205
+ (await entry.search(term, {
206
+ limit: requested
207
+ })) as DuxtSearchSection[],
208
+ index
209
+ );
167
210
 
168
211
  return hits.map((hit) => ({
169
212
  ...hit,
@@ -184,10 +227,13 @@ export function useDuxtSearch() {
184
227
  // leaves the reader with an empty box. Fuse gets a second look, over the
185
228
  // same sources and merged the same way.
186
229
  const fuzzy = await Promise.all(
187
- approximate
188
- .filter((entry) => wanted.includes(entry.entry))
189
- .map(async (entry) => {
190
- const hits = await entry.search(term, limit);
230
+ wanted
231
+ .map((source) => approximate.find((entry) => entry.entry === source)!)
232
+ .map(async (entry, index) => {
233
+ const hits = await visible(
234
+ (requested) => entry.search(term, requested),
235
+ index
236
+ );
191
237
 
192
238
  return hits.map((hit) => ({
193
239
  ...hit,
@@ -0,0 +1,41 @@
1
+ import {
2
+ sourcesForRoute,
3
+ type DuxtResolvedSource
4
+ } from '../../sources-resolve';
5
+
6
+ /** Select the editions participating in site-wide search. */
7
+ export function searchSources(
8
+ sources: DuxtResolvedSource[],
9
+ current: DuxtResolvedSource | undefined,
10
+ locale?: string,
11
+ fallbackLocale?: string | string[]
12
+ ): DuxtResolvedSource[] {
13
+ const byArtefact = new Map<string, DuxtResolvedSource>();
14
+ for (const entry of sources) {
15
+ const key = JSON.stringify([entry.repo, entry.generated?.declaration]);
16
+ const chosen = byArtefact.get(key);
17
+ if (entry.collection === current?.collection) {
18
+ byArtefact.set(key, entry);
19
+ continue;
20
+ }
21
+ if (chosen?.collection === current?.collection) continue;
22
+ if (!chosen || (entry.isDefault && !chosen.isDefault))
23
+ byArtefact.set(key, entry);
24
+ }
25
+ return [...byArtefact.values()]
26
+ .sort((a, b) =>
27
+ a.collection === current?.collection
28
+ ? -1
29
+ : b.collection === current?.collection
30
+ ? 1
31
+ : 0
32
+ )
33
+ .flatMap((edition) =>
34
+ sourcesForRoute(
35
+ edition.prefix,
36
+ locale ?? current?.locale,
37
+ sources.filter((entry) => entry.prefix === edition.prefix),
38
+ fallbackLocale
39
+ )
40
+ );
41
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "@kirchdev/duxt",
4
- "version": "0.1.1-dev.15-83cfebe",
4
+ "version": "0.1.1-dev.17-509021b",
5
5
  "description": "Nuxt documentation layer on Content v3 — multi-repo, versioned docs from one compact source list.",
6
6
  "keywords": [
7
7
  "content-v3",
package/sources-git.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import type { DuxtSource } from './sources-resolve';
3
- import { isLatestRef, newestTag, repoUrl } from './sources-resolve';
3
+ import {
4
+ expandSources,
5
+ isLatestRef,
6
+ newestTag,
7
+ repoUrl
8
+ } from './sources-resolve';
4
9
 
5
10
  /**
6
11
  * Resolve the `latest` shorthand into the tag it means.
@@ -81,6 +86,8 @@ function tagsOf(repo: string | undefined): string[] {
81
86
  * resolver itself stays pure and testable — it never learns that git exists.
82
87
  */
83
88
  export function resolveLatestRefs(sources: DuxtSource[]): DuxtSource[] {
89
+ // Reject local refs before a missing tag can hide the configuration error.
90
+ expandSources(sources);
84
91
  return sources.map((source) => {
85
92
  if (!source.refs?.some(isLatestRef)) return source;
86
93
 
@@ -12,10 +12,10 @@ import type {
12
12
  export interface DuxtSource {
13
13
  /** Folder holding the Markdown, relative to the repository root. */
14
14
  path?: string;
15
- /** `owner/name` or a full git URL. Omitted means this repository. */
15
+ /** `owner/name` or a full git URL. Omitted reads the local checkout. */
16
16
  repo?: string;
17
17
  /**
18
- * Refs to publish as versions. Omitted means the current checkout.
18
+ * Refs to publish as versions. Requires an explicit source or locale repo.
19
19
  *
20
20
  * A bare string is a branch. A tag has to say so — git keeps the two in
21
21
  * separate namespaces, and asking for a tag under refs/heads fails the
@@ -166,7 +166,7 @@ export type DuxtSourceLocale =
166
166
  path?: string;
167
167
  /** A repository of its own — `owner/name` or a git URL. */
168
168
  repo?: string;
169
- /** A ref of its own, when the translation is versioned separately. */
169
+ /** A ref of its own; requires an explicit source or locale repo. */
170
170
  ref?: DuxtRef;
171
171
  };
172
172
 
@@ -488,12 +488,16 @@ export function expandSources(
488
488
 
489
489
  return sources.flatMap((source) =>
490
490
  (source.refs?.length ? source.refs : [undefined]).flatMap((ref) =>
491
- localesOf(source, ref).map((locale) => ({
492
- source,
493
- ref,
494
- locale,
495
- effective: localeEntry(source, locale, defaultLocale)
496
- }))
491
+ localesOf(source, ref).map((locale) => {
492
+ const effective = localeEntry(source, locale, defaultLocale);
493
+ if (!effective.repo && (effective.ref ?? ref)) {
494
+ throw new Error(
495
+ `duxt: source "${source.path ?? 'docs'}" reads the local checkout and cannot select a ref. ` +
496
+ 'Set an explicit repo to publish Git refs, or use version folders without refs.'
497
+ );
498
+ }
499
+ return { source, ref, locale, effective };
500
+ })
497
501
  )
498
502
  );
499
503
  }