typesense-nextra-adapter 0.0.1

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.
@@ -0,0 +1,184 @@
1
+ :root {
2
+ --docsearch-primary-color: hsl(var(--nextra-primary-hue, 212) 100% 50%);
3
+ --docsearch-text-color: #111827;
4
+ --docsearch-muted-color: #4b5563;
5
+ --docsearch-container-background: #1818184d;
6
+ --docsearch-search-button-background: #0000000d;
7
+ --docsearch-search-button-hover-background: transparent;
8
+ --docsearch-search-button-text-color: #111827;
9
+ --docsearch-modal-background: #fff;
10
+ --docsearch-modal-shadow: 0 20px 25px -5px #0000001a, 0 10px 10px -5px #0000000a;
11
+ --docsearch-modal-width: 640px;
12
+ --docsearch-searchbox-background: #0000000d;
13
+ --docsearch-searchbox-focus-background: transparent;
14
+ --docsearch-searchbox-shadow: inset 0 0 0 2px var(--docsearch-primary-color);
15
+ --docsearch-hit-color: #1f2937;
16
+ --docsearch-hit-active-color: var(--docsearch-primary-color);
17
+ --docsearch-hit-background: #fafafa;
18
+ --docsearch-hit-shadow: none;
19
+ --docsearch-key-gradient: none;
20
+ --docsearch-key-shadow: none;
21
+ --docsearch-key-background: var(--nextra-bg, #fff);
22
+ --docsearch-key-color: #4b5563;
23
+ --docsearch-spacing: 12px;
24
+ --docsearch-footer-background: transparent;
25
+ --docsearch-footer-shadow: none;
26
+ }
27
+
28
+ html.dark {
29
+ --docsearch-text-color: #dadee4;
30
+ --docsearch-muted-color: #8f96a1;
31
+ --docsearch-search-button-background: #f9fafb1a;
32
+ --docsearch-search-button-text-color: #d1d5db;
33
+ --docsearch-modal-background: #111;
34
+ --docsearch-modal-shadow: 0 20px 25px -5px #00000080;
35
+ --docsearch-searchbox-background: #f9fafb1a;
36
+ --docsearch-hit-color: #d1d5db;
37
+ --docsearch-hit-background: #181818;
38
+ --docsearch-key-background: var(--nextra-bg, #111);
39
+ --docsearch-key-color: #9ca3af;
40
+ --docsearch-logo-color: #d1d5db;
41
+ }
42
+
43
+ .DocSearch-Container {
44
+ -webkit-backdrop-filter: blur(4px);
45
+ backdrop-filter: blur(4px);
46
+ }
47
+
48
+ #docsearch-list {
49
+ flex-direction: column;
50
+ gap: .25rem;
51
+ display: flex;
52
+ }
53
+
54
+ .DocSearch-Modal {
55
+ -webkit-backdrop-filter: blur(12px);
56
+ backdrop-filter: blur(12px);
57
+ border: 1px solid #e5e7eb;
58
+ }
59
+
60
+ html.dark .DocSearch-Modal {
61
+ border-color: #262626;
62
+ }
63
+
64
+ .DocSearch-Button {
65
+ border: 1px solid #0000;
66
+ border-radius: .5rem;
67
+ width: 100%;
68
+ margin: 0;
69
+ padding: .5rem .75rem;
70
+ transition: all .2s;
71
+ }
72
+
73
+ @media (min-width: 768px) {
74
+ .DocSearch-Button {
75
+ width: 16rem;
76
+ }
77
+ }
78
+
79
+ .DocSearch-Button:hover, .DocSearch-Button:focus {
80
+ border-color: var(--docsearch-primary-color);
81
+ box-shadow: 0 0 0 1px var(--docsearch-primary-color);
82
+ background: none;
83
+ }
84
+
85
+ .DocSearch-Button-Placeholder {
86
+ color: #4b5563;
87
+ font-size: 1rem;
88
+ line-height: 1.25;
89
+ }
90
+
91
+ html.dark .DocSearch-Button-Placeholder {
92
+ color: #9ca3af;
93
+ }
94
+
95
+ @media (min-width: 768px) {
96
+ .DocSearch-Button-Placeholder {
97
+ font-size: .875rem;
98
+ }
99
+ }
100
+
101
+ .DocSearch-Button-Keys {
102
+ gap: .25rem;
103
+ min-width: auto;
104
+ }
105
+
106
+ .DocSearch-Button-Key {
107
+ height: 1.25rem;
108
+ font-size: 11px;
109
+ font-family: var(--font-mono, monospace);
110
+ box-shadow: none;
111
+ border: 1px solid #0000001a;
112
+ border-radius: .25rem;
113
+ align-items: center;
114
+ margin: 0;
115
+ padding: 0 .375rem;
116
+ font-weight: 500;
117
+ display: flex;
118
+ }
119
+
120
+ html.dark .DocSearch-Button-Key {
121
+ border-color: #fff3;
122
+ }
123
+
124
+ .DocSearch-Hit-source {
125
+ color: var(--docsearch-muted-color);
126
+ padding: .5rem .625rem 0;
127
+ font-weight: 600;
128
+ }
129
+
130
+ .DocSearch-Hit {
131
+ padding-bottom: 0;
132
+ }
133
+
134
+ .DocSearch-Hit a {
135
+ box-shadow: none;
136
+ border: 1px solid #0000;
137
+ border-radius: .375rem;
138
+ transition: all .2s;
139
+ }
140
+
141
+ .DocSearch-Hit-title, .DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-title {
142
+ margin-bottom: 2px;
143
+ color: var(--docsearch-hit-color) !important;
144
+ }
145
+
146
+ .DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-path {
147
+ color: var(--docsearch-muted-color) !important;
148
+ }
149
+
150
+ .DocSearch-Hit[aria-selected="true"] a {
151
+ background-color: hsla(var(--nextra-primary-hue, 212),
152
+ 100%,
153
+ 50%,
154
+ .1);
155
+ color: var(--docsearch-primary-color);
156
+ }
157
+
158
+ .DocSearch-Hit mark, .DocSearch-Hit[aria-selected="true"] mark {
159
+ border-radius: 2px;
160
+ text-decoration: none;
161
+ background: hsla(var(--nextra-primary-hue, 212), 100%, 50%, .8) !important;
162
+ color: #fff !important;
163
+ }
164
+
165
+ .DocSearch-Screen-Icon svg {
166
+ margin: auto;
167
+ }
168
+
169
+ .DocSearch-Footer {
170
+ border-top: 1px solid #0000001a;
171
+ }
172
+
173
+ html.dark .DocSearch-Footer {
174
+ border-color: #ffffff1a;
175
+ }
176
+
177
+ .DocSearch-Commands-Key {
178
+ background-color: #e6e9eb;
179
+ }
180
+
181
+ html.dark .DocSearch-Commands-Key {
182
+ background-color: #22262d;
183
+ }
184
+
@@ -0,0 +1,22 @@
1
+ import type { Locales } from './locales.js';
2
+ import type { DocSearchProps } from 'typesense-docsearch-react';
3
+ import 'typesense-docsearch-css';
4
+ import './Search.css';
5
+ export type TypesenseDocSearchProps = Omit<DocSearchProps, 'translations' | 'typesenseCollectionName' | 'typesenseSearchParameters'> & {
6
+ typesenseSearchParameters?: DocSearchProps['typesenseSearchParameters'];
7
+ };
8
+ export type SearchProps = {
9
+ docSearchProps: TypesenseDocSearchProps;
10
+ locales?: Locales;
11
+ /**
12
+ * The base collection name (e.g. 'my_docs').
13
+ * The component will auto-append the locale (e.g., 'my_docs_en').
14
+ */
15
+ collectionName: string;
16
+ /**
17
+ * Explicitly set the language.
18
+ * If omitted, it will attempt to detect it from Next.js route params ([lang] or [locale]).
19
+ */
20
+ lang?: string;
21
+ };
22
+ export declare function Search({ lang, ...props }: SearchProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,134 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
4
+ import { useParams } from "next/navigation";
5
+ import "typesense-docsearch-css";
6
+ import "./Search.css";
7
+ const RealSearch = /*#__PURE__*/ lazy(()=>import("./RealSearch.js"));
8
+ function Search({ lang, ...props }) {
9
+ const [isLoaded, setIsLoaded] = useState(false);
10
+ const isLoadedRef = useRef(false);
11
+ const triggerRef = useRef(null);
12
+ const params = useParams();
13
+ const detectedLang = lang || params?.lang || params?.locale || 'en';
14
+ const placeholder = props.locales?.[detectedLang]?.placeholder || 'Search';
15
+ const loadSearch = useCallback((trigger)=>{
16
+ if (!triggerRef.current) triggerRef.current = trigger;
17
+ if (!isLoadedRef.current) {
18
+ isLoadedRef.current = true;
19
+ setIsLoaded(true);
20
+ }
21
+ }, []);
22
+ useEffect(()=>{
23
+ let idleHandle;
24
+ idleHandle = 'requestIdleCallback' in window ? window.requestIdleCallback(()=>loadSearch('idle')) : setTimeout(()=>loadSearch('idle'), 2000);
25
+ const handleKeyDown = (event)=>{
26
+ const target = event.target;
27
+ if ('INPUT' === target.tagName || 'TEXTAREA' === target.tagName || target.isContentEditable) return;
28
+ const isCmdK = (event.metaKey || event.ctrlKey) && 'k' === event.key.toLowerCase();
29
+ const isSlash = '/' === event.key;
30
+ if (isCmdK || isSlash) {
31
+ if (!isLoadedRef.current) {
32
+ event.preventDefault();
33
+ loadSearch('keyboard');
34
+ }
35
+ }
36
+ };
37
+ window.addEventListener('keydown', handleKeyDown);
38
+ return ()=>{
39
+ if ('requestIdleCallback' in window) window.cancelIdleCallback(idleHandle);
40
+ else clearTimeout(idleHandle);
41
+ window.removeEventListener('keydown', handleKeyDown);
42
+ };
43
+ }, [
44
+ loadSearch
45
+ ]);
46
+ useEffect(()=>{
47
+ if (isLoaded && 'keyboard' === triggerRef.current) {
48
+ const interval = setInterval(()=>{
49
+ const button = document.querySelector('.DocSearch-Button:not(.DocSearch-Fake-Button)');
50
+ if (button) {
51
+ button.click();
52
+ clearInterval(interval);
53
+ triggerRef.current = null;
54
+ }
55
+ }, 50);
56
+ const timeout = setTimeout(()=>clearInterval(interval), 5000);
57
+ return ()=>{
58
+ clearInterval(interval);
59
+ clearTimeout(timeout);
60
+ };
61
+ }
62
+ }, [
63
+ isLoaded
64
+ ]);
65
+ return /*#__PURE__*/ jsx(Fragment, {
66
+ children: isLoaded ? /*#__PURE__*/ jsx(Suspense, {
67
+ fallback: /*#__PURE__*/ jsx(FakeSearchButton, {
68
+ placeholder: placeholder,
69
+ onMouseEnter: ()=>loadSearch('hover')
70
+ }),
71
+ children: /*#__PURE__*/ jsx(RealSearch, {
72
+ lang: detectedLang,
73
+ ...props
74
+ })
75
+ }) : /*#__PURE__*/ jsx(FakeSearchButton, {
76
+ placeholder: placeholder,
77
+ onClick: ()=>loadSearch('hover'),
78
+ onMouseEnter: ()=>loadSearch('hover')
79
+ })
80
+ });
81
+ }
82
+ function FakeSearchButton({ onClick, onMouseEnter, placeholder = 'Search' }) {
83
+ const [modifierKey, setModifierKey] = useState(null);
84
+ useEffect(()=>{
85
+ const isMac = /(Mac|iPhone|iPod|iPad)/i.test("u" > typeof navigator ? navigator.platform : '');
86
+ setModifierKey(isMac ? '⌘' : 'Ctrl');
87
+ }, []);
88
+ return /*#__PURE__*/ jsxs("button", {
89
+ type: "button",
90
+ className: "DocSearch DocSearch-Button DocSearch-Fake-Button",
91
+ "aria-label": "Search",
92
+ onClick: onClick,
93
+ onMouseEnter: onMouseEnter,
94
+ children: [
95
+ /*#__PURE__*/ jsxs("span", {
96
+ className: "DocSearch-Button-Container",
97
+ children: [
98
+ /*#__PURE__*/ jsx("svg", {
99
+ width: "20",
100
+ height: "20",
101
+ className: "DocSearch-Search-Icon",
102
+ viewBox: "0 0 20 20",
103
+ children: /*#__PURE__*/ jsx("path", {
104
+ d: "M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z",
105
+ stroke: "currentColor",
106
+ fill: "none",
107
+ fillRule: "evenodd",
108
+ strokeLinecap: "round",
109
+ strokeLinejoin: "round"
110
+ })
111
+ }),
112
+ /*#__PURE__*/ jsx("span", {
113
+ className: "DocSearch-Button-Placeholder",
114
+ children: placeholder
115
+ })
116
+ ]
117
+ }),
118
+ /*#__PURE__*/ jsxs("span", {
119
+ className: "DocSearch-Button-Keys",
120
+ children: [
121
+ /*#__PURE__*/ jsx("kbd", {
122
+ className: "DocSearch-Button-Key",
123
+ children: modifierKey
124
+ }),
125
+ /*#__PURE__*/ jsx("kbd", {
126
+ className: "DocSearch-Button-Key",
127
+ children: "K"
128
+ })
129
+ ]
130
+ })
131
+ ]
132
+ });
133
+ }
134
+ export { Search };
@@ -0,0 +1,3 @@
1
+ export { type Locales, RU_LOCALES, ZH_LOCALES, VN_LOCALES } from './locales.js';
2
+ export { Search, type SearchProps } from './Search.js';
3
+ export type { TypesenseDocSearchProps } from './Search.js';
@@ -0,0 +1,2 @@
1
+ export { RU_LOCALES, VN_LOCALES, ZH_LOCALES } from "./locales.js";
2
+ export { Search } from "./Search.js";
@@ -0,0 +1,8 @@
1
+ import type { DocSearchProps } from 'typesense-docsearch-react';
2
+ export type Locales = Record<string, {
3
+ translations: DocSearchProps['translations'];
4
+ placeholder: string;
5
+ }>;
6
+ export declare const ZH_LOCALES: Locales;
7
+ export declare const RU_LOCALES: Locales;
8
+ export declare const VN_LOCALES: Locales;
@@ -0,0 +1,130 @@
1
+ const ZH_LOCALES = {
2
+ zh: {
3
+ placeholder: '搜索文档',
4
+ translations: {
5
+ button: {
6
+ buttonText: '搜索',
7
+ buttonAriaLabel: '搜索'
8
+ },
9
+ modal: {
10
+ searchBox: {
11
+ resetButtonTitle: '清除查询条件',
12
+ resetButtonAriaLabel: '清除查询条件',
13
+ cancelButtonText: '取消',
14
+ cancelButtonAriaLabel: '取消'
15
+ },
16
+ startScreen: {
17
+ recentSearchesTitle: '搜索历史',
18
+ noRecentSearchesText: '没有搜索历史',
19
+ saveRecentSearchButtonTitle: '保存至搜索历史',
20
+ removeRecentSearchButtonTitle: '从搜索历史中移除',
21
+ favoriteSearchesTitle: '收藏',
22
+ removeFavoriteSearchButtonTitle: '从收藏中移除'
23
+ },
24
+ errorScreen: {
25
+ titleText: '无法获取结果',
26
+ helpText: '你可能需要检查你的网络连接'
27
+ },
28
+ footer: {
29
+ selectText: '选择',
30
+ navigateText: '切换',
31
+ closeText: '关闭',
32
+ searchByText: '搜索提供者'
33
+ },
34
+ noResultsScreen: {
35
+ noResultsText: '无法找到相关结果',
36
+ suggestedQueryText: '你可以尝试查询',
37
+ reportMissingResultsText: '你认为该查询应该有结果?',
38
+ reportMissingResultsLinkText: '点击反馈'
39
+ }
40
+ }
41
+ }
42
+ }
43
+ };
44
+ const RU_LOCALES = {
45
+ ru: {
46
+ placeholder: 'Поиск в документации',
47
+ translations: {
48
+ button: {
49
+ buttonText: 'Поиск',
50
+ buttonAriaLabel: 'Поиск'
51
+ },
52
+ modal: {
53
+ searchBox: {
54
+ resetButtonTitle: 'Очистить поиск',
55
+ resetButtonAriaLabel: 'Очистить поиск',
56
+ cancelButtonText: 'Закрыть',
57
+ cancelButtonAriaLabel: 'Закрыть'
58
+ },
59
+ startScreen: {
60
+ recentSearchesTitle: 'История поиска',
61
+ noRecentSearchesText: 'Нет истории поиска',
62
+ saveRecentSearchButtonTitle: 'Сохранить в истории поиска',
63
+ removeRecentSearchButtonTitle: 'Удалить из истории поиска',
64
+ favoriteSearchesTitle: 'Избранное',
65
+ removeFavoriteSearchButtonTitle: 'Удалить из избранного'
66
+ },
67
+ errorScreen: {
68
+ titleText: 'Невозможно получить результаты',
69
+ helpText: 'Проверьте подключение к Интернету'
70
+ },
71
+ footer: {
72
+ selectText: 'выбрать',
73
+ navigateText: 'перейти',
74
+ closeText: 'закрыть',
75
+ searchByText: 'поиск от'
76
+ },
77
+ noResultsScreen: {
78
+ noResultsText: 'Ничего не найдено',
79
+ suggestedQueryText: 'Попробуйте изменить запрос',
80
+ reportMissingResultsText: 'Считаете, что результаты должны быть?',
81
+ reportMissingResultsLinkText: 'Сообщите об этом'
82
+ }
83
+ }
84
+ }
85
+ }
86
+ };
87
+ const VN_LOCALES = {
88
+ vn: {
89
+ placeholder: 'Tìm kiếm tài liệu',
90
+ translations: {
91
+ button: {
92
+ buttonText: 'Tìm kiếm',
93
+ buttonAriaLabel: 'Tìm kiếm'
94
+ },
95
+ modal: {
96
+ searchBox: {
97
+ resetButtonTitle: 'Xóa truy vấn',
98
+ resetButtonAriaLabel: 'Xóa truy vấn',
99
+ cancelButtonText: 'Hủy',
100
+ cancelButtonAriaLabel: 'Hủy'
101
+ },
102
+ startScreen: {
103
+ recentSearchesTitle: 'Gần đây',
104
+ noRecentSearchesText: 'Chưa có tìm kiếm gần đây',
105
+ saveRecentSearchButtonTitle: 'Lưu tìm kiếm này',
106
+ removeRecentSearchButtonTitle: 'Xóa tìm kiếm này khỏi lịch sử',
107
+ favoriteSearchesTitle: 'Yêu thích',
108
+ removeFavoriteSearchButtonTitle: 'Xóa tìm kiếm này khỏi mục yêu thích'
109
+ },
110
+ errorScreen: {
111
+ titleText: 'Không thể tải kết quả',
112
+ helpText: 'Hãy kiểm tra lại kết nối mạng của bạn.'
113
+ },
114
+ footer: {
115
+ selectText: 'để chọn',
116
+ navigateText: 'để di chuyển',
117
+ closeText: 'để đóng',
118
+ searchByText: 'Vận hành bởi'
119
+ },
120
+ noResultsScreen: {
121
+ noResultsText: 'Không có kết quả cho',
122
+ suggestedQueryText: 'Hãy thử tìm với từ khóa',
123
+ reportMissingResultsText: 'Bạn nghĩ truy vấn này nên có kết quả?',
124
+ reportMissingResultsLinkText: 'Hãy cho chúng tôi biết.'
125
+ }
126
+ }
127
+ }
128
+ }
129
+ };
130
+ export { RU_LOCALES, VN_LOCALES, ZH_LOCALES };
@@ -0,0 +1,39 @@
1
+ import type { ConfigurationOptions } from 'typesense/lib/Typesense/Configuration';
2
+ import type { CustomCollectionSettingsConfig, DocSearchRecord } from './types.js';
3
+ import { getDefaultCollectionFields } from './typesenseHelper.js';
4
+ export type { CustomCollectionSettings, CustomCollectionSettingsConfig, DocSearchRecord, } from './types.js';
5
+ export { getDefaultCollectionFields };
6
+ export interface NextraTypesenseOptions {
7
+ /** Typesense server connection options. */
8
+ serverConfig: ConfigurationOptions;
9
+ /** The base name of the Typesense collection. */
10
+ collectionName: string;
11
+ /** Optional schema overrides. Can be a global settings object or a map keyed by language. */
12
+ customCollectionSettings?: CustomCollectionSettingsConfig;
13
+ /** Whether to index code blocks into Typesense. Defaults to `false`. */
14
+ indexCodeBlocks?: boolean;
15
+ /** Whether a failed indexing attempt should crash the process. Defaults to `true`. */
16
+ failOnIndexError?: boolean;
17
+ /** Hook to mutate or enrich the record before it gets indexed. */
18
+ transformRecord?: (record: DocSearchRecord, route: {
19
+ routePath: string;
20
+ filePath: string;
21
+ }) => DocSearchRecord;
22
+ /** The output directory to scan for HTML files. Defaults to '.next/server/app' */
23
+ outDir?: string;
24
+ /** Default language if the <html lang="..."> attribute is missing. Defaults to 'en' */
25
+ defaultLang?: string;
26
+ /**
27
+ * Maximum number of files to read and parse concurrently.
28
+ * Prevents "Too many open files" OS errors.
29
+ * @default 10
30
+ */
31
+ concurrencyLimit?: number;
32
+ /**
33
+ * Maximum number of records to accumulate before sending a batch request to Typesense.
34
+ * Lower this if your payloads are extremely large and hitting Typesense payload limits.
35
+ * @default 200
36
+ */
37
+ batchSize?: number;
38
+ }
39
+ export declare function indexNextraToTypesense(options: NextraTypesenseOptions): Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,137 @@
1
+ import path from "path";
2
+ import { promises } from "fs";
3
+ import { TypesenseHelper, getDefaultCollectionFields } from "./typesenseHelper.js";
4
+ import { IndexFromHtml } from "./indexFromHtml.js";
5
+ async function getHtmlFiles(dir) {
6
+ let results = [];
7
+ try {
8
+ const list = await promises.readdir(dir, {
9
+ withFileTypes: true
10
+ });
11
+ for (const dirent of list){
12
+ const filePath = path.resolve(dir, dirent.name);
13
+ if (dirent.isDirectory()) results = results.concat(await getHtmlFiles(filePath));
14
+ else if (filePath.endsWith('.html')) results.push(filePath);
15
+ }
16
+ } catch (err) {}
17
+ return results;
18
+ }
19
+ async function asyncPool(poolLimit, array, iteratorFn) {
20
+ const executing = new Set();
21
+ for (const item of array){
22
+ const p = Promise.resolve().then(()=>iteratorFn(item));
23
+ executing.add(p);
24
+ const clean = ()=>executing.delete(p);
25
+ p.then(clean).catch(clean);
26
+ if (executing.size >= poolLimit) await Promise.race(executing);
27
+ }
28
+ await Promise.all(executing);
29
+ }
30
+ async function indexNextraToTypesense(options) {
31
+ const outDir = options.outDir || path.join(process.cwd(), '.next/server/app');
32
+ const defaultLang = options.defaultLang || 'en';
33
+ const concurrencyLimit = options.concurrencyLimit || 10;
34
+ const batchSize = options.batchSize || 200;
35
+ console.log(`\n\x1b[36m[TypesensePlugin]\x1b[0m Scanning Next.js build directory: ${outDir}`);
36
+ const htmlFiles = await getHtmlFiles(outDir);
37
+ if (0 === htmlFiles.length) return void console.warn(`\n\x1b[33m⚠ [TypesensePlugin] No HTML files found in ${outDir}.\x1b[0m \x1b[90mSkipping indexing.\x1b[0m\n`);
38
+ const routeGroups = {};
39
+ console.log(`\n\x1b[90mExtracting languages for ${htmlFiles.length} files...\x1b[0m`);
40
+ await asyncPool(50, htmlFiles, async (filePath)=>{
41
+ const relativePath = path.relative(outDir, filePath);
42
+ if ([
43
+ '404.html',
44
+ '500.html',
45
+ '_not-found.html',
46
+ '_global-error.html'
47
+ ].includes(relativePath)) return;
48
+ let routePath = '/' + relativePath.replace(/\\/g, '/').replace(/\.html$/, '');
49
+ if ('/index' === routePath) routePath = '/';
50
+ else if (routePath.endsWith('/index')) routePath = routePath.replace(/\/index$/, '');
51
+ const fd = await promises.open(filePath, 'r');
52
+ const buffer = Buffer.alloc(2000);
53
+ await fd.read(buffer, 0, 2000, 0);
54
+ await fd.close();
55
+ const contentChunk = buffer.toString('utf-8');
56
+ const langMatch = contentChunk.match(/<html[^>]*\slang=(['"])(.*?)\1/i);
57
+ const locale = langMatch?.[2] || defaultLang;
58
+ if (!routeGroups[locale]) routeGroups[locale] = {
59
+ locale,
60
+ files: []
61
+ };
62
+ routeGroups[locale].files.push({
63
+ routePath,
64
+ filePath
65
+ });
66
+ });
67
+ if (0 === Object.keys(routeGroups).length) return void console.warn(`\n\x1b[33m⚠ [TypesensePlugin] No valid routes found for indexing.\x1b[0m`);
68
+ const extractor = new IndexFromHtml({
69
+ indexCodeBlocks: options.indexCodeBlocks ?? false
70
+ });
71
+ for(const groupKey in routeGroups){
72
+ const { locale, files } = routeGroups[groupKey];
73
+ const aliasName = `${options.collectionName}_${locale}`;
74
+ const collectionNameTmp = `${aliasName}_${Date.now()}`;
75
+ const maxRouteLength = files.reduce((max, f)=>Math.max(max, f.routePath.length), 0);
76
+ const padLength = Math.max(maxRouteLength + 4, 30);
77
+ console.log(`\n\x1b[1m\x1b[36m[TypesensePlugin]\x1b[0m \x1b[1mProcessing group:\x1b[0m \x1b[35m${aliasName}\x1b[0m \x1b[90m(${files.length} routes)\x1b[0m`);
78
+ const helper = new TypesenseHelper({
79
+ config: options.serverConfig,
80
+ aliasName,
81
+ collectionNameTmp,
82
+ customSettings: resolveCustomSettings(options.customCollectionSettings, locale),
83
+ locale,
84
+ isVersioned: false,
85
+ batchSize
86
+ });
87
+ try {
88
+ await helper.init();
89
+ await helper.createTmpCollection();
90
+ } catch (error) {
91
+ console.error(`\n\x1b[31m✖ Failed to create collection:\x1b[0m \x1b[37m${collectionNameTmp}\x1b[0m`);
92
+ if (false !== options.failOnIndexError) throw error;
93
+ continue;
94
+ }
95
+ let totalRecords = 0;
96
+ let criticalError = null;
97
+ await asyncPool(concurrencyLimit, files, async (file)=>{
98
+ if (criticalError) return;
99
+ try {
100
+ const content = await promises.readFile(file.filePath, 'utf-8');
101
+ let records = extractor.getRecords(content, file.routePath, locale);
102
+ if (options.transformRecord) records = records.map((record)=>options.transformRecord(record, {
103
+ routePath: file.routePath,
104
+ filePath: file.filePath
105
+ }));
106
+ if (records.length > 0) {
107
+ const added = await helper.addRecords(records, file.routePath, padLength);
108
+ totalRecords += added;
109
+ }
110
+ } catch (error) {
111
+ if ('TYPESENSE_BATCH_ERROR' === error.message) {
112
+ if (false !== options.failOnIndexError) criticalError = error;
113
+ } else {
114
+ console.error(` \x1b[31m✖\x1b[0m \x1b[37m${file.routePath}\x1b[0m \x1b[31mfailed to parse\x1b[0m`);
115
+ if (false !== options.failOnIndexError) criticalError = error;
116
+ }
117
+ }
118
+ });
119
+ if (criticalError) throw criticalError;
120
+ try {
121
+ await helper.flushBatch();
122
+ await helper.commitTmpCollection();
123
+ console.log(`\x1b[32m✔ Indexing complete for\x1b[0m \x1b[35m${aliasName}\x1b[0m \x1b[90m—\x1b[0m \x1b[33m${totalRecords}\x1b[0m \x1b[90mtotal records!\x1b[0m`);
124
+ } catch (error) {
125
+ console.error(`\n\x1b[31m✖ Failed to flush remaining batch or commit collection.\x1b[0m`);
126
+ if (false !== options.failOnIndexError) throw error;
127
+ }
128
+ }
129
+ }
130
+ function resolveCustomSettings(settings, locale) {
131
+ if (!settings) return null;
132
+ const isGlobalConfig = 'token_separators' in settings || 'symbols_to_index' in settings || 'fields' in settings || 'enable_nested_fields' in settings;
133
+ if (isGlobalConfig) return settings;
134
+ const perLangSettings = settings;
135
+ return perLangSettings[locale] || null;
136
+ }
137
+ export { getDefaultCollectionFields, indexNextraToTypesense };
@@ -0,0 +1,19 @@
1
+ import type { DocSearchRecord } from './types.js';
2
+ export declare class IndexFromHtml {
3
+ private levels;
4
+ private selectors;
5
+ constructor(options?: {
6
+ indexCodeBlocks?: boolean;
7
+ });
8
+ getRecords(html: string, url: string, lang?: string): DocSearchRecord[];
9
+ private getLevelFromTag;
10
+ private generateEmptyHierarchy;
11
+ private getHierarchyRadio;
12
+ private getAnchor;
13
+ private getClosestAnchor;
14
+ private getObjectID;
15
+ /**
16
+ * Removes anchor links and invisible characters
17
+ */
18
+ private cleanText;
19
+ }