Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 18 additions & 32 deletions app/composables/useDuxtSearch.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 ?? []);

Expand Down Expand Up @@ -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<string, (typeof searchable)[number]>();

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 ||
'/';

Expand Down
105 changes: 105 additions & 0 deletions app/utils/search-sources.ts
Original file line number Diff line number Diff line change
@@ -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<string, Map<string, VersionGroup>>();
const artefactOrder = new Map<string, number>();

for (const [index, entry] of sources.entries()) {
const key = artefactKey(entry);
const versions = artefacts.get(key) ?? new Map<string, VersionGroup>();
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] : [];
});
}
108 changes: 108 additions & 0 deletions tests/search-sources.test.ts
Original file line number Diff line number Diff line change
@@ -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<Source> = {}
) =>
({
...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<Source> = {}) =>
({ ...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']);
});
});