@wsxjs/wsx-press 0.0.18

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,328 @@
1
+ /**
2
+ * @jsxImportSource @wsxjs/wsx-core
3
+ * DocSearch Component
4
+ *
5
+ * A search component that uses Fuse.js to search through documentation.
6
+ * Supports real-time search, result highlighting, and keyboard navigation.
7
+ */
8
+
9
+ import { LightComponent, autoRegister, state } from "@wsxjs/wsx-core";
10
+ import { createLogger } from "@wsxjs/wsx-logger";
11
+ import Fuse from "fuse.js";
12
+ import type { SearchIndex, SearchResult, SearchDocument } from "../../types";
13
+ import styles from "./DocSearch.css?inline";
14
+
15
+ const logger = createLogger("DocSearch");
16
+
17
+ /**
18
+ * 搜索索引缓存(全局共享)
19
+ * 导出以便测试时重置
20
+ */
21
+ export const searchIndexCache: {
22
+ data: SearchIndex | null;
23
+ promise: Promise<SearchIndex> | null;
24
+ } = {
25
+ data: null,
26
+ promise: null,
27
+ };
28
+
29
+ /**
30
+ * 加载搜索索引(带缓存)
31
+ */
32
+ async function loadSearchIndex(): Promise<SearchIndex> {
33
+ // 如果已有缓存,直接返回
34
+ if (searchIndexCache.data) {
35
+ return searchIndexCache.data;
36
+ }
37
+
38
+ // 如果正在加载,等待现有请求
39
+ if (searchIndexCache.promise) {
40
+ return searchIndexCache.promise;
41
+ }
42
+
43
+ // 创建新的加载请求
44
+ searchIndexCache.promise = (async () => {
45
+ try {
46
+ const response = await fetch("/.wsx-press/search-index.json");
47
+ if (!response.ok) {
48
+ throw new Error(`Failed to load search index: ${response.statusText}`);
49
+ }
50
+ const data = (await response.json()) as SearchIndex;
51
+ searchIndexCache.data = data;
52
+ searchIndexCache.promise = null;
53
+ return data;
54
+ } catch (error) {
55
+ searchIndexCache.promise = null;
56
+ throw error;
57
+ }
58
+ })();
59
+
60
+ return searchIndexCache.promise;
61
+ }
62
+
63
+ /**
64
+ * DocSearch Component
65
+ *
66
+ * Provides real-time search functionality for documentation.
67
+ */
68
+ @autoRegister({ tagName: "wsx-doc-search" })
69
+ export default class DocSearch extends LightComponent {
70
+ @state private query: string = "";
71
+ @state private results: SearchResult[] = [];
72
+ @state private isOpen: boolean = false;
73
+ @state private isLoading: boolean = false;
74
+ @state private selectedIndex: number = -1;
75
+
76
+ private fuse: Fuse<SearchDocument> | null = null;
77
+ private searchTimeout: number | null = null;
78
+
79
+ constructor() {
80
+ super({
81
+ styles,
82
+ styleName: "wsx-doc-search",
83
+ });
84
+ logger.info("DocSearch initialized");
85
+ }
86
+
87
+ protected async onConnected() {
88
+ // 预加载搜索索引
89
+ try {
90
+ const index = await loadSearchIndex();
91
+ this.fuse = new Fuse(index.documents, index.options);
92
+ } catch (error) {
93
+ logger.error("Failed to load search index", error);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 处理搜索输入
99
+ */
100
+ private handleInput = (e: Event) => {
101
+ const target = e.target as HTMLInputElement;
102
+ this.query = target.value;
103
+
104
+ // 清除之前的定时器
105
+ if (this.searchTimeout !== null) {
106
+ clearTimeout(this.searchTimeout);
107
+ }
108
+
109
+ // 如果查询为空,清空结果
110
+ if (!this.query.trim()) {
111
+ this.results = [];
112
+ this.selectedIndex = -1;
113
+ this.isOpen = false;
114
+ return;
115
+ }
116
+
117
+ // 延迟搜索(防抖)
118
+ this.searchTimeout = window.setTimeout(() => {
119
+ this.performSearch();
120
+ }, 300);
121
+ };
122
+
123
+ /**
124
+ * 执行搜索
125
+ */
126
+ private async performSearch(): Promise<void> {
127
+ if (!this.fuse) {
128
+ // 如果索引未加载,尝试加载
129
+ try {
130
+ this.isLoading = true;
131
+ const index = await loadSearchIndex();
132
+ this.fuse = new Fuse(index.documents, index.options);
133
+ } catch (error) {
134
+ logger.error("Failed to load search index", error);
135
+ this.isLoading = false;
136
+ return;
137
+ }
138
+ }
139
+
140
+ this.isLoading = false;
141
+
142
+ if (!this.query.trim()) {
143
+ this.results = [];
144
+ this.isOpen = false;
145
+ return;
146
+ }
147
+
148
+ // 执行搜索
149
+ const searchResults = this.fuse.search(this.query.trim());
150
+ this.results = searchResults as SearchResult[];
151
+ // 如果有查询但没有结果,也要显示"无结果"消息,所以 isOpen 应该为 true
152
+ this.isOpen = this.query.trim().length > 0;
153
+ this.selectedIndex = -1;
154
+ }
155
+
156
+ /**
157
+ * 处理键盘导航
158
+ */
159
+ private handleKeyDown = (e: KeyboardEvent) => {
160
+ if (!this.isOpen || this.results.length === 0) {
161
+ return;
162
+ }
163
+
164
+ switch (e.key) {
165
+ case "ArrowDown":
166
+ e.preventDefault();
167
+ this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
168
+ break;
169
+ case "ArrowUp":
170
+ e.preventDefault();
171
+ this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
172
+ break;
173
+ case "Enter":
174
+ e.preventDefault();
175
+ if (this.selectedIndex >= 0 && this.selectedIndex < this.results.length) {
176
+ this.selectResult(this.results[this.selectedIndex]);
177
+ } else if (this.results.length > 0) {
178
+ this.selectResult(this.results[0]);
179
+ }
180
+ break;
181
+ case "Escape":
182
+ e.preventDefault();
183
+ this.isOpen = false;
184
+ this.query = "";
185
+ this.results = [];
186
+ this.selectedIndex = -1;
187
+ break;
188
+ }
189
+ };
190
+
191
+ /**
192
+ * 选择搜索结果
193
+ */
194
+ private selectResult(result: SearchResult): void {
195
+ // 触发自定义事件,让父组件处理导航
196
+ const event = new CustomEvent("doc-search-select", {
197
+ detail: { route: result.item.route },
198
+ bubbles: true,
199
+ });
200
+ this.dispatchEvent(event);
201
+
202
+ // 关闭搜索
203
+ this.isOpen = false;
204
+ this.query = "";
205
+ this.results = [];
206
+ this.selectedIndex = -1;
207
+ }
208
+
209
+ /**
210
+ * 高亮匹配文本,返回 JSX 元素数组
211
+ */
212
+ private highlightTextNodes(
213
+ text: string,
214
+ matches: Array<{ indices: [number, number][] }> | undefined
215
+ ): (HTMLElement | string)[] {
216
+ if (!matches || matches.length === 0) {
217
+ return [text];
218
+ }
219
+
220
+ // 合并所有匹配位置
221
+ const allIndices: Array<[number, number]> = [];
222
+ for (const match of matches) {
223
+ if (match.indices) {
224
+ allIndices.push(...match.indices);
225
+ }
226
+ }
227
+
228
+ // 按开始位置排序
229
+ allIndices.sort((a, b) => a[0] - b[0]);
230
+
231
+ // 合并重叠的区间
232
+ const merged: Array<[number, number]> = [];
233
+ for (const [start, end] of allIndices) {
234
+ if (merged.length === 0 || merged[merged.length - 1][1] < start) {
235
+ merged.push([start, end + 1]);
236
+ } else {
237
+ merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], end + 1);
238
+ }
239
+ }
240
+
241
+ // 构建节点数组
242
+ const nodes: (HTMLElement | string)[] = [];
243
+ let lastIndex = 0;
244
+ for (const [start, end] of merged) {
245
+ // 添加匹配前的文本
246
+ if (start > lastIndex) {
247
+ nodes.push(text.substring(lastIndex, start));
248
+ }
249
+ // 添加高亮标记
250
+ const mark = document.createElement("mark");
251
+ mark.textContent = text.substring(start, end);
252
+ nodes.push(mark);
253
+ lastIndex = end;
254
+ }
255
+ // 添加剩余文本
256
+ if (lastIndex < text.length) {
257
+ nodes.push(text.substring(lastIndex));
258
+ }
259
+
260
+ return nodes;
261
+ }
262
+
263
+ render() {
264
+ return (
265
+ <div class="doc-search">
266
+ <div class="search-input-wrapper">
267
+ <input
268
+ type="text"
269
+ class="search-input"
270
+ placeholder="搜索文档..."
271
+ value={this.query}
272
+ onInput={this.handleInput}
273
+ onKeyDown={this.handleKeyDown}
274
+ onFocus={() => {
275
+ if (this.results.length > 0) {
276
+ this.isOpen = true;
277
+ }
278
+ }}
279
+ />
280
+ {this.isLoading && <div class="search-loading">加载中...</div>}
281
+ </div>
282
+
283
+ {this.isOpen && this.results.length > 0 && (
284
+ <div class="search-results">
285
+ {this.results.map((result, index) => {
286
+ const isSelected = index === this.selectedIndex;
287
+ const titleMatches = result.matches?.find((m) => m.key === "title");
288
+ const contentMatches = result.matches?.find((m) => m.key === "content");
289
+
290
+ return (
291
+ <div
292
+ key={result.item.id}
293
+ class={`search-result ${isSelected ? "selected" : ""}`}
294
+ onClick={() => this.selectResult(result)}
295
+ onMouseEnter={() => {
296
+ this.selectedIndex = index;
297
+ }}
298
+ >
299
+ <div class="result-title">
300
+ {titleMatches
301
+ ? this.highlightTextNodes(result.item.title, [
302
+ titleMatches,
303
+ ])
304
+ : result.item.title}
305
+ </div>
306
+ <div class="result-category">{result.item.category}</div>
307
+ {contentMatches && (
308
+ <div class="result-snippet">
309
+ {this.highlightTextNodes(
310
+ result.item.content.substring(0, 100),
311
+ [contentMatches]
312
+ )}
313
+ </div>
314
+ )}
315
+ </div>
316
+ );
317
+ })}
318
+ </div>
319
+ )}
320
+
321
+ {this.isOpen &&
322
+ this.query.trim() &&
323
+ this.results.length === 0 &&
324
+ !this.isLoading && <div class="search-no-results">未找到匹配的文档</div>}
325
+ </div>
326
+ );
327
+ }
328
+ }
@@ -0,0 +1,97 @@
1
+ wsx-doc-sidebar {
2
+ display: flex;
3
+ flex-direction: column;
4
+ width: 280px;
5
+ min-width: 280px;
6
+ height: 100%;
7
+ background: var(--wsx-press-sidebar-bg, #f9fafb);
8
+ border-right: 1px solid var(--wsx-press-border, #e5e7eb);
9
+ overflow-y: auto;
10
+ position: sticky;
11
+ top: 70px; /* Navigation height */
12
+ max-height: calc(100vh - 70px);
13
+ }
14
+
15
+ .doc-sidebar {
16
+ display: flex;
17
+ flex-direction: column;
18
+ width: 100%;
19
+ height: 100%;
20
+ }
21
+
22
+ .doc-sidebar-loading,
23
+ .doc-sidebar-empty {
24
+ padding: 2rem;
25
+ text-align: center;
26
+ color: var(--wsx-press-text-secondary, #6b7280);
27
+ }
28
+
29
+ .doc-sidebar-nav {
30
+ padding: 1.5rem 0;
31
+ }
32
+
33
+ .doc-sidebar-category {
34
+ margin-bottom: 2rem;
35
+ }
36
+
37
+ .doc-sidebar-category:last-child {
38
+ margin-bottom: 0;
39
+ }
40
+
41
+ .doc-sidebar-category-title {
42
+ font-size: 0.875rem;
43
+ font-weight: 600;
44
+ text-transform: uppercase;
45
+ letter-spacing: 0.05em;
46
+ color: var(--wsx-press-text-secondary, #6b7280);
47
+ padding: 0.5rem 1.5rem;
48
+ margin: 0 0 0.5rem 0;
49
+ }
50
+
51
+ .doc-sidebar-list {
52
+ list-style: none;
53
+ margin: 0;
54
+ padding: 0;
55
+ }
56
+
57
+ .doc-sidebar-item {
58
+ margin: 0;
59
+ }
60
+
61
+ .doc-sidebar-link {
62
+ display: block;
63
+ padding: 0.5rem 1.5rem;
64
+ color: var(--wsx-press-text-primary, #111827);
65
+ text-decoration: none;
66
+ font-size: 0.875rem;
67
+ line-height: 1.5;
68
+ transition: all 0.2s ease;
69
+ border-left: 3px solid transparent;
70
+ }
71
+
72
+ .doc-sidebar-link:hover {
73
+ background: var(--wsx-press-sidebar-hover-bg, #f3f4f6);
74
+ color: var(--wsx-press-text-primary, #111827);
75
+ }
76
+
77
+ .doc-sidebar-link.active {
78
+ background: var(--wsx-press-sidebar-active-bg, #eff6ff);
79
+ color: var(--wsx-press-sidebar-active-color, #2563eb);
80
+ border-left-color: var(--wsx-press-sidebar-active-color, #2563eb);
81
+ font-weight: 500;
82
+ }
83
+
84
+ /* 响应式设计 */
85
+ @media (max-width: 1024px) {
86
+ wsx-doc-sidebar {
87
+ width: 240px;
88
+ min-width: 240px;
89
+ }
90
+ }
91
+
92
+ @media (max-width: 768px) {
93
+ wsx-doc-sidebar {
94
+ display: none; /* 在移动设备上隐藏侧边栏 */
95
+ }
96
+ }
97
+
@@ -0,0 +1,173 @@
1
+ /**
2
+ * @jsxImportSource @wsxjs/wsx-core
3
+ * DocSidebar Component
4
+ *
5
+ * Sidebar navigation for documentation pages.
6
+ * Displays organized documentation menu by category.
7
+ */
8
+
9
+ import { LightComponent, autoRegister, state } from "@wsxjs/wsx-core";
10
+ import { createLogger } from "@wsxjs/wsx-logger";
11
+ import { RouterUtils } from "@wsxjs/wsx-router";
12
+ import type { DocMetadata } from "../../types";
13
+ import { metadataCache } from "./DocPage.wsx";
14
+ import styles from "./DocSidebar.css?inline";
15
+
16
+ const logger = createLogger("DocSidebar");
17
+
18
+ /**
19
+ * DocSidebar Component
20
+ *
21
+ * Displays a sidebar navigation menu organized by documentation categories.
22
+ */
23
+ @autoRegister({ tagName: "wsx-doc-sidebar" })
24
+ export default class DocSidebar extends LightComponent {
25
+ @state private metadata: Record<string, DocMetadata> = {};
26
+ @state private currentRoute: string = "";
27
+ @state private isLoading: boolean = true;
28
+
29
+ private routeChangeUnsubscribe: (() => void) | null = null;
30
+
31
+ constructor() {
32
+ super({
33
+ styles,
34
+ styleName: "wsx-doc-sidebar",
35
+ });
36
+ }
37
+
38
+ protected async onConnected() {
39
+ // 加载元数据
40
+ await this.loadMetadata();
41
+
42
+ // 监听路由变化
43
+ this.routeChangeUnsubscribe = RouterUtils.onRouteChange(() => {
44
+ this.updateCurrentRoute();
45
+ });
46
+
47
+ // 初始更新当前路由
48
+ this.updateCurrentRoute();
49
+ }
50
+
51
+ protected onDisconnected() {
52
+ if (this.routeChangeUnsubscribe) {
53
+ this.routeChangeUnsubscribe();
54
+ this.routeChangeUnsubscribe = null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 加载文档元数据
60
+ */
61
+ private async loadMetadata(): Promise<void> {
62
+ try {
63
+ this.isLoading = true;
64
+ if (metadataCache.data) {
65
+ this.metadata = metadataCache.data;
66
+ } else {
67
+ const response = await fetch("/.wsx-press/docs-meta.json");
68
+ if (response.ok) {
69
+ this.metadata = (await response.json()) as Record<string, DocMetadata>;
70
+ }
71
+ }
72
+ } catch (error) {
73
+ logger.error("Failed to load metadata", error);
74
+ } finally {
75
+ this.isLoading = false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 更新当前路由
81
+ */
82
+ private updateCurrentRoute(): void {
83
+ const routeInfo = RouterUtils.getCurrentRoute();
84
+ this.currentRoute = routeInfo.path;
85
+ }
86
+
87
+ /**
88
+ * 按分类组织文档
89
+ */
90
+ private organizeByCategory(): Record<string, DocMetadata[]> {
91
+ const organized: Record<string, DocMetadata[]> = {};
92
+
93
+ Object.values(this.metadata).forEach((doc) => {
94
+ const category = doc.category || "other";
95
+ if (!organized[category]) {
96
+ organized[category] = [];
97
+ }
98
+ organized[category].push(doc);
99
+ });
100
+
101
+ // 按分类名称排序
102
+ const sortedCategories = Object.keys(organized).sort();
103
+
104
+ // 对每个分类内的文档排序(按标题)
105
+ sortedCategories.forEach((category) => {
106
+ organized[category].sort((a, b) => a.title.localeCompare(b.title));
107
+ });
108
+
109
+ return organized;
110
+ }
111
+
112
+ /**
113
+ * 处理文档链接点击
114
+ */
115
+ private handleDocClick = (route: string, e: Event): void => {
116
+ e.preventDefault();
117
+ RouterUtils.navigate(route);
118
+ };
119
+
120
+ /**
121
+ * 检查文档是否为当前文档
122
+ */
123
+ private isCurrentDoc(route: string): boolean {
124
+ return this.currentRoute === route;
125
+ }
126
+
127
+ render() {
128
+ if (this.isLoading) {
129
+ return (
130
+ <aside class="doc-sidebar">
131
+ <div class="doc-sidebar-loading">加载中...</div>
132
+ </aside>
133
+ );
134
+ }
135
+
136
+ const organized = this.organizeByCategory();
137
+ const categories = Object.keys(organized);
138
+
139
+ if (categories.length === 0) {
140
+ return (
141
+ <aside class="doc-sidebar">
142
+ <div class="doc-sidebar-empty">暂无文档</div>
143
+ </aside>
144
+ );
145
+ }
146
+
147
+ return (
148
+ <aside class="doc-sidebar">
149
+ <nav class="doc-sidebar-nav">
150
+ {categories.map((category) => (
151
+ <div key={category} class="doc-sidebar-category">
152
+ <h3 class="doc-sidebar-category-title">{category}</h3>
153
+ <ul class="doc-sidebar-list">
154
+ {organized[category].map((doc) => (
155
+ <li key={doc.route} class="doc-sidebar-item">
156
+ <a
157
+ href={doc.route}
158
+ class={`doc-sidebar-link ${this.isCurrentDoc(doc.route) ? "active" : ""}`}
159
+ onClick={(e) => this.handleDocClick(doc.route, e)}
160
+ >
161
+ {doc.title}
162
+ </a>
163
+ </li>
164
+ ))}
165
+ </ul>
166
+ </div>
167
+ ))}
168
+ </nav>
169
+ </aside>
170
+ );
171
+ }
172
+ }
173
+
@@ -0,0 +1,105 @@
1
+ wsx-doc-toc {
2
+ display: flex;
3
+ flex-direction: column;
4
+ width: 240px;
5
+ min-width: 240px;
6
+ height: 100%;
7
+ overflow-y: auto;
8
+ position: sticky;
9
+ top: 70px; /* Navigation height */
10
+ max-height: calc(100vh - 70px);
11
+ }
12
+
13
+ .doc-toc {
14
+ display: flex;
15
+ flex-direction: column;
16
+ width: 100%;
17
+ padding: 1.5rem 1rem;
18
+ }
19
+
20
+ .doc-toc-empty {
21
+ padding: 1rem;
22
+ text-align: center;
23
+ color: var(--wsx-press-text-secondary, #6b7280);
24
+ font-size: 0.875rem;
25
+ }
26
+
27
+ .doc-toc-title {
28
+ font-size: 0.875rem;
29
+ font-weight: 600;
30
+ text-transform: uppercase;
31
+ letter-spacing: 0.05em;
32
+ color: var(--wsx-press-text-secondary, #6b7280);
33
+ margin: 0 0 1rem 0;
34
+ padding: 0;
35
+ }
36
+
37
+ .doc-toc-nav {
38
+ width: 100%;
39
+ }
40
+
41
+ .doc-toc-list {
42
+ list-style: none;
43
+ margin: 0;
44
+ padding: 0;
45
+ }
46
+
47
+ .doc-toc-sublist {
48
+ margin-top: 0.25rem;
49
+ padding-left: 1rem;
50
+ }
51
+
52
+ .doc-toc-item {
53
+ margin: 0;
54
+ line-height: 1.6;
55
+ }
56
+
57
+ .doc-toc-item-level-1 {
58
+ margin-bottom: 0.5rem;
59
+ }
60
+
61
+ .doc-toc-item-level-2 {
62
+ margin-bottom: 0.375rem;
63
+ font-size: 0.875rem;
64
+ }
65
+
66
+ .doc-toc-item-level-3 {
67
+ margin-bottom: 0.25rem;
68
+ font-size: 0.8125rem;
69
+ }
70
+
71
+ .doc-toc-item-level-4,
72
+ .doc-toc-item-level-5,
73
+ .doc-toc-item-level-6 {
74
+ margin-bottom: 0.25rem;
75
+ font-size: 0.75rem;
76
+ }
77
+
78
+ .doc-toc-link {
79
+ display: block;
80
+ color: var(--wsx-press-text-secondary, #6b7280);
81
+ text-decoration: none;
82
+ transition: color 0.2s ease;
83
+ padding: 0.25rem 0;
84
+ border-left: 2px solid transparent;
85
+ padding-left: 0.5rem;
86
+ margin-left: -0.5rem;
87
+ }
88
+
89
+ .doc-toc-link:hover {
90
+ color: var(--wsx-press-text-primary, #111827);
91
+ }
92
+
93
+ .doc-toc-link.active {
94
+ color: var(--wsx-press-toc-active-color, #2563eb);
95
+ border-left-color: var(--wsx-press-toc-active-color, #2563eb);
96
+ font-weight: 500;
97
+ }
98
+
99
+ /* 响应式设计 */
100
+ @media (max-width: 1280px) {
101
+ wsx-doc-toc {
102
+ display: none; /* 在较小屏幕上隐藏 TOC */
103
+ }
104
+ }
105
+