@terrafirmagreg-team/search 0.0.0-stage → 1.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/package.json +45 -3
- package/src/components/dialog.tsx +231 -0
- package/src/lib/config.ts +14 -0
- package/src/lib/filters.ts +23 -0
- package/src/lib/index.ts +20 -0
- package/src/lib/indexes.ts +48 -0
- package/src/lib/merge.ts +23 -0
- package/src/lib/server.ts +56 -0
- package/src/lib/sources.ts +21 -0
- package/src/lib/structured.ts +26 -0
- package/src/locales/en_us.json +3 -0
- package/src/locales/ru_ru.json +3 -0
- package/src/locales.ts +6 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrafirmagreg-team/search",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Section-aware search",
|
|
6
|
+
"license": "LGPL-3.0-only",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./lib": "./src/lib/index.ts",
|
|
9
|
+
"./lib/*": [
|
|
10
|
+
"./src/lib/*.ts",
|
|
11
|
+
"./src/lib/*/index.ts"
|
|
12
|
+
],
|
|
13
|
+
"./components/*": [
|
|
14
|
+
"./src/components/*.tsx",
|
|
15
|
+
"./src/components/*/index.tsx",
|
|
16
|
+
"./src/components/*/index.ts"
|
|
17
|
+
],
|
|
18
|
+
"./locales": "./src/locales.ts"
|
|
19
|
+
},
|
|
20
|
+
"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",
|
|
25
|
+
"fumadocs-core": "16.17.8",
|
|
26
|
+
"server-only": "^0.0.1"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"next": ">=15",
|
|
30
|
+
"react": ">=19"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^26.6.5",
|
|
34
|
+
"@types/react": "^19.3.0",
|
|
35
|
+
"@types/react-dom": "^19.3.0"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
|
|
40
|
+
"directory": "packages/search"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"src"
|
|
47
|
+
]
|
|
6
48
|
}
|
|
@@ -0,0 +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
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LangConfig } from '@terrafirmagreg-team/translate/lang';
|
|
2
|
+
import { createConfig } from '@terrafirmagreg-team/kit/lib/config';
|
|
3
|
+
import type { LocalizedIndex } from './indexes';
|
|
4
|
+
|
|
5
|
+
export interface SearchConfig {
|
|
6
|
+
|
|
7
|
+
indexes(): Promise<LocalizedIndex[]>;
|
|
8
|
+
|
|
9
|
+
i18n(): LangConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const { register: registerSearchConfig, get: searchConfig } =
|
|
13
|
+
createConfig<SearchConfig>('Search');
|
|
14
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SortedResult } from '@terrafirmagreg-team/ui/types/search';
|
|
2
|
+
|
|
3
|
+
export type SearchHitFilter = (hit: SortedResult) => boolean;
|
|
4
|
+
export type SearchHitFilterFactory = (
|
|
5
|
+
params: URLSearchParams,
|
|
6
|
+
) => SearchHitFilter | undefined;
|
|
7
|
+
|
|
8
|
+
const hitFilters = new Map<string, SearchHitFilterFactory>();
|
|
9
|
+
|
|
10
|
+
export function registerSearchHitFilter(
|
|
11
|
+
section: string,
|
|
12
|
+
factory: SearchHitFilterFactory,
|
|
13
|
+
): void {
|
|
14
|
+
hitFilters.set(section, factory);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function searchHitFilter(
|
|
18
|
+
section: string,
|
|
19
|
+
params: URLSearchParams,
|
|
20
|
+
): SearchHitFilter | undefined {
|
|
21
|
+
return hitFilters.get(section)?.(params);
|
|
22
|
+
}
|
|
23
|
+
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { mergeClients } from './merge';
|
|
2
|
+
export { pageStructuredData } from './structured';
|
|
3
|
+
export {
|
|
4
|
+
buildSearchIndexes,
|
|
5
|
+
type LocalizedIndex,
|
|
6
|
+
type SearchIndexPage,
|
|
7
|
+
type SearchPageSource,
|
|
8
|
+
} from './indexes';
|
|
9
|
+
export {
|
|
10
|
+
registerSearchConfig,
|
|
11
|
+
searchConfig,
|
|
12
|
+
type SearchConfig,
|
|
13
|
+
} from './config';
|
|
14
|
+
export {
|
|
15
|
+
registerSearchHitFilter,
|
|
16
|
+
searchHitFilter,
|
|
17
|
+
type SearchHitFilter,
|
|
18
|
+
type SearchHitFilterFactory,
|
|
19
|
+
} from './filters';
|
|
20
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AdvancedIndex } from 'fumadocs-core/search/server';
|
|
2
|
+
import { stripLangFromUrl } from '@terrafirmagreg-team/translate/lib/slug';
|
|
3
|
+
import { pageStructuredData } from './structured';
|
|
4
|
+
|
|
5
|
+
export type LocalizedIndex = AdvancedIndex & { locale: string };
|
|
6
|
+
|
|
7
|
+
export interface SearchIndexPage {
|
|
8
|
+
slugs: string[];
|
|
9
|
+
url: string;
|
|
10
|
+
data: { title?: string; description?: string };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchPageSource {
|
|
14
|
+
getSource(): Promise<{ getPages(): SearchIndexPage[] }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function buildSearchIndexes(
|
|
18
|
+
sources: Record<string, SearchPageSource | undefined>,
|
|
19
|
+
languages: readonly string[],
|
|
20
|
+
): Promise<LocalizedIndex[]> {
|
|
21
|
+
const pages: { section: string; page: SearchIndexPage }[] = [];
|
|
22
|
+
for (const [section, entry] of Object.entries(sources)) {
|
|
23
|
+
if (!entry) continue;
|
|
24
|
+
for (const page of (await entry.getSource()).getPages()) {
|
|
25
|
+
pages.push({ section, page });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return (
|
|
29
|
+
await Promise.all(
|
|
30
|
+
pages.map(async ({ section, page }) => {
|
|
31
|
+
const [dataset, lang] = page.slugs;
|
|
32
|
+
if (!dataset || !lang) return null;
|
|
33
|
+
return {
|
|
34
|
+
title: page.data.title ?? page.url,
|
|
35
|
+
description: page.data.description,
|
|
36
|
+
url: stripLangFromUrl(page.url, [...languages]),
|
|
37
|
+
id: page.url,
|
|
38
|
+
tag: `${section}:${dataset}`,
|
|
39
|
+
locale: lang,
|
|
40
|
+
structuredData: await pageStructuredData(
|
|
41
|
+
page.data as Record<string, unknown>,
|
|
42
|
+
),
|
|
43
|
+
} satisfies LocalizedIndex;
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
).filter((index) => index !== null);
|
|
47
|
+
}
|
|
48
|
+
|
package/src/lib/merge.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SearchClient } from 'fumadocs-core/search/client';
|
|
2
|
+
|
|
3
|
+
export function mergeClients(...clients: SearchClient[]): SearchClient {
|
|
4
|
+
return {
|
|
5
|
+
deps: clients.flatMap((client) => client.deps ?? []),
|
|
6
|
+
async search(query) {
|
|
7
|
+
const lists = await Promise.all(
|
|
8
|
+
clients.map((client) => client.search(query)),
|
|
9
|
+
);
|
|
10
|
+
const seen = new Set<string>();
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const list of lists) {
|
|
13
|
+
for (const hit of list) {
|
|
14
|
+
if (seen.has(hit.id)) continue;
|
|
15
|
+
seen.add(hit.id);
|
|
16
|
+
out.push(hit);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { initAdvancedSearch } from 'fumadocs-core/search/server';
|
|
3
|
+
import { searchConfig } from './config';
|
|
4
|
+
import type { LocalizedIndex } from './indexes';
|
|
5
|
+
|
|
6
|
+
type LangServer = { export(): Promise<unknown> };
|
|
7
|
+
|
|
8
|
+
async function createLangServers() {
|
|
9
|
+
const config = searchConfig();
|
|
10
|
+
const indexes = await config.indexes();
|
|
11
|
+
const byLang = new Map<string, LocalizedIndex[]>();
|
|
12
|
+
for (const index of indexes) {
|
|
13
|
+
const list = byLang.get(index.locale) ?? [];
|
|
14
|
+
if (!list.length) byLang.set(index.locale, list);
|
|
15
|
+
list.push(index);
|
|
16
|
+
}
|
|
17
|
+
const servers = new Map<string, LangServer>();
|
|
18
|
+
for (const [lang, langIndexes] of byLang) {
|
|
19
|
+
servers.set(
|
|
20
|
+
lang,
|
|
21
|
+
initAdvancedSearch({ indexes: langIndexes, localeFilter: true }),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return servers;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let cached: Promise<Map<string, LangServer>> | undefined;
|
|
28
|
+
|
|
29
|
+
function langServers() {
|
|
30
|
+
const p = (cached ??= createLangServers());
|
|
31
|
+
p.catch(() => {
|
|
32
|
+
if (cached === p) cached = undefined;
|
|
33
|
+
});
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function exportFor(langs: Iterable<string>) {
|
|
38
|
+
const servers = await langServers();
|
|
39
|
+
const data: Record<string, unknown> = {};
|
|
40
|
+
await Promise.all(
|
|
41
|
+
[...langs].map(async (lang) => {
|
|
42
|
+
const server = servers.get(lang);
|
|
43
|
+
if (server) data[lang] = await server.export();
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
return { type: 'i18n' as const, data };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function searchExport() {
|
|
50
|
+
return langServers().then((servers) => exportFor(servers.keys()));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function searchExportLang(lang: string) {
|
|
54
|
+
return exportFor([lang]);
|
|
55
|
+
}
|
|
56
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
|
|
2
|
+
import type { SearchClient } from 'fumadocs-core/search/client';
|
|
3
|
+
|
|
4
|
+
export type SearchClientSourceCtx = {
|
|
5
|
+
refs: DatasetRef[];
|
|
6
|
+
locale: string;
|
|
7
|
+
tag?: string | string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SearchClientSource = (ctx: SearchClientSourceCtx) => SearchClient;
|
|
11
|
+
|
|
12
|
+
const sources: SearchClientSource[] = [];
|
|
13
|
+
|
|
14
|
+
export function registerSearchClientSource(source: SearchClientSource): void {
|
|
15
|
+
sources.push(source);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSearchClientSources(): readonly SearchClientSource[] {
|
|
19
|
+
return sources;
|
|
20
|
+
}
|
|
21
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { StructuredData } from 'fumadocs-core/mdx-plugins';
|
|
2
|
+
|
|
3
|
+
type StructuredPageData = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
export async function pageStructuredData(
|
|
6
|
+
data: StructuredPageData,
|
|
7
|
+
): Promise<StructuredData> {
|
|
8
|
+
const raw = data.structuredData;
|
|
9
|
+
if (typeof raw === 'function') {
|
|
10
|
+
const result = await (raw as () => Promise<StructuredData> | StructuredData)();
|
|
11
|
+
if (!result) throw new Error('Cannot find structured data from page');
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
if (typeof data.load === 'function') {
|
|
15
|
+
const loaded = await (
|
|
16
|
+
data.load as () => Promise<{ structuredData?: StructuredData }>
|
|
17
|
+
)();
|
|
18
|
+
if (!loaded.structuredData) {
|
|
19
|
+
throw new Error('Cannot find structured data from page');
|
|
20
|
+
}
|
|
21
|
+
return loaded.structuredData;
|
|
22
|
+
}
|
|
23
|
+
if (!raw) throw new Error('Cannot find structured data from page');
|
|
24
|
+
return raw as StructuredData;
|
|
25
|
+
}
|
|
26
|
+
|
package/src/locales.ts
ADDED
package/README.md
DELETED