From 1e67bd9bd649c623f6ab14de4547f58e285839aa Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Thu, 10 Sep 2026 10:02:29 +0800 Subject: [PATCH] fix(search): include generated collections --- app/composables/useDuxtSearch.ts | 50 ++++++-------- app/utils/search-sources.ts | 105 ++++++++++++++++++++++++++++++ tests/search-sources.test.ts | 108 +++++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 32 deletions(-) create mode 100644 app/utils/search-sources.ts create mode 100644 tests/search-sources.test.ts diff --git a/app/composables/useDuxtSearch.ts b/app/composables/useDuxtSearch.ts index 2d07a7fc..58319df5 100644 --- a/app/composables/useDuxtSearch.ts +++ b/app/composables/useDuxtSearch.ts @@ -1,4 +1,5 @@ import type { DuxtSearchSection } from '@duxt/composables/useFuzzySearch'; +import { selectSearchSources } from '../utils/search-sources'; /** A hit, with the source it came out of. */ export interface DuxtSearchHit extends DuxtSearchSection { @@ -22,13 +23,16 @@ export interface DuxtSearchHit extends DuxtSearchSection { * The source being read goes first in each round, because a reader searching * inside a project usually means that project. * - * ONE VERSION PER REPOSITORY. Searching every version returns each page as many + * ONE VERSION PER ARTEFACT. Searching every version returns each page as many * times as there are versions, which buries the answer under its own history. - * So each repository contributes the version the reader is in, or its default. + * Documentation, each generated declaration and a global changelog are + * separate artefacts, so each contributes the version the reader is in, or its + * default. */ export function useDuxtSearch() { const duxt = useDuxtConfig(); const { source } = useDuxtCollection(); + const { locale, fallbackLocale } = useI18n(); const sources = computed(() => duxt.resolvedSources ?? []); @@ -79,41 +83,23 @@ export function useDuxtSearch() { }).search })); - /** One entry per repository: the version being read, else that repo's default. */ - const active = computed(() => { - const current = source.value; - const byRepo = new Map(); - - for (const entry of searchable) { - const key = entry.repo ?? ''; - const chosen = byRepo.get(key); - - if (entry.collection === current?.collection) { - byRepo.set(key, entry); - continue; - } - - if (chosen?.collection === current?.collection) continue; - if (!chosen || (entry.isDefault && !chosen.isDefault)) { - byRepo.set(key, entry); - } - } - - // The source being read leads; the rest keep the config's own order. - return [...byRepo.values()].sort((a, b) => - a.collection === current?.collection - ? -1 - : b.collection === current?.collection - ? 1 - : 0 - ); - }); + /** One version per artefact, with the current artefact and locale first. */ + const active = computed(() => + selectSearchSources( + searchable, + source.value, + locale.value, + fallbackLocale.value as string | string[] | undefined + ) + ); /** Only worth a badge when there is more than one thing to tell apart. */ const labelled = computed(() => active.value.length > 1); const labelOf = (entry: (typeof searchable)[number]) => - [entry.repo, entry.version].filter(Boolean).join(' ') || + [entry.repo, entry.generated?.label, entry.version] + .filter(Boolean) + .join(' ') || entry.prefix || '/'; diff --git a/app/utils/search-sources.ts b/app/utils/search-sources.ts new file mode 100644 index 00000000..fa9223f6 --- /dev/null +++ b/app/utils/search-sources.ts @@ -0,0 +1,105 @@ +import { localeChain } from '../../sources-resolve'; + +type SearchSource = DuxtResolvedSource; +type FallbackLocale = string | string[] | undefined; + +interface VersionGroup { + entries: SearchSource[]; +} + +const artefactKey = (entry: SearchSource) => + [ + entry.repo ?? '', + entry.generated + ? `generated:${entry.generated.declaration}` + : 'documentation' + ].join('\u0000'); + +const versionKey = (entry: SearchSource) => + entry.generated?.versioning === 'global' + ? 'global' + : (entry.version ?? 'unversioned'); + +function bestLocale( + entries: SearchSource[], + locale: string | undefined, + fallbackLocale: FallbackLocale +) { + const chain = localeChain( + locale, + entries.map((entry) => entry.locale), + fallbackLocale + ); + + for (const code of chain) { + const match = + code === undefined + ? entries.find((entry) => entry.isDefaultLocale) + : entries.find((entry) => entry.locale === code); + if (match) return match; + } + + return entries[0]; +} + +/** + * Pick the collections that represent the searchable site. + * + * A repository can publish documentation, several generated declarations and + * a version-neutral changelog. Those are separate artefacts even when their + * pages share a repository, so grouping by `repo` silently removed all but one + * of them. Versions are grouped inside each artefact, then one best-available + * locale is chosen so a translated page is not returned twice through its + * fallback collection. + */ +export function selectSearchSources( + sources: SearchSource[], + current: SearchSource | undefined, + locale: string | undefined, + fallbackLocale: FallbackLocale +): SearchSource[] { + const artefacts = new Map>(); + const artefactOrder = new Map(); + + for (const [index, entry] of sources.entries()) { + const key = artefactKey(entry); + const versions = artefacts.get(key) ?? new Map(); + const version = versionKey(entry); + const group = versions.get(version) ?? { entries: [] }; + + group.entries.push(entry); + versions.set(version, group); + artefacts.set(key, versions); + if (!artefactOrder.has(key)) artefactOrder.set(key, index); + } + + const currentArtefact = current ? artefactKey(current) : undefined; + const orderedArtefacts = [...artefacts.keys()].sort((left, right) => { + if (left === currentArtefact) return -1; + if (right === currentArtefact) return 1; + return artefactOrder.get(left)! - artefactOrder.get(right)!; + }); + + return orderedArtefacts.flatMap((key) => { + const versions = artefacts.get(key)!; + const currentVersion = + key === currentArtefact && current + ? versions.get(versionKey(current)) + : undefined; + const selectedVersion = + currentVersion ?? + [...versions.values()].find((group) => + group.entries.some((entry) => entry.isDefault) + ) ?? + [...versions.values()][0]; + + if (!selectedVersion) return []; + + const selected = + key === currentArtefact && current + ? current + : bestLocale(selectedVersion.entries, locale, fallbackLocale); + + return selected ? [selected] : []; + }); +} diff --git a/tests/search-sources.test.ts b/tests/search-sources.test.ts new file mode 100644 index 00000000..4a118166 --- /dev/null +++ b/tests/search-sources.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { selectSearchSources } from '../app/utils/search-sources'; + +type Source = DuxtResolvedSource; + +const base = { + prefix: '', + repo: 'acme/site', + isDefault: true, + isDefaultLocale: true, + path: 'docs', + status: 'current', + history: false +} as Source; + +const generated = ( + collection: string, + declaration: number, + over: Partial = {} +) => + ({ + ...base, + collection, + generated: { + type: declaration === 1 ? 'openapi' : 'changelog', + label: declaration === 1 ? 'API' : 'Releases', + slug: declaration === 1 ? 'api' : 'releases', + declaration, + navigation: 'sections', + versioning: declaration === 0 ? 'global' : 'per-version', + localisation: 'original', + remote: false + }, + ...over + }) as Source; + +const docs = (collection: string, over: Partial = {}) => + ({ ...base, collection, ...over }) as Source; + +describe('selectSearchSources', () => { + it('keeps docs, every generated declaration, and one version of each', () => { + const sources = [ + docs('docs'), + docs('docs_v1', { version: 'v1', isDefault: false }), + generated('docs_releases', 0), + generated('docs_api', 1), + generated('docs_api_v1', 1, { + version: 'v1', + isDefault: false, + prefix: '/v1/api' + }) + ]; + + expect( + selectSearchSources(sources, sources[0], undefined, undefined).map( + (source) => source.collection + ) + ).toEqual(['docs', 'docs_releases', 'docs_api']); + }); + + it('keeps the current API edition while other artefacts use defaults', () => { + const sources = [ + docs('docs'), + docs('docs_v1', { version: 'v1', isDefault: false }), + generated('docs_releases', 0), + generated('docs_api', 1), + generated('docs_api_v1', 1, { + version: 'v1', + isDefault: false, + prefix: '/v1/api' + }) + ]; + + expect( + selectSearchSources(sources, sources[4], undefined, undefined).map( + (source) => source.collection + ) + ).toEqual(['docs_api_v1', 'docs', 'docs_releases']); + }); + + it('chooses the best language once and falls back for untranslated artefacts', () => { + const sources = [ + docs('docs', { locale: 'en', isDefaultLocale: true }), + docs('docs_de', { + locale: 'de', + isDefaultLocale: false, + collection: 'docs_de' + }), + generated('docs_releases', 0), + generated('docs_api', 1, { + locale: 'de', + isDefaultLocale: false, + collection: 'docs_de_api' + }), + generated('docs_api_en', 1, { + collection: 'docs_api_en', + locale: 'en', + isDefaultLocale: true + }) + ]; + + expect( + selectSearchSources(sources, sources[0], 'de', 'en').map( + (source) => source.collection + ) + ).toEqual(['docs', 'docs_releases', 'docs_de_api']); + }); +});