@terrafirmagreg-team/search 1.2.0 → 1.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/search",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "description": "Section-aware search",
6
6
  "license": "LGPL-3.0-only",
@@ -14,14 +14,13 @@
14
14
  "./src/components/*.tsx",
15
15
  "./src/components/*/index.tsx",
16
16
  "./src/components/*/index.ts"
17
- ],
18
- "./locales": "./src/locales.ts"
17
+ ]
19
18
  },
20
19
  "dependencies": {
21
- "@terrafirmagreg-team/kit": "1.2.0",
22
- "@terrafirmagreg-team/sites": "1.2.0",
23
- "@terrafirmagreg-team/translate": "1.2.0",
24
- "@terrafirmagreg-team/ui": "1.2.0",
20
+ "@terrafirmagreg-team/kit": "1.2.2",
21
+ "@terrafirmagreg-team/sites": "1.2.1",
22
+ "@terrafirmagreg-team/translate": "1.2.3",
23
+ "@terrafirmagreg-team/ui": "1.2.2",
25
24
  "fumadocs-core": "16.17.8",
26
25
  "server-only": "^0.0.1"
27
26
  },
@@ -1,231 +1,231 @@
1
- 'use client';
2
- import {
3
- SearchDialog as SearchDialogRoot,
4
- SearchDialogClose,
5
- SearchDialogContent,
6
- SearchDialogFooter,
7
- SearchDialogHeader,
8
- SearchDialogIcon,
9
- SearchDialogInput,
10
- SearchDialogList,
11
- SearchDialogOverlay,
12
- SearchDialogListItem,
13
- type SharedProps,
14
- } from '@terrafirmagreg-team/ui/components/blocks/search/dialog';
15
- import {
16
- useDocsSearch,
17
- type SearchClient,
18
- } from 'fumadocs-core/search/client';
19
- import { staticClient } from 'fumadocs-core/search/client/orama-static';
20
- import { useResolvedLang } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
21
- import { usePathname } from 'next/navigation';
22
- import { useLocationSearchParams } from '@terrafirmagreg-team/ui/hooks/page/use-location-search';
23
- import { useMemo, useState } from 'react';
24
- import { Skeleton } from '@terrafirmagreg-team/ui/components/ui/skeleton';
25
- import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
26
- import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
27
- import { useSectionCatalog } from '@terrafirmagreg-team/kit/hooks/use-catalog';
28
- import { useSectionNames } from '@terrafirmagreg-team/kit/hooks/use-section-names';
29
- import {
30
- dumpSectionKeys,
31
- parseSectionLocation,
32
- } from '@terrafirmagreg-team/sites/location';
33
- import { searchHitFilter, mergeClients } from '../lib';
34
- import { getSearchClientSources } from '../lib/sources';
35
-
36
- const chip =
37
- 'rounded-md border px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer';
38
- const chipOn = 'bg-accent text-accent-foreground border-transparent';
39
- const chipOff = 'text-muted-foreground hover:text-foreground';
40
- const chipMixed = cn(chipOff, 'border-dashed');
41
-
42
- export function SearchDialog(props: SharedProps & { api: string }) {
43
- const locale = useResolvedLang();
44
- const t = useTranslations();
45
- const sectionNames = useSectionNames();
46
- const catalog = useSectionCatalog();
47
- const pathname = usePathname();
48
- const version = useLocationSearchParams().get('version') ?? undefined;
49
- const { section, dataset: locationDataset } =
50
- parseSectionLocation(pathname);
51
- const dataset = locationDataset;
52
- const scopeKey = `${section ?? ''}/${locationDataset ?? ''}`;
53
-
54
- const sections = useMemo(
55
- () =>
56
- Object.entries(catalog).filter(
57
- ([, datasets]) => datasets.length > 0,
58
- ),
59
- [catalog],
60
- );
61
- const defaultSel = useMemo((): Set<string> => {
62
- if (section && dataset) return new Set([`${section}:${dataset}`]);
63
- if (section)
64
- return new Set(
65
- (catalog[section] ?? []).map(({ id }) => `${section}:${id}`),
66
- );
67
- return new Set();
68
- }, [section, dataset, catalog]);
69
- const [picked, setPicked] = useState<{ key: string; sel: Set<string> }>();
70
- const sel = picked?.key === scopeKey ? picked.sel : defaultSel;
71
- const setSel = (value: Set<string>) =>
72
- setPicked({ key: scopeKey, sel: value });
73
- const toggle = (ids: readonly string[]) => {
74
- const next = new Set(sel);
75
- const allOn = ids.every((id) => next.has(id));
76
- for (const id of ids) {
77
- if (allOn) next.delete(id);
78
- else next.add(id);
79
- }
80
- setSel(next);
81
- };
82
-
83
- const client = useMemo((): SearchClient => {
84
- const from = `${props.api}/${locale}.json`;
85
- const content = staticClient({ locale, from });
86
- const selSecs = new Set([...sel].map((entry) => entry.split(':')[0]!));
87
- const scopedSections =
88
- sel.size === 0
89
- ? dumpSectionKeys()
90
- : dumpSectionKeys().filter((sec) => selSecs.has(sec));
91
- const refs = scopedSections.flatMap((sec) =>
92
- (catalog[sec] ?? []).map(({ id }) =>
93
- sec === section && id === dataset && locationDataset
94
- ? { id, version }
95
- : { id },
96
- ),
97
- );
98
- const extra = getSearchClientSources().map((make) =>
99
- make({ refs, locale, tag: undefined }),
100
- );
101
- return mergeClients(content, ...extra);
102
- }, [locale, sel, section, dataset, locationDataset, version, catalog, props.api]);
103
- const { search, setSearch, query } = useDocsSearch({ client });
104
- const showBadges =
105
- sel.size === 0 ||
106
- new Set([...sel].map((entry) => entry.split(':')[0]!)).size > 1;
107
- const items = useMemo(() => {
108
- const list = query.data !== 'empty' ? (query.data ?? []) : [];
109
- const inScope = (item: (typeof list)[number]) =>
110
- sel.size === 0 ||
111
- !('url' in item) ||
112
- [...sel].some((entry) =>
113
- item.url.startsWith(`/${entry.replace(':', '/')}`),
114
- );
115
- const hitFilter =
116
- section && typeof window !== 'undefined'
117
- ? searchHitFilter(
118
- section,
119
- new URLSearchParams(window.location.search),
120
- )
121
- : undefined;
122
- return list.filter(
123
- (item) => inScope(item) && (hitFilter?.(item) ?? true),
124
- );
125
- }, [query.data, section, sel]);
126
-
127
- return (
128
- <SearchDialogRoot search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
129
- <SearchDialogOverlay />
130
- <SearchDialogContent>
131
- <SearchDialogHeader>
132
- <SearchDialogIcon />
133
- <SearchDialogInput />
134
- <SearchDialogClose />
135
- </SearchDialogHeader>
136
- {query.isLoading ? (
137
- <div className="flex flex-col gap-1.5 p-3">
138
- <Skeleton className="h-9 w-full" />
139
- <Skeleton className="h-9 w-5/6" />
140
- <Skeleton className="h-9 w-4/6" />
141
- </div>
142
- ) : (
143
- <SearchDialogList
144
- items={items.length ? items : null}
145
- Item={(itemProps) => (
146
- <SearchDialogListItem
147
- {...itemProps}
148
- badge={
149
- showBadges && 'url' in itemProps.item
150
- ? scopeBadge(itemProps.item.url)
151
- : undefined
152
- }
153
- />
154
- )}
155
- />
156
- )}
157
- <SearchDialogFooter>
158
- <div className="flex flex-wrap items-center gap-1">
159
- <button
160
- type="button"
161
- aria-pressed={sel.size === 0}
162
- className={cn(chip, sel.size === 0 ? chipOn : chipOff)}
163
- onClick={() => setSel(new Set())}
164
- >
165
- {t('Everywhere', { note: 'ui.search_everywhere' })}
166
- </button>
167
- <span aria-hidden className="mx-0.5 h-4 w-px bg-border" />
168
- {sections.map(([sec, datasets]) => {
169
- const ids = datasets.map(({ id }) => `${sec}:${id}`);
170
- if (ids.length === 1) {
171
- const id = ids[0]!;
172
- const on = sel.has(id);
173
- return (
174
- <button
175
- key={sec}
176
- type="button"
177
- aria-pressed={on}
178
- className={cn(chip, on ? chipOn : chipOff)}
179
- onClick={() => toggle(ids)}
180
- >
181
- {sectionNames[sec]?.title ?? sec}
182
- </button>
183
- );
184
- }
185
- const onCount = ids.filter((id) => sel.has(id)).length;
186
- return [
187
- <button
188
- key={sec}
189
- type="button"
190
- aria-pressed={onCount > 0}
191
- className={cn(
192
- chip,
193
- onCount === ids.length
194
- ? chipOn
195
- : onCount > 0
196
- ? chipMixed
197
- : chipOff,
198
- )}
199
- onClick={() => toggle(ids)}
200
- >
201
- {sectionNames[sec]?.title ?? sec}
202
- </button>,
203
- ...datasets.map(({ id, title }) => {
204
- const entry = `${sec}:${id}`;
205
- const on = sel.has(entry);
206
- return (
207
- <button
208
- key={entry}
209
- type="button"
210
- aria-pressed={on}
211
- className={cn(chip, on ? chipOn : chipOff)}
212
- onClick={() => toggle([entry])}
213
- >
214
- {title ?? id}
215
- </button>
216
- );
217
- }),
218
- ];
219
- })}
220
- </div>
221
- </SearchDialogFooter>
222
- </SearchDialogContent>
223
- </SearchDialogRoot>
224
- );
225
-
226
- function scopeBadge(url: string) {
227
- const sec = url.split('/')[1];
228
- return sec && sec in catalog ? (sectionNames[sec]?.title ?? sec) : undefined;
229
- }
230
- }
231
-
1
+ 'use client';
2
+ import {
3
+ SearchDialog as SearchDialogRoot,
4
+ SearchDialogClose,
5
+ SearchDialogContent,
6
+ SearchDialogFooter,
7
+ SearchDialogHeader,
8
+ SearchDialogIcon,
9
+ SearchDialogInput,
10
+ SearchDialogList,
11
+ SearchDialogOverlay,
12
+ SearchDialogListItem,
13
+ type SharedProps,
14
+ } from '@terrafirmagreg-team/ui/components/blocks/search/dialog';
15
+ import {
16
+ useDocsSearch,
17
+ type SearchClient,
18
+ } from 'fumadocs-core/search/client';
19
+ import { staticClient } from 'fumadocs-core/search/client/orama-static';
20
+ import { useResolvedLang } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
21
+ import { usePathname } from 'next/navigation';
22
+ import { useLocationSearchParams } from '@terrafirmagreg-team/ui/hooks/page/use-location-search';
23
+ import { useMemo, useState } from 'react';
24
+ import { Skeleton } from '@terrafirmagreg-team/ui/components/ui/skeleton';
25
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
26
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
27
+ import { useSectionCatalog } from '@terrafirmagreg-team/kit/hooks/use-catalog';
28
+ import { useSectionNames } from '@terrafirmagreg-team/kit/hooks/use-section-names';
29
+ import {
30
+ dumpSectionKeys,
31
+ parseSectionLocation,
32
+ } from '@terrafirmagreg-team/sites/location';
33
+ import { searchHitFilter, mergeClients } from '../lib';
34
+ import { getSearchClientSources } from '../lib/sources';
35
+
36
+ const chip =
37
+ 'rounded-md border px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer';
38
+ const chipOn = 'bg-accent text-accent-foreground border-transparent';
39
+ const chipOff = 'text-muted-foreground hover:text-foreground';
40
+ const chipMixed = cn(chipOff, 'border-dashed');
41
+
42
+ export function SearchDialog(props: SharedProps & { api: string }) {
43
+ const locale = useResolvedLang();
44
+ const t = useTranslations();
45
+ const sectionNames = useSectionNames();
46
+ const catalog = useSectionCatalog();
47
+ const pathname = usePathname();
48
+ const version = useLocationSearchParams().get('version') ?? undefined;
49
+ const { section, dataset: locationDataset } =
50
+ parseSectionLocation(pathname);
51
+ const dataset = locationDataset;
52
+ const scopeKey = `${section ?? ''}/${locationDataset ?? ''}`;
53
+
54
+ const sections = useMemo(
55
+ () =>
56
+ Object.entries(catalog).filter(
57
+ ([, datasets]) => datasets.length > 0,
58
+ ),
59
+ [catalog],
60
+ );
61
+ const defaultSel = useMemo((): Set<string> => {
62
+ if (section && dataset) return new Set([`${section}:${dataset}`]);
63
+ if (section)
64
+ return new Set(
65
+ (catalog[section] ?? []).map(({ id }) => `${section}:${id}`),
66
+ );
67
+ return new Set();
68
+ }, [section, dataset, catalog]);
69
+ const [picked, setPicked] = useState<{ key: string; sel: Set<string> }>();
70
+ const sel = picked?.key === scopeKey ? picked.sel : defaultSel;
71
+ const setSel = (value: Set<string>) =>
72
+ setPicked({ key: scopeKey, sel: value });
73
+ const toggle = (ids: readonly string[]) => {
74
+ const next = new Set(sel);
75
+ const allOn = ids.every((id) => next.has(id));
76
+ for (const id of ids) {
77
+ if (allOn) next.delete(id);
78
+ else next.add(id);
79
+ }
80
+ setSel(next);
81
+ };
82
+
83
+ const client = useMemo((): SearchClient => {
84
+ const from = `${props.api}/${locale}.json`;
85
+ const content = staticClient({ locale, from });
86
+ const selSecs = new Set([...sel].map((entry) => entry.split(':')[0]!));
87
+ const scopedSections =
88
+ sel.size === 0
89
+ ? dumpSectionKeys()
90
+ : dumpSectionKeys().filter((sec) => selSecs.has(sec));
91
+ const refs = scopedSections.flatMap((sec) =>
92
+ (catalog[sec] ?? []).map(({ id }) =>
93
+ sec === section && id === dataset && locationDataset
94
+ ? { id, version }
95
+ : { id },
96
+ ),
97
+ );
98
+ const extra = getSearchClientSources().map((make) =>
99
+ make({ refs, locale, tag: undefined }),
100
+ );
101
+ return mergeClients(content, ...extra);
102
+ }, [locale, sel, section, dataset, locationDataset, version, catalog, props.api]);
103
+ const { search, setSearch, query } = useDocsSearch({ client });
104
+ const showBadges =
105
+ sel.size === 0 ||
106
+ new Set([...sel].map((entry) => entry.split(':')[0]!)).size > 1;
107
+ const items = useMemo(() => {
108
+ const list = query.data !== 'empty' ? (query.data ?? []) : [];
109
+ const inScope = (item: (typeof list)[number]) =>
110
+ sel.size === 0 ||
111
+ !('url' in item) ||
112
+ [...sel].some((entry) =>
113
+ item.url.startsWith(`/${entry.replace(':', '/')}`),
114
+ );
115
+ const hitFilter =
116
+ section && typeof window !== 'undefined'
117
+ ? searchHitFilter(
118
+ section,
119
+ new URLSearchParams(window.location.search),
120
+ )
121
+ : undefined;
122
+ return list.filter(
123
+ (item) => inScope(item) && (hitFilter?.(item) ?? true),
124
+ );
125
+ }, [query.data, section, sel]);
126
+
127
+ return (
128
+ <SearchDialogRoot search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
129
+ <SearchDialogOverlay />
130
+ <SearchDialogContent>
131
+ <SearchDialogHeader>
132
+ <SearchDialogIcon />
133
+ <SearchDialogInput />
134
+ <SearchDialogClose />
135
+ </SearchDialogHeader>
136
+ {query.isLoading ? (
137
+ <div className="flex flex-col gap-1.5 p-3">
138
+ <Skeleton className="h-9 w-full" />
139
+ <Skeleton className="h-9 w-5/6" />
140
+ <Skeleton className="h-9 w-4/6" />
141
+ </div>
142
+ ) : (
143
+ <SearchDialogList
144
+ items={items.length ? items : null}
145
+ Item={(itemProps) => (
146
+ <SearchDialogListItem
147
+ {...itemProps}
148
+ badge={
149
+ showBadges && 'url' in itemProps.item
150
+ ? scopeBadge(itemProps.item.url)
151
+ : undefined
152
+ }
153
+ />
154
+ )}
155
+ />
156
+ )}
157
+ <SearchDialogFooter>
158
+ <div className="flex flex-wrap items-center gap-1">
159
+ <button
160
+ type="button"
161
+ aria-pressed={sel.size === 0}
162
+ className={cn(chip, sel.size === 0 ? chipOn : chipOff)}
163
+ onClick={() => setSel(new Set())}
164
+ >
165
+ {t('Everywhere', { note: 'ui.search_everywhere' })}
166
+ </button>
167
+ <span aria-hidden className="mx-0.5 h-4 w-px bg-border" />
168
+ {sections.map(([sec, datasets]) => {
169
+ const ids = datasets.map(({ id }) => `${sec}:${id}`);
170
+ if (ids.length === 1) {
171
+ const id = ids[0]!;
172
+ const on = sel.has(id);
173
+ return (
174
+ <button
175
+ key={sec}
176
+ type="button"
177
+ aria-pressed={on}
178
+ className={cn(chip, on ? chipOn : chipOff)}
179
+ onClick={() => toggle(ids)}
180
+ >
181
+ {sectionNames[sec]?.title ?? sec}
182
+ </button>
183
+ );
184
+ }
185
+ const onCount = ids.filter((id) => sel.has(id)).length;
186
+ return [
187
+ <button
188
+ key={sec}
189
+ type="button"
190
+ aria-pressed={onCount > 0}
191
+ className={cn(
192
+ chip,
193
+ onCount === ids.length
194
+ ? chipOn
195
+ : onCount > 0
196
+ ? chipMixed
197
+ : chipOff,
198
+ )}
199
+ onClick={() => toggle(ids)}
200
+ >
201
+ {sectionNames[sec]?.title ?? sec}
202
+ </button>,
203
+ ...datasets.map(({ id, title }) => {
204
+ const entry = `${sec}:${id}`;
205
+ const on = sel.has(entry);
206
+ return (
207
+ <button
208
+ key={entry}
209
+ type="button"
210
+ aria-pressed={on}
211
+ className={cn(chip, on ? chipOn : chipOff)}
212
+ onClick={() => toggle([entry])}
213
+ >
214
+ {title ?? id}
215
+ </button>
216
+ );
217
+ }),
218
+ ];
219
+ })}
220
+ </div>
221
+ </SearchDialogFooter>
222
+ </SearchDialogContent>
223
+ </SearchDialogRoot>
224
+ );
225
+
226
+ function scopeBadge(url: string) {
227
+ const sec = url.split('/')[1];
228
+ return sec && sec in catalog ? (sectionNames[sec]?.title ?? sec) : undefined;
229
+ }
230
+ }
231
+
@@ -1,3 +0,0 @@
1
- {
2
- "Everywhere(ui.search_everywhere)": "Everywhere"
3
- }
@@ -1,3 +0,0 @@
1
- {
2
- "Everywhere(ui.search_everywhere)": "Везде"
3
- }
package/src/locales.ts DELETED
@@ -1,6 +0,0 @@
1
- import { createLocaleCatalog } from '@terrafirmagreg-team/translate/lib/locales';
2
-
3
- export const locales = createLocaleCatalog(
4
- import.meta.glob('./locales/*.json', { eager: true, import: 'default' }),
5
- );
6
-