Skip to content
Open
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
19 changes: 19 additions & 0 deletions .changeset/live-search-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"emdash": minor
---

`LiveSearch` now scopes its results to the locale of the page it is used on.

**Behaviour change.** The component reads `Astro.currentLocale` and forwards it as the `locale` query parameter to `/_emdash/api/search` and `/_emdash/api/search/suggest`, which both already filtered on it. Until now the component never sent one, so a translated site got every entry back once per language — a French visitor searching from a French page saw each result twice, the second one opening its English page. Sites with `i18n` configured that relied on searching across every locale will see fewer results than before.

Two ways to opt out: pass an explicit `locale` to search a different one, or `locale={null}` to search across every locale, which is the previous behaviour.

```astro
<!-- results in the page's own locale (new default) -->
<LiveSearch collections={["posts", "pages"]} />

<!-- every locale, as before -->
<LiveSearch collections={["posts", "pages"]} locale={null} />
```

Sites without Astro's `i18n` configured are unaffected: `Astro.currentLocale` is `undefined` there, so no `locale` parameter is sent and the search still spans everything.
14 changes: 14 additions & 0 deletions docs/src/content/docs/themes/creating-themes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,20 @@ import Base from "../layouts/Base.astro";

`LiveSearch` provides debounced instant search with prefix matching, Porter stemming, and highlighted result snippets. Search must be enabled per-collection in the admin UI (Content Types > Edit > Features > Search).

On a multilingual site, results are scoped to the locale of the page the search runs on. The component reads `Astro.currentLocale`, which Astro derives from the URL, and forwards it to the search endpoint — so a translated site returns each entry once, in the visitor's language.

Pass `locale` explicitly to search a different one, or `null` to search across every locale:

```astro title="src/pages/search.astro"
<LiveSearch
placeholder="Search posts and pages..."
collections={["posts", "pages"]}
locale={null}
/>
```

Sites without Astro's [`i18n` configuration](https://docs.astro.build/en/guides/internationalization/) are unaffected: `Astro.currentLocale` is `undefined` there, so no locale is sent and the search spans every entry.

## Testing Your Theme

1. Create a test project from your theme:
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/components/LiveSearch.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
* <LiveSearch placeholder="Search articles..." collections={["posts", "pages"]} />
* ```
*
* Results are scoped to the page's locale, taken from `Astro.currentLocale`,
* so a translated site returns each entry once, in the language of the page
* the visitor searched from. Pass `locale` to override that, or `null` to
* search across every locale:
*
* ```astro
* <LiveSearch collections={["posts", "pages"]} locale={null} />
* ```
*
* Customize the result rendering with slots:
*
* ```astro
Expand All @@ -26,6 +35,12 @@ export interface Props {
placeholder?: string;
/** Collections to search (defaults to all searchable collections) */
collections?: string[];
/**
* Locale to restrict results to. Defaults to `Astro.currentLocale`, which
* Astro derives from the URL, so results match the page the search was
* made from. Pass `null` to search across every locale.
*/
locale?: string | null;
/** Minimum characters before searching (defaults to 2) */
minChars?: number;
/** Debounce delay in milliseconds (defaults to 300) */
Expand Down Expand Up @@ -59,6 +74,7 @@ export interface Props {
const {
placeholder = "Search...",
collections,
locale = Astro.currentLocale ?? null,
minChars = 2,
debounce = 300,
limit = 10,
Expand All @@ -77,6 +93,7 @@ const {

const config = {
collections: collections?.join(",") ?? "",
locale: locale ?? "",
minChars,
debounce,
limit,
Expand Down Expand Up @@ -145,6 +162,7 @@ const config = {

interface Config {
collections: string;
locale: string;
minChars: number;
debounce: number;
limit: number;
Expand All @@ -164,6 +182,7 @@ const config = {
private template: HTMLTemplateElement | null = null;
private config: Config = {
collections: "",
locale: "",
minChars: 2,
debounce: 300,
limit: 10,
Expand Down Expand Up @@ -364,6 +383,10 @@ const config = {
params.set("collections", this.config.collections);
}

if (this.config.locale) {
params.set("locale", this.config.locale);
}

const response = await fetch(`${endpoint}?${params}`, {
signal: this.abortController.signal,
});
Expand Down
33 changes: 33 additions & 0 deletions packages/core/tests/repro/live-search-locale.render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { experimental_AstroContainer as AstroContainer } from "astro/container";
import { describe, expect, it } from "vitest";

import LiveSearch from "../../src/components/LiveSearch.astro";

/**
* The serialized config the client script reads. `locale` is sent as a query
* parameter only when it is a non-empty string.
*/
async function renderConfig(props: Record<string, unknown>) {
const container = await AstroContainer.create();
const html = await container.renderToString(LiveSearch, { props, locals: {} });
const match = html.match(/data-config="([^"]*)"/);
if (!match) throw new Error("no data-config on the rendered component");
const json = match[1].replaceAll("&#34;", '"').replaceAll("&quot;", '"');
return JSON.parse(json) as { locale: string };
}

describe("LiveSearch locale", () => {
it("sends no locale when the site has no i18n configuration", async () => {
// The container has no `i18n` config, so `Astro.currentLocale` is
// undefined — the same situation as a single-language site.
expect((await renderConfig({})).locale).toBe("");
});

it("forwards an explicit locale", async () => {
expect((await renderConfig({ locale: "fr" })).locale).toBe("fr");
});

it("sends no locale when passed null, so results span every locale", async () => {
expect((await renderConfig({ locale: null })).locale).toBe("");
});
});
Loading