pgkiln 0.68.0 → 0.69.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.
Files changed (48) hide show
  1. package/db/migrations/135_fulltext_search.sql +60 -0
  2. package/docs/apex-feature-parity.md +2 -2
  3. package/docs/guide/03-builder.md +7 -0
  4. package/docs/guide/04-pages-and-regions.md +46 -1
  5. package/docs/guide/12-development.md +10 -0
  6. package/examples/hr/README.md +1 -0
  7. package/examples/hr/hr_71_fulltext_search.sql +43 -0
  8. package/package.json +1 -1
  9. package/src/builder/advisor.ts +39 -3
  10. package/src/builder/components.ts +14 -1
  11. package/src/builder/fulltext.ts +160 -0
  12. package/src/builder/i18n/de.ts +20 -0
  13. package/src/builder/i18n/en.ts +21 -0
  14. package/src/builder/i18n/es.ts +20 -0
  15. package/src/builder/i18n/fr.ts +20 -0
  16. package/src/builder/i18n/it.ts +20 -0
  17. package/src/builder/i18n/ja.ts +20 -0
  18. package/src/builder/i18n/ko.ts +20 -0
  19. package/src/builder/i18n/nl.ts +20 -0
  20. package/src/builder/i18n/pt.ts +20 -0
  21. package/src/builder/i18n/zh-cn.ts +20 -0
  22. package/src/builder/i18n/zh-tw.ts +20 -0
  23. package/src/builder/object-actions.ts +1 -0
  24. package/src/builder/sql.ts +3 -0
  25. package/src/builder/table-editor.ts +1 -0
  26. package/src/i18n/ar.ts +1 -0
  27. package/src/i18n/cs.ts +1 -0
  28. package/src/i18n/da.ts +1 -0
  29. package/src/i18n/de.ts +1 -0
  30. package/src/i18n/el.ts +1 -0
  31. package/src/i18n/es.ts +1 -0
  32. package/src/i18n/fi.ts +1 -0
  33. package/src/i18n/fr.ts +1 -0
  34. package/src/i18n/he.ts +1 -0
  35. package/src/i18n/it.ts +1 -0
  36. package/src/i18n/ja.ts +1 -0
  37. package/src/i18n/ko.ts +1 -0
  38. package/src/i18n/nb.ts +1 -0
  39. package/src/i18n/pl.ts +1 -0
  40. package/src/i18n/pt.ts +1 -0
  41. package/src/i18n/ru.ts +1 -0
  42. package/src/i18n/sv.ts +1 -0
  43. package/src/i18n/tr.ts +1 -0
  44. package/src/i18n/uk.ts +1 -0
  45. package/src/i18n/zh.ts +1 -0
  46. package/src/i18n.ts +2 -0
  47. package/src/runtime/report.ts +26 -2
  48. package/src/runtime/search.ts +184 -21
@@ -10,8 +10,15 @@
10
10
  // - sql: a SELECT (binds allowed), wrapped as a subquery;
11
11
  // - list: the entries of a list (Shared Components → Lists) the user may see.
12
12
  //
13
- // Every word of the search text must be found in one of the searchable
14
- // columns (ILIKE, % and _ escaped, the text as a query parameter). The
13
+ // Search type standard: every word of the search text must be found in one
14
+ // of the searchable columns (ILIKE, % and _ escaped, the text as a query
15
+ // parameter). Search type fulltext (135): the source's tsvector matched
16
+ // with websearch_to_tsquery (quotes, or, -word), the best match first
17
+ // (ts_rank_cd), title, subtitle and description highlighted by ts_headline
18
+ // (between private-use markers that only then become <mark>, everything
19
+ // else escaped); the text search configuration is checked against
20
+ // pg_ts_config and passed as a parameter. With pg_trgm installed, a
21
+ // configuration may suggest the closest words when nothing is found. The
15
22
  // queries run as the application's role in the page's transaction, each in a
16
23
  // savepoint, so one configuration that fails shows its error and the others
17
24
  // still answer. Without JavaScript the field is a GET form; app.js searches
@@ -42,6 +49,14 @@ export interface SearchConfig {
42
49
  icon: string | null;
43
50
  link: { page?: number; items?: Record<string, string> } | null;
44
51
  max_results: number;
52
+ /** (135) standard: ILIKE per word · fulltext: PostgreSQL full-text search (later: vector, hybrid) */
53
+ search_type?: 'standard' | 'fulltext';
54
+ /** (135) fulltext: a tsvector column or expression; empty: to_tsvector over the searchable columns */
55
+ fts_vector?: string | null;
56
+ /** (135) fulltext: the text search configuration; empty: the application language's */
57
+ fts_config?: string | null;
58
+ /** (135) with pg_trgm: suggest the closest words when nothing is found */
59
+ did_you_mean?: boolean;
45
60
  }
46
61
 
47
62
  export interface SearchRegionConfig {
@@ -58,6 +73,54 @@ const MAX_TEXT = 200;
58
73
  const COLUMN = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
59
74
  const TABLE = /^([A-Za-z_][A-Za-z0-9_$]{0,62}\.)?[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
60
75
 
76
+ /** A client, a pool or pgkiln's Db: something that runs a query with parameters. */
77
+ type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
78
+
79
+ /** (135) The search types (later: vector, hybrid). */
80
+ export const SEARCH_TYPES = ['standard', 'fulltext'] as const;
81
+ const TS_CONFIG = /^[a-z_][a-z0-9_]{0,62}$/;
82
+
83
+ /** (135) The text search configuration of a language: pgkiln's languages that PostgreSQL has a stemmer for. */
84
+ const TS_BY_LANGUAGE: Record<string, string> = {
85
+ ar: 'arabic', ca: 'catalan', da: 'danish', de: 'german', el: 'greek', en: 'english', es: 'spanish', eu: 'basque', fi: 'finnish',
86
+ fr: 'french', ga: 'irish', hi: 'hindi', hu: 'hungarian', hy: 'armenian', id: 'indonesian', it: 'italian', lt: 'lithuanian',
87
+ nb: 'norwegian', ne: 'nepali', nl: 'dutch', nn: 'norwegian', no: 'norwegian', pt: 'portuguese', ro: 'romanian', ru: 'russian',
88
+ sr: 'serbian', sv: 'swedish', ta: 'tamil', tr: 'turkish', yi: 'yiddish',
89
+ };
90
+ export const tsConfigFor = (language: string | null | undefined) => TS_BY_LANGUAGE[String(language ?? '').toLowerCase().split(/[-_]/)[0]] ?? 'simple';
91
+
92
+ /**
93
+ * (135) The text search configuration to use: the one asked for, else the language's, if it exists in
94
+ * pg_ts_config; else simple. Only a name that exists is returned (and it is still passed as a parameter).
95
+ */
96
+ export async function textSearchConfig(db: Queryable, wanted: string | null | undefined, language: string | null | undefined): Promise<string> {
97
+ const names = [wanted, tsConfigFor(language)].filter((x): x is string => !!x && TS_CONFIG.test(x));
98
+ if (!names.length) return 'simple';
99
+ const found = (await db.query('select cfgname from pg_catalog.pg_ts_config where cfgname = any($1::text[])', [names])).rows.map((r: { cfgname: string }) => r.cfgname);
100
+ return names.find((n) => found.includes(n)) ?? 'simple';
101
+ }
102
+
103
+ /** (135) The schema of pg_trgm when it is installed (its functions are called schema-qualified), else null. */
104
+ export async function trigramSchema(db: Queryable): Promise<string | null> {
105
+ return (await db.query(`select n.nspname as s from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_trgm'`)).rows[0]?.s ?? null;
106
+ }
107
+
108
+ /** (135) The markers ts_headline puts around a matched word (private-use characters, removed from the text first). */
109
+ export const HL_START = '\uE000';
110
+ export const HL_STOP = '\uE001';
111
+
112
+ /** (135) A ts_headline result as HTML: everything escaped, only the text between the markers in <mark>. */
113
+ export function headlined(value: string): Raw {
114
+ const parts: Raw[] = [];
115
+ let last = 0;
116
+ for (const m of value.matchAll(/\uE000([^\uE000\uE001]*)\uE001/g)) {
117
+ parts.push(html`${value.slice(last, m.index).replace(/[\uE000\uE001]/g, '')}${m[1] ? html`<mark>${m[1]}</mark>` : ''}`);
118
+ last = m.index! + m[0].length;
119
+ }
120
+ parts.push(html`${value.slice(last).replace(/[\uE000\uE001]/g, '')}`);
121
+ return html`${parts}`;
122
+ }
123
+
61
124
  /** The columns a configuration names (for the Advisor's check of its source). */
62
125
  export const configColumns = (c: Pick<SearchConfig, 'title_column' | 'subtitle_column' | 'description_column' | 'badge_column' | 'searchable_columns' | 'icon'>) =>
63
126
  [...new Set([c.title_column, c.subtitle_column, c.description_column, c.badge_column, ...(c.searchable_columns ?? []), /^#(.+)#$/.exec(c.icon ?? '')?.[1]]
@@ -87,6 +150,12 @@ export function searchConfigProblems(c: Partial<SearchConfig>): string[] {
87
150
  if (c.source_type === 'table' && !TABLE.test((c.source ?? '').trim())) out.push('The source of a table configuration is a table or view name, e.g. sales.customers.');
88
151
  if (c.icon && !/^(#[A-Za-z_][A-Za-z0-9_$]*#|[a-z0-9-]{1,40})$/.test(c.icon)) out.push('The icon is an icon name, or #COLUMN# for a column that holds icon names.');
89
152
  if (c.link && (typeof c.link !== 'object' || !Number.isInteger(c.link.page))) out.push('The link is {"page": 3, "items": {"P3_ID": "#ID#"}}.');
153
+ if (c.search_type && !SEARCH_TYPES.includes(c.search_type)) out.push(`The search type is one of ${SEARCH_TYPES.join(', ')}.`);
154
+ if (c.search_type === 'fulltext') {
155
+ if (c.source_type === 'list') out.push('Full-text search needs a table or SQL source.');
156
+ if (c.fts_config && !TS_CONFIG.test(c.fts_config)) out.push('The text search configuration is a name, e.g. english, dutch or simple.');
157
+ if (c.fts_vector && /;|--|\/\*/.test(c.fts_vector)) out.push('The tsvector is one column or expression (no ; or comments).');
158
+ }
90
159
  return out;
91
160
  }
92
161
 
@@ -97,7 +166,8 @@ async function configsOf(ctx: PageContext, r: Region): Promise<SearchConfig[]> {
97
166
  return (
98
167
  await runtime.query<SearchConfig>(
99
168
  `select name, label, seq, source_type, source, searchable_columns, title_column, subtitle_column, description_column,
100
- badge_column, icon, link, max_results
169
+ badge_column, icon, link, max_results,
170
+ search_type, fts_vector, fts_config, did_you_mean
101
171
  from meta.search_config
102
172
  where app_id = $1 and meta.build_option_on(app_id, build_option) and (cardinality($2::text[]) = 0 or name = any($2))
103
173
  order by seq, name`,
@@ -113,6 +183,8 @@ interface Hit {
113
183
  badge: string | null;
114
184
  icon: string | null;
115
185
  href: Raw | null;
186
+ /** (135) full-text search: title, subtitle and description as highlighted by ts_headline */
187
+ headlines?: { title: Raw; subtitle: Raw | null; description: Raw | null };
116
188
  }
117
189
 
118
190
  const text = (v: unknown) => (v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v));
@@ -124,21 +196,58 @@ function col(row: Record<string, unknown>, name: string | null) {
124
196
  return key === undefined ? null : text(row[key]);
125
197
  }
126
198
 
127
- /** One configuration's results on a table or a query: up to max_results, and whether there are more. */
128
- async function searchRows(ctx: PageContext, c: SearchConfig, words: string[]): Promise<{ hits: Hit[]; more: boolean }> {
129
- const searched = (c.searchable_columns.length ? c.searchable_columns : [c.title_column, c.subtitle_column, c.description_column]).filter(
130
- (x): x is string => !!x,
131
- );
132
- const conditions = words.map((_, i) => `(${searched.map((s) => `t.${ident(s)}::text ilike $${i + 1} escape '\\'`).join(' or ')})`);
133
- let from: string;
199
+ /** The columns a configuration searches: its searchable columns, else title, subtitle and description. */
200
+ const searchedColumns = (c: SearchConfig) =>
201
+ (c.searchable_columns.length ? c.searchable_columns : [c.title_column, c.subtitle_column, c.description_column]).filter((x): x is string => !!x);
202
+
203
+ /** The FROM of a table or SQL configuration (the source as `t`). */
204
+ async function sourceFrom(ctx: PageContext, c: SearchConfig) {
134
205
  if (c.source_type === 'table') {
135
206
  const table = (await ctx.client!.query('select $1::regclass::text as t', [c.source])).rows[0].t as string;
136
- from = `${table} t`;
137
- } else from = `(${stripSemicolon(applyBinds(c.source, bindValues(ctx)))}) t`;
138
- const sql = `select t.* from ${from} where ${conditions.join(' and ') || 'true'}${c.title_column ? ` order by t.${ident(c.title_column)}` : ''} limit ${c.max_results + 1}`;
139
- const res = await ctx.client!.query(sql, words.map(likePattern));
140
- const rows = res.rows as Record<string, unknown>[];
207
+ return `${table} t`;
208
+ }
209
+ return `(${stripSemicolon(applyBinds(c.source, bindValues(ctx)))}) t`;
210
+ }
211
+
212
+ /**
213
+ * (135) The tsvector of a full-text configuration as SQL over `t`: its column, its expression, or to_tsvector
214
+ * over the searchable columns (`cfg` is the placeholder of the text search configuration).
215
+ */
216
+ export function tsvectorSql(c: Pick<SearchConfig, 'fts_vector' | 'searchable_columns' | 'title_column' | 'subtitle_column' | 'description_column'>, cfg: string) {
217
+ const v = (c.fts_vector ?? '').trim();
218
+ if (v) return COLUMN.test(v) ? `t.${ident(v)}` : `(${v})`;
219
+ const cols = searchedColumns(c as SearchConfig);
220
+ return `to_tsvector(${cfg}, ${cols.map((x) => `coalesce(t.${ident(x)}::text, '')`).join(` || ' ' || `) || `''`})`;
221
+ }
222
+
223
+ /** One configuration's results on a table or a query: up to max_results, and whether there are more. */
224
+ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[], query: string): Promise<{ hits: Hit[]; more: boolean }> {
225
+ const from = await sourceFrom(ctx, c);
226
+ let rows: Record<string, unknown>[];
227
+ const fulltext = c.search_type === 'fulltext';
228
+ if (fulltext) {
229
+ // $1 the text search configuration (a name from pg_ts_config), $2 the user's text, $3 the headline options,
230
+ // $4 the marker characters (taken out of the values first, so only ts_headline's markers become markup)
231
+ const cfg = await textSearchConfig(ctx.client!, c.fts_config, ctx.app.language);
232
+ const vec = tsvectorSql(c, '$1::regconfig');
233
+ const hl = (column: string | null, alias: string, options: string) =>
234
+ column ? `, ts_headline($1::regconfig, translate(s.${ident(column)}::text, $4, ''), "pgkiln$q".query, ${options}) as "pgkiln$${alias}"` : '';
235
+ const sql = `select s.*${hl(c.title_column, 'title', '$3::text || \', HighlightAll=true\'')}${hl(c.subtitle_column, 'subtitle', '$3::text || \', HighlightAll=true\'')}${hl(c.description_column, 'description', '$3::text || \', MaxWords=35, MinWords=15, MaxFragments=2, FragmentDelimiter=" … "\'')}
236
+ from (select t.*, ts_rank_cd(${vec}, "pgkiln$q".query) as "pgkiln$rank"
237
+ from ${from}, websearch_to_tsquery($1::regconfig, $2) "pgkiln$q"(query)
238
+ where ${vec} @@ "pgkiln$q".query
239
+ order by "pgkiln$rank" desc${c.title_column ? `, t.${ident(c.title_column)}` : ''}
240
+ limit ${c.max_results + 1}) s, websearch_to_tsquery($1::regconfig, $2) "pgkiln$q"(query)
241
+ order by s."pgkiln$rank" desc${c.title_column ? `, s.${ident(c.title_column)}` : ''}`;
242
+ rows = (await ctx.client!.query(sql, [cfg, query, `StartSel="${HL_START}", StopSel="${HL_STOP}"`, HL_START + HL_STOP])).rows;
243
+ } else {
244
+ const searched = searchedColumns(c);
245
+ const conditions = words.map((_, i) => `(${searched.map((s) => `t.${ident(s)}::text ilike $${i + 1} escape '\\'`).join(' or ')})`);
246
+ const sql = `select t.* from ${from} where ${conditions.join(' and ') || 'true'}${c.title_column ? ` order by t.${ident(c.title_column)}` : ''} limit ${c.max_results + 1}`;
247
+ rows = (await ctx.client!.query(sql, words.map(likePattern))).rows;
248
+ }
141
249
  const iconCol = /^#(.+)#$/.exec(c.icon ?? '')?.[1] ?? null;
250
+ const h = (row: Record<string, unknown>, alias: string) => (typeof row[`pgkiln$${alias}`] === 'string' ? headlined(row[`pgkiln$${alias}`] as string) : null);
142
251
  return {
143
252
  more: rows.length > c.max_results,
144
253
  hits: rows.slice(0, c.max_results).map((row) => ({
@@ -151,10 +260,37 @@ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[]): P
151
260
  c.link && Number.isInteger(c.link.page)
152
261
  ? linkAttrs(ctx, c.link.page!, fillItems(c.link.items, (name) => col(row, name) ?? undefined))
153
262
  : null,
263
+ ...(fulltext ? { headlines: { title: h(row, 'title') ?? html`${col(row, c.title_column) ?? ''}`, subtitle: h(row, 'subtitle'), description: h(row, 'description') } } : {}),
154
264
  })),
155
265
  };
156
266
  }
157
267
 
268
+ /**
269
+ * (135) "Did you mean": with pg_trgm installed, each search word that is not a word of the configuration's
270
+ * searched columns replaced by the most similar one (similarity 0.3 or more), from the first 10,000 rows of the
271
+ * source. Null when there is nothing better to suggest.
272
+ */
273
+ async function suggestion(ctx: PageContext, c: SearchConfig, words: string[], trgm: string): Promise<string | null> {
274
+ const cols = searchedColumns(c);
275
+ if (!cols.length || !words.length) return null;
276
+ const sim = `${ident(trgm)}.similarity`;
277
+ const res = await ctx.client!.query<{ word: string; best: string | null; known: boolean }>(
278
+ `with v as (select distinct w from (select concat_ws(' ', ${cols.map((x) => `t.${ident(x)}::text`).join(', ')}) as doc from ${await sourceFrom(ctx, c)} limit 10000) d,
279
+ regexp_split_to_table(lower(d.doc), '[^[:alnum:]]+') w where length(w) between 2 and 40)
280
+ select u.word, exists (select from v where v.w = lower(u.word)) as known,
281
+ (select v.w from v where ${sim}(v.w, lower(u.word)) >= 0.3 order by ${sim}(v.w, lower(u.word)) desc, v.w limit 1) as best
282
+ from unnest($1::text[]) with ordinality u(word, n) order by u.n`,
283
+ [words],
284
+ );
285
+ let changed = false;
286
+ const out = res.rows.map((r) => {
287
+ if (r.known || !r.best) return r.word;
288
+ changed = true;
289
+ return r.best;
290
+ });
291
+ return changed ? out.join(' ') : null;
292
+ }
293
+
158
294
  /** One configuration's results on a list: the entries (and their children) the user may see. */
159
295
  async function searchList(ctx: PageContext, c: SearchConfig, words: string[]): Promise<{ hits: Hit[]; more: boolean }> {
160
296
  const nodes = (await listTree(ctx, c.source)) ?? [];
@@ -204,18 +340,23 @@ async function searchResults(ctx: PageContext, r: Region, query: string): Promis
204
340
  const minLength = Number.isInteger(conf.min_length) && conf.min_length! > 0 ? conf.min_length! : 1;
205
341
  if (!words.length || query.trim().length < minLength) return html``;
206
342
  const groups: Raw[] = [];
207
- for (const c of await configsOf(ctx, r)) {
343
+ const configs = await configsOf(ctx, r);
344
+ for (const c of configs) {
208
345
  let body: Raw;
209
346
  try {
210
347
  const problems = searchConfigProblems(c);
211
348
  if (problems.length) throw new Error(`Search configuration ${c.name}: ${problems.join(' ')}`);
212
- const { hits, more } = await savepoint(ctx.client!, () => (c.source_type === 'list' ? searchList(ctx, c, words) : searchRows(ctx, c, words)));
349
+ const { hits, more } = await savepoint(ctx.client!, () => (c.source_type === 'list' ? searchList(ctx, c, words) : searchRows(ctx, c, words, query.trim())));
213
350
  if (!hits.length) continue;
214
351
  body = html`<ul class="search-hits">${hits.map((h) => {
352
+ // (135) full-text results: ts_headline's highlights (stemmed words: "running" marks "run"); else the words
353
+ const title = h.headlines?.title ?? marked(h.title, words);
354
+ const subtitle = h.subtitle ? (h.headlines?.subtitle ?? marked(h.subtitle, words)) : null;
355
+ const description = h.description ? (h.headlines?.description ?? marked(h.description, words)) : null;
215
356
  const inner = html`${h.icon ? html`<span class="search-icon">${icon(h.icon)}</span>` : ''}
216
- <span class="search-text"><span class="search-title">${marked(h.title, words)}</span>${
217
- h.subtitle ? html`<span class="search-subtitle">${marked(h.subtitle, words)}</span>` : ''}${
218
- h.description ? html`<span class="search-description">${marked(h.description, words)}</span>` : ''}</span>
357
+ <span class="search-text"><span class="search-title">${title}</span>${
358
+ subtitle ? html`<span class="search-subtitle">${subtitle}</span>` : ''}${
359
+ description ? html`<span class="search-description">${description}</span>` : ''}</span>
219
360
  ${h.badge ? html`<span class="badge-pill">${marked(h.badge, words)}</span>` : ''}`;
220
361
  return html`<li>${h.href ? html`<a class="search-hit" ${h.href}>${inner}</a>` : html`<div class="search-hit">${inner}</div>`}</li>`;
221
362
  })}</ul>${more ? html`<p class="muted search-more">${ctx.locale.t('region.first_rows', { n: String(c.max_results) })}</p>` : ''}`;
@@ -226,7 +367,29 @@ async function searchResults(ctx: PageContext, r: Region, query: string): Promis
226
367
  groups.push(html`<section class="search-group"><h3>${ctx.locale.tr(c.label)}</h3>${body}</section>`);
227
368
  }
228
369
  // (a configuration that failed shows its error as a group of its own)
229
- return groups.length ? html`${groups}` : html`<p class="muted search-empty">${conf.empty ?? ctx.locale.t('lov.no_rows')}</p>`;
370
+ if (groups.length) return html`${groups}`;
371
+ return html`<p class="muted search-empty">${conf.empty ?? ctx.locale.t('lov.no_rows')}</p>${await didYouMean(ctx, r, configs, words)}`;
372
+ }
373
+
374
+ /** (135) Nothing found: the first suggestion of a configuration that asks for one (pg_trgm installed), as a link. */
375
+ async function didYouMean(ctx: PageContext, r: Region, configs: SearchConfig[], words: string[]): Promise<Raw | ''> {
376
+ const asking = configs.filter((c) => c.did_you_mean && c.source_type !== 'list' && !searchConfigProblems(c).length);
377
+ if (!asking.length) return '';
378
+ const trgm = await trigramSchema(ctx.client!);
379
+ if (!trgm) return '';
380
+ for (const c of asking) {
381
+ // a configuration that fails here suggests nothing (its error was shown, or there was none to show)
382
+ const s = await savepoint(ctx.client!, () => suggestion(ctx, c, words, trgm)).catch(() => null);
383
+ if (!s) continue;
384
+ const conf = (r.config ?? {}) as SearchRegionConfig;
385
+ const label = ctx.locale.t('search.did_you_mean');
386
+ // the region's own field: a link that searches for the suggestion; a page item: the words only
387
+ if (typeof conf.item === 'string' && conf.item) return html`<p class="muted search-suggestion">${label} <b>${s}</b></p>`;
388
+ const params = new URLSearchParams([...ctx.params].filter(([k]) => k !== searchParam(r)));
389
+ params.set(searchParam(r), s);
390
+ return html`<p class="muted search-suggestion">${label} <a href="${ctx.base}/${ctx.page.page_no}?${params}">${s}</a></p>`;
391
+ }
392
+ return '';
230
393
  }
231
394
 
232
395
  export async function renderSearch(ctx: PageContext, r: Region): Promise<Raw> {