@jxsuite/search 0.1.0 → 0.2.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Shenk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,3 +1,142 @@
1
+ // ../../packages/schema/src/locale.ts
2
+ var RTL_SCRIPTS = new Set([
3
+ "Adlm",
4
+ "Arab",
5
+ "Aran",
6
+ "Armi",
7
+ "Avst",
8
+ "Cprt",
9
+ "Egyd",
10
+ "Egyh",
11
+ "Elym",
12
+ "Gara",
13
+ "Hatr",
14
+ "Hebr",
15
+ "Hluw",
16
+ "Hung",
17
+ "Khar",
18
+ "Lydi",
19
+ "Mand",
20
+ "Mani",
21
+ "Mend",
22
+ "Merc",
23
+ "Mero",
24
+ "Narb",
25
+ "Nbat",
26
+ "Nkoo",
27
+ "Orkh",
28
+ "Palm",
29
+ "Phli",
30
+ "Phlp",
31
+ "Phnx",
32
+ "Prti",
33
+ "Rohg",
34
+ "Samr",
35
+ "Sarb",
36
+ "Sogd",
37
+ "Sogo",
38
+ "Syrc",
39
+ "Thaa",
40
+ "Todr",
41
+ "Yezi"
42
+ ]);
43
+ function canonicalizeLocale(tag) {
44
+ if (typeof tag !== "string" || tag.trim() === "") {
45
+ return null;
46
+ }
47
+ try {
48
+ return new Intl.Locale(tag.trim()).toString();
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ var DEFAULT_ROUTING = "prefix-except-default";
54
+ function resolveI18n(projectConfig) {
55
+ const raw = projectConfig.i18n;
56
+ const errors = [];
57
+ if (!raw || typeof raw !== "object") {
58
+ return { errors, i18n: null };
59
+ }
60
+ const declared = Array.isArray(raw.locales) ? raw.locales : [];
61
+ const locales = [];
62
+ for (const tag of declared) {
63
+ const canonical = canonicalizeLocale(tag);
64
+ if (canonical === null) {
65
+ errors.push(`i18n.locales: "${String(tag)}" is not a well-formed BCP 47 language tag.`);
66
+ continue;
67
+ }
68
+ if (!locales.includes(canonical)) {
69
+ locales.push(canonical);
70
+ }
71
+ }
72
+ const defaultLocale = canonicalizeLocale(raw.defaultLocale) ?? locales[0] ?? null;
73
+ if (raw.defaultLocale !== undefined && canonicalizeLocale(raw.defaultLocale) === null) {
74
+ errors.push(`i18n.defaultLocale: "${String(raw.defaultLocale)}" is not a well-formed BCP 47 language tag.`);
75
+ }
76
+ if (defaultLocale === null) {
77
+ errors.push("i18n is declared with no usable locale — set `defaultLocale` or `locales`.");
78
+ return { errors, i18n: null };
79
+ }
80
+ if (!locales.includes(defaultLocale)) {
81
+ locales.unshift(defaultLocale);
82
+ }
83
+ const routing = raw.routing === "prefix-always" || raw.routing === "prefix-except-default" ? raw.routing : DEFAULT_ROUTING;
84
+ return { errors, i18n: { defaultLocale, locales, routing } };
85
+ }
86
+ function localeUrlPrefix(locale, i18n) {
87
+ if (i18n === null) {
88
+ return "";
89
+ }
90
+ const canonical = canonicalizeLocale(locale);
91
+ if (canonical === null || !i18n.locales.includes(canonical)) {
92
+ return "";
93
+ }
94
+ if (canonical === i18n.defaultLocale && i18n.routing === "prefix-except-default") {
95
+ return "";
96
+ }
97
+ return `/${canonical.toLowerCase()}`;
98
+ }
99
+
100
+ // ../../packages/schema/src/guards.ts
101
+ function isJsonObject(value) {
102
+ return typeof value === "object" && value !== null && !Array.isArray(value);
103
+ }
104
+ var SCHEMA_KEYWORDS = new Set([
105
+ "type",
106
+ "enum",
107
+ "minimum",
108
+ "maximum",
109
+ "minLength",
110
+ "maxLength",
111
+ "pattern",
112
+ "format",
113
+ "items",
114
+ "properties",
115
+ "required",
116
+ "description",
117
+ "title",
118
+ "$comment"
119
+ ]);
120
+ function isTagExpression(tagName) {
121
+ return isJsonObject(tagName) && isJsonObject(tagName.$expression);
122
+ }
123
+ function tagNameCandidates(tagName) {
124
+ if (typeof tagName === "string") {
125
+ return [tagName];
126
+ }
127
+ if (!isTagExpression(tagName)) {
128
+ return [];
129
+ }
130
+ const expression = tagName.$expression;
131
+ if (expression.operator === "?:") {
132
+ return [...new Set([expression.value, expression.initial])];
133
+ }
134
+ return [...new Set([...Object.values(expression.cases), expression.default])];
135
+ }
136
+ function displayTagName(tagName) {
137
+ return typeof tagName === "string" ? tagName : tagNameCandidates(tagName).join("|");
138
+ }
139
+
1
140
  // src/shared.ts
2
141
  var DEFAULT_OUTPUT = "/search-index.json";
3
142
  var DEFAULT_FIELDS = ["title", "heading", "text"];
@@ -85,7 +224,7 @@ function headingOf(node) {
85
224
  if (typeof node !== "object" || node === null || !node.tagName) {
86
225
  return null;
87
226
  }
88
- const match = HEADING_TAG.exec(node.tagName);
227
+ const match = HEADING_TAG.exec(displayTagName(node.tagName));
89
228
  if (!match) {
90
229
  return null;
91
230
  }
@@ -105,6 +244,7 @@ var SearchIndex = {
105
244
  const config = normalizeSearchConfig(sectionValue);
106
245
  const content = ctx.sections?.content;
107
246
  const trailingSlash = ctx.projectConfig?.build?.trailingSlash ?? "always";
247
+ const { i18n } = resolveI18n(ctx.projectConfig ?? {});
108
248
  const documents = [];
109
249
  const fields = new Set;
110
250
  const boost = {};
@@ -120,31 +260,35 @@ var SearchIndex = {
120
260
  Object.assign(boost, collection.boost);
121
261
  for (const entry of entries) {
122
262
  const data = entry.data ?? {};
123
- const url = entryUrl(collection.basePath, entry.id, trailingSlash);
263
+ const locale = i18n === null ? undefined : canonicalizeLocale(entry._meta?.locale) ?? undefined;
264
+ const url = entryUrl(`${localeUrlPrefix(locale, i18n)}${collection.basePath}`, entry.id, trailingSlash);
124
265
  const title = typeof data.title === "string" ? data.title : entry.id;
125
266
  const description = typeof data.description === "string" ? data.description : "";
126
267
  const children = entry.$children;
268
+ const documentId = locale === undefined ? `${name}:${entry.id}` : `${name}:${locale}:${entry.id}`;
127
269
  documents.push({
128
- id: `${name}:${entry.id}`,
270
+ id: documentId,
129
271
  collection: name,
130
272
  slug: entry.id,
131
273
  url,
132
274
  title,
133
275
  description,
134
276
  heading: "",
135
- text: jxTreeToText(children)
277
+ text: jxTreeToText(children),
278
+ ...locale === undefined ? {} : { locale }
136
279
  });
137
280
  if (collection.sections) {
138
281
  for (const section of splitSections(children, collection.sectionDepth)) {
139
282
  documents.push({
140
- id: `${name}:${entry.id}#${section.anchor}`,
283
+ id: `${documentId}#${section.anchor}`,
141
284
  collection: name,
142
285
  slug: entry.id,
143
286
  url: `${url}#${section.anchor}`,
144
287
  title,
145
288
  description,
146
289
  heading: section.heading,
147
- text: section.text
290
+ text: section.text,
291
+ ...locale === undefined ? {} : { locale }
148
292
  });
149
293
  }
150
294
  }
@@ -12,6 +12,7 @@ function sidecarAssetPath(specifier) {
12
12
  base = base.replace(/^\.\//, "").replace(/\.(js|ts|mjs|mts)$/, "").replaceAll("@", "").replaceAll(/[/\\]+/g, "-").replaceAll(/[^\w.-]/g, "-").replaceAll(/-+/g, "-").replaceAll(/^-|-$/g, "");
13
13
  return `${SIDECAR_ASSET_DIR}${base}.js`;
14
14
  }
15
+ var URL_TERMINATOR = String.raw`"'\s<>()\\`;
15
16
 
16
17
  // src/search-state.ts
17
18
  var CLIENT_SPECIFIER = "npm:@jxsuite/search/client";
@@ -44,7 +45,8 @@ class Search {
44
45
  const query = typeof this.config.query === "string" ? this.config.query : "";
45
46
  return client.query(query, {
46
47
  group: this.config.group ?? true,
47
- limit: this.config.limit ?? 8
48
+ limit: this.config.limit ?? 8,
49
+ ...this.config.locale === undefined ? {} : { locale: this.config.locale }
48
50
  });
49
51
  }
50
52
  indexUrl() {
@@ -53,7 +55,11 @@ class Search {
53
55
  static lower(def, context = {}) {
54
56
  const indexUrl = def.index ?? context.projectConfig?.search?.output ?? "/search-index.json";
55
57
  const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
56
- const opts = JSON.stringify({ group: def.group ?? true, limit: def.limit ?? 8 });
58
+ const opts = JSON.stringify({
59
+ group: def.group ?? true,
60
+ limit: def.limit ?? 8,
61
+ ...def.locale === undefined ? {} : { locale: def.locale }
62
+ });
57
63
  const body = [
58
64
  "void state.__jxSearchReady;",
59
65
  "if (!globalThis.__jxSearch) {",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/search",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Static site search for Jx: build-time index emission from content collections and a headless MiniSearch browser client",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,7 +26,7 @@
26
26
  "./Search.class.json": "./src/Search.class.json"
27
27
  },
28
28
  "publishConfig": {
29
- "provenance": false
29
+ "provenance": true
30
30
  },
31
31
  "scripts": {
32
32
  "prepare": "bun build src/search-index.ts src/search-state.ts --outdir=dist --format=esm --target=node",
@@ -36,7 +36,7 @@
36
36
  "upgrade": "bunx npm-check-updates -u && bun install"
37
37
  },
38
38
  "dependencies": {
39
- "@jxsuite/schema": "workspace:^",
39
+ "@jxsuite/schema": "^1.6.0",
40
40
  "minisearch": "^7.2.0"
41
41
  },
42
42
  "devDependencies": {
@@ -32,6 +32,11 @@
32
32
  "identifier": "index",
33
33
  "type": { "type": "string" },
34
34
  "description": "Index URL override (defaults to the project's search.output)"
35
+ },
36
+ "locale": {
37
+ "identifier": "locale",
38
+ "type": { "type": ["string", "null"] },
39
+ "description": "Language to search in \u2014 omitted, the page's own (<html lang>); null searches every language"
35
40
  }
36
41
  },
37
42
  "constructor": {
@@ -40,7 +45,8 @@
40
45
  { "$ref": "#/$defs/parameters/query" },
41
46
  { "$ref": "#/$defs/parameters/limit" },
42
47
  { "$ref": "#/$defs/parameters/group" },
43
- { "$ref": "#/$defs/parameters/index" }
48
+ { "$ref": "#/$defs/parameters/index" },
49
+ { "$ref": "#/$defs/parameters/locale" }
44
50
  ],
45
51
  "body": ["this.config = config;"]
46
52
  },
package/src/client.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Bundled into `/assets/` by the site build (spec.md §5.3); no UI. Fetches the emitted index
5
5
  * envelope once, builds a MiniSearch index in-memory (tens of milliseconds at docs scale), and
6
- * answers synchronous queries with page-grouped, section-anchored results. Field boosts and indexed
7
- * fields come from the envelope — the emitter bakes the project's `search` section in, so the
8
- * client needs no configuration.
6
+ * answers synchronous queries with section-anchored results — either grouped under their page, or
7
+ * flat and presentation-ready (breadcrumbs plus highlight tokens for the title and a body excerpt).
8
+ * Field boosts and indexed fields come from the envelope — the emitter bakes the project's `search`
9
+ * section in, so the client needs no configuration.
9
10
  *
10
11
  * Two integration surfaces: - `preload` / `isReady` / `query` — the core API (used by lowered
11
12
  * `Search` defs). - `searchInit` / `runSearch` — state-convention helpers for `$src` use inside
@@ -24,6 +25,37 @@ export interface SearchHit {
24
25
  score: number;
25
26
  }
26
27
 
28
+ /**
29
+ * One run of display text. `m` is true when the run matched a query term — renderers wrap those in
30
+ * `<mark>` (or equivalent). Tokens rather than an HTML string so declarative renderers can
31
+ * highlight without injecting markup.
32
+ */
33
+ export interface SearchToken {
34
+ t: string;
35
+ m: boolean;
36
+ }
37
+
38
+ /** One flat, presentation-ready result row: a page or one of its heading sections. */
39
+ export interface SearchResult {
40
+ id: string;
41
+ collection: string;
42
+ slug: string;
43
+ /** Page URL, or page URL + `#anchor` for a section row. */
44
+ url: string;
45
+ /** Page title. */
46
+ title: string;
47
+ /** Heading text for a section row; `""` for a page row. */
48
+ heading: string;
49
+ description: string;
50
+ score: number;
51
+ /** Breadcrumb trail: slug ancestors, plus the page title on a section row. */
52
+ crumbs: string[];
53
+ /** The row's display title (heading, else page title), highlighted. */
54
+ titleTokens: SearchToken[];
55
+ /** A window of body text around the first match, highlighted. */
56
+ excerptTokens: SearchToken[];
57
+ }
58
+
27
59
  /** One result group: a page and its matching sections, best-score first. */
28
60
  export interface SearchResultGroup {
29
61
  collection: string;
@@ -39,10 +71,24 @@ export interface SearchResultGroup {
39
71
 
40
72
  /** Query options. */
41
73
  export interface QueryOptions {
42
- /** Maximum result groups (default 8). */
74
+ /** Maximum rows returned (default 8). */
43
75
  limit?: number;
44
- /** Group section hits under their page (default true); false returns flat documents. */
76
+ /**
77
+ * Group section hits under their page (default true), yielding `SearchResultGroup[]`; false
78
+ * returns flat, presentation-ready `SearchResult[]` — one row per matching page or section.
79
+ */
45
80
  group?: boolean;
81
+ /**
82
+ * In flat mode, the most rows one page may contribute (default 3). Keeps long pages from flooding
83
+ * the list.
84
+ */
85
+ pageCap?: number;
86
+ /**
87
+ * The language to search in. Omitted, it is the page's own — `<html lang>`, which the build wrote
88
+ * from the route's locale, so a reader searching a French page is not handed English results.
89
+ * `null` searches every language.
90
+ */
91
+ locale?: string | null;
46
92
  }
47
93
 
48
94
  interface ClientState {
@@ -53,7 +99,162 @@ interface ClientState {
53
99
 
54
100
  const state: ClientState = { indexUrl: "/search-index.json", loading: null, mini: null };
55
101
 
56
- const STORE_FIELDS = ["collection", "slug", "url", "title", "description", "heading"];
102
+ /** `text` is stored so flat results can carry a highlighted excerpt of the body. */
103
+ const STORE_FIELDS = [
104
+ "collection",
105
+ "slug",
106
+ "url",
107
+ "title",
108
+ "description",
109
+ "heading",
110
+ "text",
111
+ "locale",
112
+ ];
113
+
114
+ /**
115
+ * The language this page is in, from `<html lang>`.
116
+ *
117
+ * The document element is the right source and the only one available: the build wrote that
118
+ * attribute from the route's locale (site-architecture.md §13.4), so it is the same answer the
119
+ * index was built against, and it is correct inside a component, an island and a fragment alike.
120
+ *
121
+ * @returns {string} The tag, or `""` off a document
122
+ */
123
+ function pageLocale(): string {
124
+ return globalThis.document?.documentElement?.lang ?? "";
125
+ }
126
+
127
+ /**
128
+ * Whether a document in `docLocale` answers a search made in `want`.
129
+ *
130
+ * A document with no locale always answers: an unlocalized collection is not in one language, it is
131
+ * outside the question, and hiding it would empty the search box on a site whose docs are shared.
132
+ *
133
+ * Region is ignored when the tags disagree on it alone — a reader on `/fr-ca/` wants the French
134
+ * posts — which is RFC 4647 Lookup's truncation, at the one length that matters here.
135
+ */
136
+ /**
137
+ * What makes two hits the same page.
138
+ *
139
+ * The locale is part of it because two translations of one entry **share a slug** (§13.3): without
140
+ * it, an unscoped search collapses the French copy and the English one into a single group whose
141
+ * title is whichever was indexed first.
142
+ */
143
+ function pageKey(hit: RawHit): string {
144
+ return `${hit.collection}:${hit.locale ?? ""}:${hit.slug}`;
145
+ }
146
+
147
+ function localeAnswers(docLocale: string | undefined, want: string): boolean {
148
+ if (docLocale === undefined || want === "") {
149
+ return true;
150
+ }
151
+ const a = docLocale.toLowerCase();
152
+ const b = want.toLowerCase();
153
+ return a === b || a.split("-")[0] === b.split("-")[0];
154
+ }
155
+
156
+ /** A fresh word scanner — regexes with `g` carry `lastIndex`, so never share one. */
157
+ function wordScanner(): RegExp {
158
+ return /[\p{L}\p{N}_]+/gu;
159
+ }
160
+
161
+ /** Excerpt window, in characters. */
162
+ const EXCERPT_WIDTH = 160;
163
+
164
+ /** How much of the excerpt window sits before the first match. */
165
+ const EXCERPT_LEAD = 60;
166
+
167
+ /** Default cap on rows one page may contribute to a flat result list. */
168
+ const PAGE_CAP = 3;
169
+
170
+ /** Lowercased needles a word must start with to count as a match. */
171
+ function needlesOf(terms: readonly string[]): string[] {
172
+ const seen = new Set<string>();
173
+ for (const term of terms) {
174
+ if (term) {
175
+ seen.add(term.toLowerCase());
176
+ }
177
+ }
178
+ return [...seen];
179
+ }
180
+
181
+ /** `[start, end)` spans of the whole words in `text` that begin with one of `needles`. */
182
+ function matchSpans(text: string, needles: readonly string[]): [number, number][] {
183
+ const spans: [number, number][] = [];
184
+ if (!text || needles.length === 0) {
185
+ return spans;
186
+ }
187
+ const lower = text.toLowerCase();
188
+ const scanner = wordScanner();
189
+ let match = scanner.exec(lower);
190
+ while (match !== null) {
191
+ const [word] = match;
192
+ if (needles.some((needle) => word.startsWith(needle))) {
193
+ spans.push([match.index, match.index + word.length]);
194
+ }
195
+ match = scanner.exec(lower);
196
+ }
197
+ return spans;
198
+ }
199
+
200
+ /**
201
+ * Split `text` into alternating plain and matched runs. A word is matched when it starts with one
202
+ * of the query's terms, so a prefix search for "intro" highlights the whole word "introduction".
203
+ */
204
+ export function buildTokens(text: string, terms: readonly string[]): SearchToken[] {
205
+ if (!text) {
206
+ return [];
207
+ }
208
+ const tokens: SearchToken[] = [];
209
+ let cursor = 0;
210
+ for (const [start, end] of matchSpans(text, needlesOf(terms))) {
211
+ if (start > cursor) {
212
+ tokens.push({ m: false, t: text.slice(cursor, start) });
213
+ }
214
+ tokens.push({ m: true, t: text.slice(start, end) });
215
+ cursor = end;
216
+ }
217
+ if (cursor < text.length) {
218
+ tokens.push({ m: false, t: text.slice(cursor) });
219
+ }
220
+ return tokens;
221
+ }
222
+
223
+ /**
224
+ * A readable window of `text` around its first match, snapped to word boundaries and elided with
225
+ * `…` on either side when truncated. With no match in the body, the opening of the text is used.
226
+ */
227
+ export function buildExcerpt(text: string, terms: readonly string[]): string {
228
+ const body = text.trim();
229
+ if (!body) {
230
+ return "";
231
+ }
232
+ const at = matchSpans(body, needlesOf(terms))[0]?.[0] ?? -1;
233
+ let start = at < 0 ? 0 : Math.max(0, at - EXCERPT_LEAD);
234
+ if (start > 0) {
235
+ const space = body.indexOf(" ", start);
236
+ start = space === -1 || space >= at ? start : space + 1;
237
+ }
238
+ let end = Math.min(body.length, start + EXCERPT_WIDTH);
239
+ if (end < body.length) {
240
+ const space = body.lastIndexOf(" ", end);
241
+ if (space > start) {
242
+ end = space;
243
+ }
244
+ }
245
+ return `${start > 0 ? "…" : ""}${body.slice(start, end).trim()}${end < body.length ? "…" : ""}`;
246
+ }
247
+
248
+ /** Breadcrumb labels for a slug's ancestor segments: `framework/site/search` → `Framework › Site`. */
249
+ export function crumbsFromSlug(slug: string): string[] {
250
+ return slug
251
+ .split("/")
252
+ .filter(Boolean)
253
+ .slice(0, -1)
254
+ .map((segment) =>
255
+ segment.replaceAll(/[-_]+/g, " ").replaceAll(/\b\p{Ll}/gu, (char) => char.toUpperCase()),
256
+ );
257
+ }
57
258
 
58
259
  /** Override the default index URL for subsequent `preload()` calls. */
59
260
  export function configure(opts: { indexUrl?: string }): void {
@@ -99,24 +300,53 @@ export function preload(indexUrl?: string): Promise<void> {
99
300
  return state.loading;
100
301
  }
101
302
 
102
- /** Raw MiniSearch hit with our stored fields. */
303
+ /** Raw MiniSearch hit with our stored fields and the terms it matched on. */
103
304
  interface RawHit {
104
305
  id: string;
105
306
  score: number;
307
+ terms: string[];
308
+ queryTerms: string[];
106
309
  collection: string;
107
310
  slug: string;
108
311
  url: string;
109
312
  title: string;
110
313
  description: string;
111
314
  heading: string;
315
+ text: string;
316
+ locale?: string;
317
+ }
318
+
319
+ /** Turn a raw hit into a presentation-ready row: breadcrumbs plus highlighted title and excerpt. */
320
+ function toResult(hit: RawHit): SearchResult {
321
+ // `terms` holds the matched document words, so a prefix or fuzzy search highlights the whole
322
+ // `word` it landed on; `queryTerms` holds what the user actually typed.
323
+ const terms = [...hit.terms, ...hit.queryTerms];
324
+ const crumbs = crumbsFromSlug(hit.slug);
325
+ if (hit.heading) {
326
+ crumbs.push(hit.title);
327
+ }
328
+ return {
329
+ collection: hit.collection,
330
+ crumbs,
331
+ description: hit.description,
332
+ excerptTokens: buildTokens(buildExcerpt(hit.text ?? "", terms), terms),
333
+ heading: hit.heading,
334
+ id: hit.id,
335
+ score: hit.score,
336
+ slug: hit.slug,
337
+ title: hit.title,
338
+ titleTokens: buildTokens(hit.heading || hit.title, terms),
339
+ url: hit.url,
340
+ };
112
341
  }
113
342
 
114
343
  /**
115
344
  * Search synchronously. Returns `[]` until `preload` completes (and kicks it off if nobody has).
116
345
  * With `group: true` (default), section hits nest under their page, groups sorted by best score;
117
- * `group: false` returns the flat scored documents.
346
+ * `group: false` returns flat `SearchResult` rows — one per matching page or section, at most
347
+ * `pageCap` from any one page.
118
348
  */
119
- export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] | RawHit[] {
349
+ export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] | SearchResult[] {
120
350
  const limit = opts.limit ?? 8;
121
351
  const group = opts.group ?? true;
122
352
  if (!state.mini) {
@@ -127,14 +357,37 @@ export function query(q: string, opts: QueryOptions = {}): SearchResultGroup[] |
127
357
  if (!text) {
128
358
  return [];
129
359
  }
130
- const raw = state.mini.search(text) as unknown as RawHit[];
360
+ /*
361
+ * Scoped to the page's language unless the caller says otherwise. A multilingual site's index
362
+ * holds every translation of every post, so an unscoped search hands a French reader the English
363
+ * copy of the page they are already on — ranked first, because it matched the same words.
364
+ */
365
+ const want = opts.locale === undefined ? pageLocale() : (opts.locale ?? "");
366
+ const raw = (state.mini.search(text) as unknown as RawHit[]).filter((hit) =>
367
+ localeAnswers(hit.locale, want),
368
+ );
131
369
  if (!group) {
132
- return raw.slice(0, limit);
370
+ const pageCap = opts.pageCap ?? PAGE_CAP;
371
+ const perPage = new Map<string, number>();
372
+ const rows: SearchResult[] = [];
373
+ for (const hit of raw) {
374
+ if (rows.length >= limit) {
375
+ break;
376
+ }
377
+ const key = pageKey(hit);
378
+ const taken = perPage.get(key) ?? 0;
379
+ if (taken >= pageCap) {
380
+ continue;
381
+ }
382
+ perPage.set(key, taken + 1);
383
+ rows.push(toResult(hit));
384
+ }
385
+ return rows;
133
386
  }
134
387
 
135
388
  const groups = new Map<string, SearchResultGroup>();
136
389
  for (const hit of raw) {
137
- const key = `${hit.collection}:${hit.slug}`;
390
+ const key = pageKey(hit);
138
391
  let entry = groups.get(key);
139
392
  if (!entry) {
140
393
  entry = {
@@ -174,22 +427,26 @@ export function searchInit(componentState: Record<string, unknown>): boolean {
174
427
  return true;
175
428
  }
176
429
 
430
+ /** Rows a `$src` component search shows at once. */
431
+ const UI_LIMIT = 20;
432
+
177
433
  /**
178
434
  * State-convention query handler for `$src` component use: reads the query from the input event (or
179
- * `state.searchQuery`), stores grouped results on `state.searchResults`, and resets the active-row
180
- * index.
435
+ * `state.searchQuery`), stores flat highlighted rows on `state.searchResults`, publishes the row
436
+ * count as `state.searchCount`, and resets the active-row index.
181
437
  */
182
438
  export function runSearch(
183
439
  componentState: Record<string, unknown>,
184
440
  event?: { target?: { value?: unknown } | null },
185
- ): unknown[] {
441
+ ): SearchResult[] {
186
442
  const fromEvent =
187
443
  event?.target && typeof event.target.value === "string" ? event.target.value : null;
188
444
  const q =
189
445
  fromEvent ?? (typeof componentState.searchQuery === "string" ? componentState.searchQuery : "");
190
446
  componentState.searchQuery = q;
191
- const results = query(q);
447
+ const results = query(q, { group: false, limit: UI_LIMIT }) as SearchResult[];
192
448
  componentState.searchResults = results;
449
+ componentState.searchCount = results.length;
193
450
  componentState.searchActive = 0;
194
451
  return results;
195
452
  }
@@ -10,9 +10,10 @@
10
10
  * @docs extending/extensions/search
11
11
  */
12
12
 
13
+ import { canonicalizeLocale, localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
13
14
  import { entryUrl, jxTreeToText, normalizeSearchConfig, splitSections } from "./shared.ts";
14
15
  import type { NormalizedSearchConfig } from "./shared.ts";
15
- import type { ContentLoaderEntry, JxElement } from "@jxsuite/schema/types";
16
+ import type { ContentLoaderEntry, JxElement, ProjectConfig } from "@jxsuite/schema/types";
16
17
 
17
18
  /** One searchable document in the emitted index. */
18
19
  export interface SearchDocument {
@@ -29,6 +30,12 @@ export interface SearchDocument {
29
30
  /** Section heading text; empty for page-level documents. */
30
31
  heading: string;
31
32
  text: string;
33
+ /**
34
+ * The entry's language, present only for a collection spread over one directory per locale
35
+ * (site-architecture.md §13.3). Absent means "every language" — an unlocalized collection is not
36
+ * in one language, it is outside the question.
37
+ */
38
+ locale?: string;
32
39
  }
33
40
 
34
41
  /** The emitted index file: engine-tagged envelope around the document list. */
@@ -66,6 +73,13 @@ export const SearchIndex = {
66
73
  const config = normalizeSearchConfig(sectionValue);
67
74
  const content = ctx.sections?.content as Map<string, ContentLoaderEntry[]> | undefined;
68
75
  const trailingSlash = ctx.projectConfig?.build?.trailingSlash ?? "always";
76
+ /*
77
+ * A localized entry lives at a locale-prefixed URL, and the index is the only place that knew
78
+ * the entry without knowing the prefix: every French post was indexed at the English post's
79
+ * URL, so a reader who searched in French was sent to the English page. The prefix is the
80
+ * routing rule, read from the same resolver the build reads.
81
+ */
82
+ const { i18n } = resolveI18n((ctx.projectConfig ?? {}) as ProjectConfig);
69
83
 
70
84
  const documents: SearchDocument[] = [];
71
85
  const fields = new Set<string>();
@@ -86,13 +100,31 @@ export const SearchIndex = {
86
100
 
87
101
  for (const entry of entries) {
88
102
  const data = (entry.data ?? {}) as Record<string, unknown>;
89
- const url = entryUrl(collection.basePath, entry.id, trailingSlash);
103
+ /*
104
+ * Only when the project declares locales. An entry cannot carry one otherwise — the loader
105
+ * stamps it while expanding `{locale}`, which needs a list to expand over — and a stray tag
106
+ * would make the client filter results away for a language the site does not have.
107
+ */
108
+ const locale =
109
+ i18n === null ? undefined : (canonicalizeLocale(entry._meta?.locale) ?? undefined);
110
+ const url = entryUrl(
111
+ `${localeUrlPrefix(locale, i18n)}${collection.basePath}`,
112
+ entry.id,
113
+ trailingSlash,
114
+ );
90
115
  const title = typeof data.title === "string" ? data.title : entry.id;
91
116
  const description = typeof data.description === "string" ? data.description : "";
92
117
  const children = entry.$children as (JxElement | string)[] | undefined;
118
+ /*
119
+ * Two translations of one post share an id (§13.3), so the document id has to carry the
120
+ * locale as well — without it the French entry and the English one are one document and
121
+ * whichever is indexed second wins.
122
+ */
123
+ const documentId =
124
+ locale === undefined ? `${name}:${entry.id}` : `${name}:${locale}:${entry.id}`;
93
125
 
94
126
  documents.push({
95
- id: `${name}:${entry.id}`,
127
+ id: documentId,
96
128
  collection: name,
97
129
  slug: entry.id,
98
130
  url,
@@ -100,12 +132,13 @@ export const SearchIndex = {
100
132
  description,
101
133
  heading: "",
102
134
  text: jxTreeToText(children),
135
+ ...(locale === undefined ? {} : { locale }),
103
136
  });
104
137
 
105
138
  if (collection.sections) {
106
139
  for (const section of splitSections(children, collection.sectionDepth)) {
107
140
  documents.push({
108
- id: `${name}:${entry.id}#${section.anchor}`,
141
+ id: `${documentId}#${section.anchor}`,
109
142
  collection: name,
110
143
  slug: entry.id,
111
144
  url: `${url}#${section.anchor}`,
@@ -113,6 +146,7 @@ export const SearchIndex = {
113
146
  description,
114
147
  heading: section.heading,
115
148
  text: section.text,
149
+ ...(locale === undefined ? {} : { locale }),
116
150
  });
117
151
  }
118
152
  }
@@ -32,6 +32,12 @@ export interface SearchDef {
32
32
  group?: boolean;
33
33
  /** Override the index URL (defaults to the project's `search.output`). */
34
34
  index?: string;
35
+ /**
36
+ * Language to search in. Omitted, the client uses the page's own — `<html lang>`, which the build
37
+ * wrote from the route's locale — so a search box needs no configuration to stop handing a French
38
+ * reader the English copy of the page they are on. `null` searches every language.
39
+ */
40
+ locale?: string | null;
35
41
  default?: unknown;
36
42
  [key: string]: unknown;
37
43
  }
@@ -75,13 +81,17 @@ export class Search {
75
81
  const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
76
82
  const client = (await import(clientUrl)) as {
77
83
  preload: (url?: string) => Promise<void>;
78
- query: (q: string, opts?: { limit?: number; group?: boolean }) => unknown;
84
+ query: (
85
+ q: string,
86
+ opts?: { limit?: number; group?: boolean; locale?: string | null },
87
+ ) => unknown;
79
88
  };
80
89
  await client.preload(this.indexUrl());
81
90
  const query = typeof this.config.query === "string" ? this.config.query : "";
82
91
  return client.query(query, {
83
92
  group: this.config.group ?? true,
84
93
  limit: this.config.limit ?? 8,
94
+ ...(this.config.locale === undefined ? {} : { locale: this.config.locale }),
85
95
  });
86
96
  }
87
97
 
@@ -98,7 +108,16 @@ export class Search {
98
108
  static lower(def: SearchDef, context: LowerContext = {}): Record<string, unknown> {
99
109
  const indexUrl = def.index ?? context.projectConfig?.search?.output ?? "/search-index.json";
100
110
  const clientUrl = sidecarAssetPath(CLIENT_SPECIFIER);
101
- const opts = JSON.stringify({ group: def.group ?? true, limit: def.limit ?? 8 });
111
+ /*
112
+ * `locale` is passed only when the def names one. Omitting the key is not the same as passing
113
+ * `undefined` through JSON — the client reads "absent" as "the page's own language", which is
114
+ * the default a search box should have and the one nobody has to configure.
115
+ */
116
+ const opts = JSON.stringify({
117
+ group: def.group ?? true,
118
+ limit: def.limit ?? 8,
119
+ ...(def.locale === undefined ? {} : { locale: def.locale }),
120
+ });
102
121
  const body = [
103
122
  // Reading the ready flag makes the computed re-run when the client finishes loading.
104
123
  "void state.__jxSearchReady;",
package/src/shared.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { JxElement } from "@jxsuite/schema/types";
10
+ import { displayTagName } from "@jxsuite/schema/guards";
10
11
 
11
12
  /** One collection's index configuration inside the `search` project section. */
12
13
  export interface SearchCollectionConfig {
@@ -159,7 +160,7 @@ function headingOf(
159
160
  if (typeof node !== "object" || node === null || !node.tagName) {
160
161
  return null;
161
162
  }
162
- const match = HEADING_TAG.exec(node.tagName);
163
+ const match = HEADING_TAG.exec(displayTagName(node.tagName));
163
164
  if (!match) {
164
165
  return null;
165
166
  }