From 99c0b1556e5b38fb618bf37b7ac47338c236eb82 Mon Sep 17 00:00:00 2001 From: David Pivert Date: Sat, 15 Aug 2026 03:40:53 +0200 Subject: [PATCH 1/2] feat(ui): add a locale prop to LiveSearch Both /_emdash/api/search and /_emdash/api/search/suggest already accept a locale query parameter, but LiveSearch had no way to pass one. On a multilingual site every entry therefore came back once per language: a French visitor searching from a French page saw each result twice, and the second one opened its English page. The prop is forwarded to whichever endpoint the component is using. Leaving it unset keeps the current all-locales behaviour, so existing sites are unaffected. Co-Authored-By: Claude Opus 5 --- .changeset/live-search-locale.md | 5 +++++ .../content/docs/themes/creating-themes.mdx | 10 +++++++++ packages/core/src/components/LiveSearch.astro | 21 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 .changeset/live-search-locale.md diff --git a/.changeset/live-search-locale.md b/.changeset/live-search-locale.md new file mode 100644 index 0000000000..b79221961d --- /dev/null +++ b/.changeset/live-search-locale.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds a `locale` prop to `LiveSearch`, forwarded to the search and suggest endpoints. Both already accept a `locale` query parameter, but the component had no way to pass one, so a multilingual site got every entry back once per language — a French visitor searching from a French page saw each result twice, with the second one opening its English page. Pass `locale={Astro.locals.locale}` to scope results to the visitor's language; leaving it unset keeps the current all-locales behaviour. diff --git a/docs/src/content/docs/themes/creating-themes.mdx b/docs/src/content/docs/themes/creating-themes.mdx index 52b7aa408c..2450080cf3 100644 --- a/docs/src/content/docs/themes/creating-themes.mdx +++ b/docs/src/content/docs/themes/creating-themes.mdx @@ -543,6 +543,16 @@ 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, pass `locale` so results stay in the visitor's language. Without it every entry comes back once per locale, so a translated site shows each result twice: + +```astro title="src/pages/search.astro" + +``` + ## Testing Your Theme 1. Create a test project from your theme: diff --git a/packages/core/src/components/LiveSearch.astro b/packages/core/src/components/LiveSearch.astro index b1cb018f2d..9be070e710 100644 --- a/packages/core/src/components/LiveSearch.astro +++ b/packages/core/src/components/LiveSearch.astro @@ -9,6 +9,13 @@ * * ``` * + * On a multilingual site, pass `locale` to keep results in the visitor's + * language: + * + * ```astro + * + * ``` + * * Customize the result rendering with slots: * * ```astro @@ -26,6 +33,12 @@ export interface Props { placeholder?: string; /** Collections to search (defaults to all searchable collections) */ collections?: string[]; + /** + * Restrict results to one locale, e.g. `Astro.locals.locale`. Left unset, + * the search returns every locale, so a bilingual site shows each entry + * once per language. + */ + locale?: string; /** Minimum characters before searching (defaults to 2) */ minChars?: number; /** Debounce delay in milliseconds (defaults to 300) */ @@ -59,6 +72,7 @@ export interface Props { const { placeholder = "Search...", collections, + locale = "", minChars = 2, debounce = 300, limit = 10, @@ -77,6 +91,7 @@ const { const config = { collections: collections?.join(",") ?? "", + locale, minChars, debounce, limit, @@ -145,6 +160,7 @@ const config = { interface Config { collections: string; + locale: string; minChars: number; debounce: number; limit: number; @@ -164,6 +180,7 @@ const config = { private template: HTMLTemplateElement | null = null; private config: Config = { collections: "", + locale: "", minChars: 2, debounce: 300, limit: 10, @@ -364,6 +381,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, }); From 714b0f4151a73685d79b6c6a20ac27be6743711c Mon Sep 17 00:00:00 2001 From: David Pivert Date: Tue, 18 Aug 2026 10:49:15 +0200 Subject: [PATCH 2/2] feat(ui): scope LiveSearch results to the page locale by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the maintainer's call on discussion #2479: rather than an opt-in prop, `LiveSearch` now reads `Astro.currentLocale` and forwards it as the `locale` query parameter, so results match the language of the page the search runs on. The prop stays, as an override, and `locale={null}` restores the previous all-locales behaviour. Sites without Astro's `i18n` configured are unaffected: `Astro.currentLocale` is undefined there, so no parameter is sent. The changeset now leads with the behaviour change, as asked. Adds a container render test covering the three paths: no i18n config (no locale sent), explicit prop (forwarded), and `null` (no locale sent). The `Astro.currentLocale` default itself is not covered there — the test container has no i18n configuration to derive a locale from. Co-Authored-By: Claude Opus 5 --- .changeset/live-search-locale.md | 16 ++++++++- .../content/docs/themes/creating-themes.mdx | 8 +++-- packages/core/src/components/LiveSearch.astro | 20 ++++++----- .../repro/live-search-locale.render.test.ts | 33 +++++++++++++++++++ 4 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 packages/core/tests/repro/live-search-locale.render.test.ts diff --git a/.changeset/live-search-locale.md b/.changeset/live-search-locale.md index b79221961d..b33562c4b3 100644 --- a/.changeset/live-search-locale.md +++ b/.changeset/live-search-locale.md @@ -2,4 +2,18 @@ "emdash": minor --- -Adds a `locale` prop to `LiveSearch`, forwarded to the search and suggest endpoints. Both already accept a `locale` query parameter, but the component had no way to pass one, so a multilingual site got every entry back once per language — a French visitor searching from a French page saw each result twice, with the second one opening its English page. Pass `locale={Astro.locals.locale}` to scope results to the visitor's language; leaving it unset keeps the current all-locales behaviour. +`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 + + + + + +``` + +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. diff --git a/docs/src/content/docs/themes/creating-themes.mdx b/docs/src/content/docs/themes/creating-themes.mdx index 2450080cf3..7ba44fff77 100644 --- a/docs/src/content/docs/themes/creating-themes.mdx +++ b/docs/src/content/docs/themes/creating-themes.mdx @@ -543,16 +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, pass `locale` so results stay in the visitor's language. Without it every entry comes back once per locale, so a translated site shows each result twice: +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" ``` +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: diff --git a/packages/core/src/components/LiveSearch.astro b/packages/core/src/components/LiveSearch.astro index 9be070e710..dd5885c3e2 100644 --- a/packages/core/src/components/LiveSearch.astro +++ b/packages/core/src/components/LiveSearch.astro @@ -9,11 +9,13 @@ * * ``` * - * On a multilingual site, pass `locale` to keep results in the visitor's - * language: + * 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 - * + * * ``` * * Customize the result rendering with slots: @@ -34,11 +36,11 @@ export interface Props { /** Collections to search (defaults to all searchable collections) */ collections?: string[]; /** - * Restrict results to one locale, e.g. `Astro.locals.locale`. Left unset, - * the search returns every locale, so a bilingual site shows each entry - * once per language. + * 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; + locale?: string | null; /** Minimum characters before searching (defaults to 2) */ minChars?: number; /** Debounce delay in milliseconds (defaults to 300) */ @@ -72,7 +74,7 @@ export interface Props { const { placeholder = "Search...", collections, - locale = "", + locale = Astro.currentLocale ?? null, minChars = 2, debounce = 300, limit = 10, @@ -91,7 +93,7 @@ const { const config = { collections: collections?.join(",") ?? "", - locale, + locale: locale ?? "", minChars, debounce, limit, diff --git a/packages/core/tests/repro/live-search-locale.render.test.ts b/packages/core/tests/repro/live-search-locale.render.test.ts new file mode 100644 index 0000000000..9a0cfc6922 --- /dev/null +++ b/packages/core/tests/repro/live-search-locale.render.test.ts @@ -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) { + 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(""", '"').replaceAll(""", '"'); + 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(""); + }); +});