docnest 0.1.14 → 0.1.16

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DocNest
2
2
 
3
- DocNest 是一个可复用的本地 Markdown 文档中心。文档内容归使用它的项目所有,DocNest 只提供查看、导航、Mermaid 图表、导出和本地 Watch 能力。
3
+ DocNest 是一个可复用的本地 Markdown 文档中心。文档内容归使用它的项目所有,DocNest 只提供查看、导航、搜索、Mermaid 图表、导出和本地 Watch 能力。
4
4
 
5
5
  ## 快速接入
6
6
 
@@ -56,6 +56,16 @@ export default defineConfig({
56
56
 
57
57
  未配置 `appearance` 时使用 `slate-modern + auto`。旧配置中的 `current-docs` 会自动迁移到 `slate-modern`,正文视觉不会发生变化;旧版保存的 `markdown-theme` 明暗偏好也会自动迁移到新的项目级存储键。
58
58
 
59
+ ### 文档搜索
60
+
61
+ 普通阅读模式下,页面顶部提供文档搜索入口,也可以使用 `⌘K`(macOS)或
62
+ `Ctrl K`(Windows/Linux)打开。搜索索引在首次打开搜索时才加载,覆盖文档标题、目录路径、标题层级、正文和代码块;中文按连续字符匹配,英文和带空格的查询按多个词匹配。结果按标题、目录和正文命中位置综合排序,并显示安全的文本摘要。
63
+
64
+ 搜索完全基于宿主项目自己的 Markdown 内容,不依赖 Algolia 或其他外部服务。动态服务通过同源
65
+ `/search-index.json` 提供索引;静态站点需要把同一份响应保存为站点根目录的
66
+ `search-index.json`,并保留页面上的静态站点标记,让结果链接指向 `/doc/<path>.html`。
67
+ 受限模式不生成搜索索引,也不显示搜索入口,避免摘要绕过阅读限制。
68
+
59
69
  PDF、页面水印和图表外带能力由一个开关统一控制,默认使用普通阅读模式:
60
70
 
61
71
  ```js
package/dist/server.js CHANGED
@@ -422,6 +422,114 @@ app.get('/ready', async (req, res) => {
422
422
  // 消费项目可以在通用文档路由前注入自己的访问控制;DocNest 不实现任何项目身份系统。
423
423
  const beforeDocumentRoutes = globalThis.__DOCNEST_BEFORE_DOCUMENT_ROUTES__ || [];
424
424
  beforeDocumentRoutes.forEach((middleware) => app.use(middleware));
425
+ const SEARCH_INDEX_VERSION = 1;
426
+ let searchIndexCache = null;
427
+ let searchIndexBuildPromise = null;
428
+ function collectSearchDocPaths(tree, paths = []) {
429
+ for (const item of tree) {
430
+ if (item.type === 'directory') {
431
+ collectSearchDocPaths(item.children, paths);
432
+ }
433
+ else if (item.type === 'file') {
434
+ paths.push(item.path);
435
+ }
436
+ }
437
+ return paths;
438
+ }
439
+ function normalizeSearchText(value) {
440
+ return String(value || '').replace(/\s+/g, ' ').trim();
441
+ }
442
+ function getSearchInlineText(token) {
443
+ if (!Array.isArray(token.children))
444
+ return token.content;
445
+ return token.children.map((child) => {
446
+ if (child.type === 'softbreak' || child.type === 'hardbreak')
447
+ return ' ';
448
+ if (child.type === 'image')
449
+ return child.content || '';
450
+ return String(child.content || '').replace(/<[^>]+>/g, ' ');
451
+ }).join('');
452
+ }
453
+ function createSearchDocument(docPath, markdown) {
454
+ const tokens = md.parse(markdown, {});
455
+ const headings = [];
456
+ const textParts = [];
457
+ let insideHeading = false;
458
+ let title = '';
459
+ for (const token of tokens) {
460
+ if (token.type === 'heading_open') {
461
+ insideHeading = true;
462
+ continue;
463
+ }
464
+ if (token.type === 'heading_close') {
465
+ insideHeading = false;
466
+ continue;
467
+ }
468
+ if (token.type === 'inline') {
469
+ const text = normalizeSearchText(getSearchInlineText(token));
470
+ if (!text)
471
+ continue;
472
+ textParts.push(text);
473
+ if (insideHeading) {
474
+ headings.push(text);
475
+ if (!title)
476
+ title = text;
477
+ }
478
+ continue;
479
+ }
480
+ if (token.type === 'code_block' || token.type === 'fence') {
481
+ const code = normalizeSearchText(token.content);
482
+ if (code)
483
+ textParts.push(code);
484
+ }
485
+ }
486
+ const fallbackTitle = path.basename(docPath).replace(/\.(md|markdown)$/i, '');
487
+ return {
488
+ path: docPath,
489
+ title: title || fallbackTitle,
490
+ directory: path.posix.dirname(docPath) === '.' ? '' : path.posix.dirname(docPath),
491
+ headings,
492
+ text: normalizeSearchText(textParts.join(' ')),
493
+ };
494
+ }
495
+ async function buildSearchIndex() {
496
+ if (searchIndexCache)
497
+ return searchIndexCache;
498
+ if (searchIndexBuildPromise)
499
+ return searchIndexBuildPromise;
500
+ searchIndexBuildPromise = (async () => {
501
+ const tree = await getDocTree(DOCS_DIR);
502
+ const paths = collectSearchDocPaths(tree);
503
+ const documents = [];
504
+ for (const docPath of paths) {
505
+ const markdown = await storageReadFile(docPath);
506
+ documents.push(createSearchDocument(docPath, markdown));
507
+ }
508
+ return { version: SEARCH_INDEX_VERSION, documents };
509
+ })()
510
+ .then((index) => {
511
+ searchIndexCache = index;
512
+ return index;
513
+ })
514
+ .finally(() => {
515
+ searchIndexBuildPromise = null;
516
+ });
517
+ return searchIndexBuildPromise;
518
+ }
519
+ function invalidateSearchIndex() {
520
+ searchIndexCache = null;
521
+ }
522
+ app.get('/search-index.json', async (req, res) => {
523
+ if (DOCS_RESTRICTED_MODE) {
524
+ return res.status(404).json({ ok: false, error: '搜索功能已关闭。' });
525
+ }
526
+ try {
527
+ return res.json(await buildSearchIndex());
528
+ }
529
+ catch (error) {
530
+ return res.status(503).json({ ok: false, error: `搜索索引暂不可用:${error.message}` });
531
+ }
532
+ });
425
533
  // 获取文档目录树(排除隐藏目录和系统文件)
426
534
  async function getDocTree(dir, basePath = '') {
427
535
  const entries = await storageReadDir(basePath);
@@ -882,6 +990,7 @@ if (WATCH_ENABLED && !injectedDocumentStore) {
882
990
  return;
883
991
  }
884
992
  const relativePath = normalizePath(filePath);
993
+ invalidateSearchIndex();
885
994
  console.log(`📝 文件已更新: ${relativePath}`);
886
995
  try {
887
996
  // 重新读取文件内容并渲染
@@ -904,6 +1013,7 @@ if (WATCH_ENABLED && !injectedDocumentStore) {
904
1013
  watcher.on('add', (filePath) => {
905
1014
  if (filePath.endsWith('.md')) {
906
1015
  const relativePath = normalizePath(filePath);
1016
+ invalidateSearchIndex();
907
1017
  console.log(`➕ 新文件已添加: ${relativePath}`);
908
1018
  io.emit('file-added', { path: relativePath });
909
1019
  }
@@ -911,6 +1021,7 @@ if (WATCH_ENABLED && !injectedDocumentStore) {
911
1021
  watcher.on('unlink', (filePath) => {
912
1022
  if (filePath.endsWith('.md')) {
913
1023
  const relativePath = normalizePath(filePath);
1024
+ invalidateSearchIndex();
914
1025
  console.log(`🗑️ 文件已删除: ${relativePath}`);
915
1026
  io.emit('file-deleted', { path: relativePath });
916
1027
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docnest",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "可复用的本地 Markdown 文档中心",
5
5
  "repository": {
6
6
  "type": "git",
@@ -167,6 +167,39 @@ body {
167
167
  min-width: 112px;
168
168
  }
169
169
 
170
+ .doc-search-trigger {
171
+ min-width: 168px;
172
+ justify-content: flex-start;
173
+ }
174
+
175
+ .doc-search-trigger .header-action-text {
176
+ flex: 1;
177
+ text-align: left;
178
+ }
179
+
180
+ .doc-search-icon {
181
+ display: inline-block;
182
+ font-size: 1.2rem;
183
+ line-height: 1;
184
+ }
185
+
186
+ .doc-search-shortcut,
187
+ .doc-search-input-key {
188
+ display: inline-flex;
189
+ align-items: center;
190
+ justify-content: center;
191
+ min-width: 2.25rem;
192
+ padding: 0.15rem 0.35rem;
193
+ border: 1px solid var(--linear-border);
194
+ border-radius: 5px;
195
+ background: var(--linear-bg);
196
+ color: var(--linear-text-muted);
197
+ font: inherit;
198
+ font-size: 0.6875rem;
199
+ line-height: 1.2;
200
+ white-space: nowrap;
201
+ }
202
+
170
203
  .theme-icon {
171
204
  display: inline-block;
172
205
  line-height: 1;
@@ -179,13 +212,26 @@ body {
179
212
  }
180
213
 
181
214
  .doc-pdf-export-root {
215
+ /* PDF pages use a light paper palette, independently of the live reading mode. */
216
+ --linear-bg: #ffffff;
217
+ --linear-bg-subtle: #fafafa;
218
+ --linear-border: #e5e5e5;
219
+ --linear-text: #111111;
220
+ --linear-text-muted: #6b6b6b;
221
+ --linear-accent: #5e6ad2;
222
+ --linear-accent-hover: #4c57b8;
223
+ --doc-surface-raised: #ffffff;
224
+ --doc-rule-strong: #bfc0c5;
225
+ --doc-accent-soft: rgba(94, 106, 210, 0.12);
226
+ --doc-code-bg: #f6f7f8;
227
+ --doc-code-text: #24262b;
182
228
  position: absolute;
183
229
  left: 0;
184
230
  top: 0;
185
231
  width: 960px;
186
232
  padding: 24px 32px;
187
- background: #ffffff;
188
- color: #111111;
233
+ background: var(--doc-surface-raised);
234
+ color: var(--linear-text);
189
235
  z-index: -1;
190
236
  pointer-events: none;
191
237
  overflow: visible;
@@ -540,6 +586,10 @@ body[data-theme="dark"] .dir-toggle.active {
540
586
  margin: 0 auto;
541
587
  }
542
588
 
589
+ body.doc-search-open {
590
+ overflow: hidden;
591
+ }
592
+
543
593
  .doc-toc {
544
594
  position: sticky;
545
595
  top: 12px;
@@ -2124,6 +2174,8 @@ body[data-theme="dark"] .tree-nav::-webkit-scrollbar-thumb:hover {
2124
2174
  .theme-toggle,
2125
2175
  .download-pdf-btn,
2126
2176
  .print-btn,
2177
+ .doc-search-modal,
2178
+ .doc-search-trigger,
2127
2179
  .sidebar,
2128
2180
  .sidebar-resizer {
2129
2181
  display: none !important;
@@ -2148,6 +2200,228 @@ body[data-theme="dark"] .tree-nav::-webkit-scrollbar-thumb:hover {
2148
2200
  }
2149
2201
  }
2150
2202
 
2203
+ .doc-search-modal {
2204
+ position: fixed;
2205
+ inset: 0;
2206
+ display: flex;
2207
+ align-items: flex-start;
2208
+ justify-content: center;
2209
+ box-sizing: border-box;
2210
+ padding: clamp(4rem, 12vh, 8rem) 1rem 2rem;
2211
+ background: rgba(15, 23, 42, 0.48);
2212
+ z-index: 20030;
2213
+ }
2214
+
2215
+ .doc-search-modal[hidden] {
2216
+ display: none;
2217
+ }
2218
+
2219
+ .doc-search-modal__panel {
2220
+ display: flex;
2221
+ flex-direction: column;
2222
+ width: min(720px, 100%);
2223
+ max-height: min(680px, calc(100vh - 6rem));
2224
+ overflow: hidden;
2225
+ box-sizing: border-box;
2226
+ border: 1px solid var(--linear-border);
2227
+ border-radius: 16px;
2228
+ background: var(--linear-bg);
2229
+ color: var(--linear-text);
2230
+ box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
2231
+ }
2232
+
2233
+ .doc-search-modal__topline {
2234
+ display: flex;
2235
+ align-items: center;
2236
+ justify-content: space-between;
2237
+ padding: 1rem 1.15rem 0.75rem;
2238
+ }
2239
+
2240
+ .doc-search-modal__eyebrow {
2241
+ color: var(--linear-text-muted);
2242
+ font-size: 0.75rem;
2243
+ font-weight: 700;
2244
+ letter-spacing: 0.08em;
2245
+ text-transform: uppercase;
2246
+ }
2247
+
2248
+ .doc-search-modal__close {
2249
+ width: 2rem;
2250
+ height: 2rem;
2251
+ border: 0;
2252
+ border-radius: 8px;
2253
+ background: transparent;
2254
+ color: var(--linear-text-muted);
2255
+ cursor: pointer;
2256
+ font-size: 1.4rem;
2257
+ line-height: 1;
2258
+ }
2259
+
2260
+ .doc-search-modal__close:hover {
2261
+ background: var(--linear-bg-subtle);
2262
+ color: var(--linear-text);
2263
+ }
2264
+
2265
+ .doc-search-input-wrap {
2266
+ display: flex;
2267
+ align-items: center;
2268
+ gap: 0.75rem;
2269
+ margin: 0 1.15rem;
2270
+ padding: 0.7rem 0.8rem;
2271
+ border: 1px solid var(--linear-border);
2272
+ border-radius: 10px;
2273
+ background: var(--linear-bg-subtle);
2274
+ }
2275
+
2276
+ .doc-search-input-wrap:focus-within {
2277
+ border-color: var(--linear-accent);
2278
+ box-shadow: 0 0 0 3px var(--doc-accent-soft);
2279
+ }
2280
+
2281
+ .doc-search-input-icon {
2282
+ flex: 0 0 auto;
2283
+ color: var(--linear-accent);
2284
+ font-size: 1.35rem;
2285
+ line-height: 1;
2286
+ }
2287
+
2288
+ .doc-search-input {
2289
+ min-width: 0;
2290
+ flex: 1;
2291
+ border: 0;
2292
+ outline: 0;
2293
+ background: transparent;
2294
+ color: var(--linear-text);
2295
+ font: inherit;
2296
+ font-size: 1rem;
2297
+ }
2298
+
2299
+ .doc-search-input::placeholder {
2300
+ color: var(--linear-text-muted);
2301
+ }
2302
+
2303
+ .doc-search-input:disabled {
2304
+ cursor: wait;
2305
+ opacity: 0.65;
2306
+ }
2307
+
2308
+ .doc-search-status {
2309
+ min-height: 1.25rem;
2310
+ padding: 0.75rem 1.25rem 0.5rem;
2311
+ color: var(--linear-text-muted);
2312
+ font-size: 0.75rem;
2313
+ }
2314
+
2315
+ .doc-search-results {
2316
+ min-height: 5rem;
2317
+ overflow-y: auto;
2318
+ padding: 0 0.6rem 0.6rem;
2319
+ overscroll-behavior: contain;
2320
+ }
2321
+
2322
+ .doc-search-result {
2323
+ position: relative;
2324
+ display: block;
2325
+ padding: 0.8rem 2.5rem 0.75rem 0.8rem;
2326
+ border: 1px solid transparent;
2327
+ border-radius: 10px;
2328
+ color: var(--linear-text);
2329
+ text-decoration: none;
2330
+ }
2331
+
2332
+ .doc-search-result:hover,
2333
+ .doc-search-result.is-selected {
2334
+ border-color: var(--linear-border);
2335
+ background: var(--linear-bg-subtle);
2336
+ }
2337
+
2338
+ .doc-search-result__title {
2339
+ overflow: hidden;
2340
+ color: var(--linear-text);
2341
+ font-size: 0.9375rem;
2342
+ font-weight: 650;
2343
+ line-height: 1.4;
2344
+ text-overflow: ellipsis;
2345
+ white-space: nowrap;
2346
+ }
2347
+
2348
+ .doc-search-result__path {
2349
+ margin-top: 0.15rem;
2350
+ overflow: hidden;
2351
+ color: var(--linear-accent);
2352
+ font-size: 0.6875rem;
2353
+ line-height: 1.4;
2354
+ text-overflow: ellipsis;
2355
+ white-space: nowrap;
2356
+ }
2357
+
2358
+ .doc-search-result__snippet {
2359
+ display: -webkit-box;
2360
+ overflow: hidden;
2361
+ margin: 0.35rem 0 0;
2362
+ color: var(--linear-text-muted);
2363
+ font-size: 0.8125rem;
2364
+ line-height: 1.5;
2365
+ -webkit-box-orient: vertical;
2366
+ -webkit-line-clamp: 2;
2367
+ }
2368
+
2369
+ .doc-search-result__arrow {
2370
+ position: absolute;
2371
+ top: 50%;
2372
+ right: 0.9rem;
2373
+ color: var(--linear-text-muted);
2374
+ font-size: 1rem;
2375
+ transform: translateY(-50%);
2376
+ }
2377
+
2378
+ .doc-search-result.is-selected .doc-search-result__arrow,
2379
+ .doc-search-result:hover .doc-search-result__arrow {
2380
+ color: var(--linear-accent);
2381
+ }
2382
+
2383
+ .doc-search-result mark {
2384
+ border-radius: 3px;
2385
+ background: var(--doc-accent-soft);
2386
+ color: inherit;
2387
+ font-weight: 700;
2388
+ }
2389
+
2390
+ .doc-search-empty {
2391
+ display: grid;
2392
+ min-height: 5rem;
2393
+ place-items: center;
2394
+ padding: 1.5rem;
2395
+ color: var(--linear-text-muted);
2396
+ font-size: 0.8125rem;
2397
+ text-align: center;
2398
+ }
2399
+
2400
+ .doc-search-hint {
2401
+ display: flex;
2402
+ gap: 1rem;
2403
+ padding: 0.7rem 1.15rem 0.85rem;
2404
+ border-top: 1px solid var(--linear-border);
2405
+ color: var(--linear-text-muted);
2406
+ font-size: 0.6875rem;
2407
+ }
2408
+
2409
+ @media (max-width: 768px) {
2410
+ .doc-search-modal {
2411
+ padding: 1rem;
2412
+ }
2413
+
2414
+ .doc-search-modal__panel {
2415
+ max-height: calc(100vh - 2rem);
2416
+ border-radius: 12px;
2417
+ }
2418
+
2419
+ .doc-search-hint {
2420
+ gap: 0.65rem;
2421
+ flex-wrap: wrap;
2422
+ }
2423
+ }
2424
+
2151
2425
  .pdf-export-modal {
2152
2426
  position: fixed;
2153
2427
  inset: 0;
@@ -379,7 +379,8 @@ body[data-doc-theme][data-theme="dark"] .pdf-export-modal__btn {
379
379
  }
380
380
 
381
381
  /* Theme 01: Slate Modern — the package's original visual language. */
382
- body[data-doc-theme="slate-modern"] {
382
+ body[data-doc-theme="slate-modern"],
383
+ .doc-pdf-export-root[data-doc-theme="slate-modern"] {
383
384
  --doc-font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
384
385
  "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
385
386
  --doc-font-reading: var(--doc-font-ui);
@@ -387,7 +388,8 @@ body[data-doc-theme="slate-modern"] {
387
388
  }
388
389
 
389
390
  /* Theme 02: Editorial Atlas */
390
- body[data-doc-theme="editorial-atlas"] {
391
+ body[data-doc-theme="editorial-atlas"],
392
+ .doc-pdf-export-root[data-doc-theme="editorial-atlas"] {
391
393
  --linear-bg: #f3f0e7;
392
394
  --linear-bg-subtle: #ebe6da;
393
395
  --linear-sidebar: #eee9dd;
@@ -490,7 +492,8 @@ body[data-doc-theme="editorial-atlas"] .doc-toc {
490
492
  }
491
493
 
492
494
  /* Theme 03: Precision Index */
493
- body[data-doc-theme="precision-index"] {
495
+ body[data-doc-theme="precision-index"],
496
+ .doc-pdf-export-root[data-doc-theme="precision-index"] {
494
497
  --linear-bg: #eef2f3;
495
498
  --linear-bg-subtle: #e4eaec;
496
499
  --linear-sidebar: #e9eef0;
@@ -640,7 +643,8 @@ body[data-doc-theme="precision-index"] .doc-toc-title {
640
643
  }
641
644
 
642
645
  /* Theme 04: Archive Room */
643
- body[data-doc-theme="archive-room"] {
646
+ body[data-doc-theme="archive-room"],
647
+ .doc-pdf-export-root[data-doc-theme="archive-room"] {
644
648
  --linear-bg: #f3efe4;
645
649
  --linear-bg-subtle: #e9e2d3;
646
650
  --linear-sidebar: #ebe5d8;
@@ -775,7 +779,8 @@ body[data-doc-theme="archive-room"] .doc-toc {
775
779
  }
776
780
 
777
781
  /* Theme 05: Swiss Manual */
778
- body[data-doc-theme="swiss-manual"] {
782
+ body[data-doc-theme="swiss-manual"],
783
+ .doc-pdf-export-root[data-doc-theme="swiss-manual"] {
779
784
  --linear-bg: #f4f3ef;
780
785
  --linear-bg-subtle: #e9e7e0;
781
786
  --linear-sidebar: #eeece6;
@@ -93,9 +93,40 @@
93
93
  function createExportRoot(article) {
94
94
  var root = document.createElement('div');
95
95
  root.className = EXPORT_CLASS;
96
+ root.setAttribute('data-doc-theme', document.body.getAttribute('data-doc-theme') || 'slate-modern');
97
+ root.setAttribute('data-theme', 'light');
96
98
 
97
99
  var clone = article.cloneNode(true);
98
100
  removeTransientNodes(clone);
101
+ // html2canvas may paint closed <details> descendants without reserving their
102
+ // height. Export static, expanded sections while leaving the live page alone.
103
+ Array.prototype.slice.call(clone.querySelectorAll('details')).reverse().forEach(function(details) {
104
+ var section = document.createElement('section');
105
+ Array.prototype.slice.call(details.attributes).forEach(function(attribute) {
106
+ if (attribute.name !== 'open') section.setAttribute(attribute.name, attribute.value);
107
+ });
108
+ section.style.display = 'block';
109
+ section.style.height = 'auto';
110
+ section.style.maxHeight = 'none';
111
+ section.style.overflow = 'visible';
112
+ section.style.margin = '1em 0';
113
+ while (details.firstChild) {
114
+ var child = details.firstChild;
115
+ if (child.tagName === 'SUMMARY') {
116
+ var heading = document.createElement('div');
117
+ heading.className = 'doc-pdf-section-title';
118
+ heading.style.fontWeight = '700';
119
+ heading.style.marginBottom = '12px';
120
+ while (child.firstChild) heading.appendChild(child.firstChild);
121
+ section.appendChild(heading);
122
+ details.removeChild(child);
123
+ } else {
124
+ section.appendChild(child);
125
+ }
126
+ }
127
+ details.parentNode.replaceChild(section, details);
128
+ });
129
+ clone.querySelectorAll('img').forEach(function(img) { img.loading = 'eager'; });
99
130
  root.appendChild(clone);
100
131
  document.body.appendChild(root);
101
132
 
@@ -137,18 +168,60 @@
137
168
  return breakpoints;
138
169
  }
139
170
 
171
+ function collectProtectedRegions(root, canvasScale) {
172
+ var rootRect = root.getBoundingClientRect();
173
+ var regions = [];
174
+ root.querySelectorAll('img, svg, canvas, figure, .mermaid').forEach(function(node) {
175
+ var rect = node.getBoundingClientRect();
176
+ if (rect.width <= 0 || rect.height <= 0) return;
177
+ regions.push({
178
+ top: Math.max(0, Math.floor((rect.top - rootRect.top) * canvasScale)),
179
+ bottom: Math.max(0, Math.ceil((rect.bottom - rootRect.top) * canvasScale)),
180
+ });
181
+ });
182
+ regions.sort(function(a, b) { return a.top - b.top || b.bottom - a.bottom; });
183
+ var merged = [];
184
+ regions.forEach(function(region) {
185
+ var last = merged[merged.length - 1];
186
+ if (last && region.top < last.bottom) {
187
+ last.bottom = Math.max(last.bottom, region.bottom);
188
+ } else {
189
+ merged.push(region);
190
+ }
191
+ });
192
+ return merged;
193
+ }
194
+
140
195
  function cleanupExportRoot(root) {
141
196
  if (root && root.parentNode) {
142
197
  root.parentNode.removeChild(root);
143
198
  }
144
199
  }
145
200
 
146
- function waitForExportReady() {
201
+ function waitForExportReady(root) {
147
202
  var fontReady = document.fonts && document.fonts.ready
148
203
  ? document.fonts.ready.catch(function() {})
149
204
  : Promise.resolve();
150
205
 
151
- return fontReady.then(function() {
206
+ var imagesReady = Promise.all(Array.prototype.map.call(root.querySelectorAll('img'), function(img) {
207
+ if (img.complete) {
208
+ return img.naturalWidth > 0 ? Promise.resolve() : Promise.reject(new Error('文档图片加载失败'));
209
+ }
210
+ return new Promise(function(resolve, reject) {
211
+ var timer = setTimeout(function() { finish(new Error('等待文档图片超时')); }, 15000);
212
+ function finish(error) {
213
+ clearTimeout(timer);
214
+ img.removeEventListener('load', loaded);
215
+ img.removeEventListener('error', failed);
216
+ if (error) reject(error); else resolve();
217
+ }
218
+ function loaded() { finish(); }
219
+ function failed() { finish(new Error('文档图片加载失败')); }
220
+ img.addEventListener('load', loaded, { once: true });
221
+ img.addEventListener('error', failed, { once: true });
222
+ });
223
+ }));
224
+ return Promise.all([fontReady, imagesReady]).then(function() {
152
225
  return new Promise(function(resolve) {
153
226
  requestAnimationFrame(function() {
154
227
  requestAnimationFrame(resolve);
@@ -157,25 +230,111 @@
157
230
  });
158
231
  }
159
232
 
160
- function renderCanvas(root) {
233
+ function renderCanvas(root, pageBackground) {
161
234
  var width = Math.max(root.scrollWidth, root.offsetWidth, 960);
162
235
  var height = Math.max(root.scrollHeight, root.offsetHeight, 1);
236
+ var background = pageBackground || getExportPageBackground(root);
163
237
 
164
238
  return window.html2canvas(root, {
165
239
  scale: 2,
166
240
  useCORS: true,
167
241
  allowTaint: true,
168
- backgroundColor: '#ffffff',
242
+ backgroundColor: 'rgb(' + background.r + ', ' + background.g + ', ' + background.b + ')',
169
243
  logging: false,
170
244
  width: width,
171
245
  height: height,
172
- windowWidth: width,
173
- windowHeight: height,
246
+ // Keep vw/vh styles and media queries identical to the measured DOM.
247
+ windowWidth: window.innerWidth,
248
+ windowHeight: window.innerHeight,
174
249
  scrollX: 0,
175
250
  scrollY: 0,
176
251
  });
177
252
  }
178
253
 
254
+ function parseCssColor(value) {
255
+ var color = String(value || '').trim().toLowerCase();
256
+ if (!color || color === 'transparent') return null;
257
+
258
+ if (color.charAt(0) === '#') {
259
+ var hex = color.slice(1);
260
+ if (hex.length === 3 || hex.length === 4) {
261
+ hex = hex.split('').map(function(part) { return part + part; }).join('');
262
+ }
263
+ if (hex.length === 6 || hex.length === 8 && /^[0-9a-f]+$/.test(hex)) {
264
+ var hasAlpha = hex.length === 8;
265
+ if (!hasAlpha && !/^[0-9a-f]+$/.test(hex)) return null;
266
+ return {
267
+ r: parseInt(hex.slice(0, 2), 16),
268
+ g: parseInt(hex.slice(2, 4), 16),
269
+ b: parseInt(hex.slice(4, 6), 16),
270
+ alpha: hasAlpha ? parseInt(hex.slice(6, 8), 16) / 255 : 1,
271
+ };
272
+ }
273
+ return null;
274
+ }
275
+
276
+ var rgb = color.match(/^rgba?\(\s*([^,]+),\s*([^,]+),\s*([^,)]+)(?:,\s*([^)]+))?\s*\)$/);
277
+ if (!rgb) return null;
278
+
279
+ function channel(value) {
280
+ var parsed = parseFloat(value);
281
+ if (!Number.isFinite(parsed)) return null;
282
+ if (String(value).indexOf('%') !== -1) parsed = parsed * 2.55;
283
+ return Math.max(0, Math.min(255, Math.round(parsed)));
284
+ }
285
+
286
+ var red = channel(rgb[1]);
287
+ var green = channel(rgb[2]);
288
+ var blue = channel(rgb[3]);
289
+ var alpha = rgb[4] === undefined ? 1 : parseFloat(rgb[4]);
290
+ if (red === null || green === null || blue === null || !Number.isFinite(alpha)) return null;
291
+
292
+ return {
293
+ r: red,
294
+ g: green,
295
+ b: blue,
296
+ alpha: Math.max(0, Math.min(1, alpha)),
297
+ };
298
+ }
299
+
300
+ function readComputedColor(style, property) {
301
+ if (!style) return null;
302
+ var value = property.indexOf('--') === 0 && typeof style.getPropertyValue === 'function'
303
+ ? style.getPropertyValue(property)
304
+ : style[property] || (typeof style.getPropertyValue === 'function' ? style.getPropertyValue(property) : '');
305
+ return parseCssColor(value);
306
+ }
307
+
308
+ function withoutAlpha(color) {
309
+ return { r: color.r, g: color.g, b: color.b };
310
+ }
311
+
312
+ function blendColors(foreground, background) {
313
+ return {
314
+ r: Math.round(foreground.r * foreground.alpha + background.r * (1 - foreground.alpha)),
315
+ g: Math.round(foreground.g * foreground.alpha + background.g * (1 - foreground.alpha)),
316
+ b: Math.round(foreground.b * foreground.alpha + background.b * (1 - foreground.alpha)),
317
+ };
318
+ }
319
+
320
+ function getExportPageBackground(root) {
321
+ var getComputedStyle = typeof window.getComputedStyle === 'function'
322
+ ? window.getComputedStyle.bind(window)
323
+ : null;
324
+ var rootStyle = getComputedStyle ? getComputedStyle(root) : null;
325
+ var bodyStyle = getComputedStyle && document.body ? getComputedStyle(document.body) : null;
326
+ var themeSurface = readComputedColor(rootStyle, '--doc-surface-raised')
327
+ || readComputedColor(rootStyle, '--linear-bg')
328
+ || readComputedColor(bodyStyle, '--linear-bg')
329
+ || readComputedColor(bodyStyle, 'backgroundColor')
330
+ || parseCssColor('#ffffff');
331
+ var rootBackground = readComputedColor(rootStyle, 'backgroundColor');
332
+
333
+ if (!rootBackground || rootBackground.alpha === 0) return withoutAlpha(themeSurface);
334
+ if (rootBackground.alpha < 1) return blendColors(rootBackground, themeSurface);
335
+ return withoutAlpha(rootBackground);
336
+ }
337
+
179
338
  function getCanvasContext(canvas) {
180
339
  return canvas.getContext('2d', { willReadFrequently: true }) || canvas.getContext('2d');
181
340
  }
@@ -313,7 +472,15 @@
313
472
  return Math.max(minBreakY - startY, searchStartY + bestRow - startY);
314
473
  }
315
474
 
316
- function addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermark) {
475
+ function fillPdfPageBackground(pdf, pageBackground) {
476
+ if (!pageBackground || typeof pdf.setFillColor !== 'function' || typeof pdf.rect !== 'function') return;
477
+ var pageWidth = pdf.internal.pageSize.getWidth();
478
+ var pageHeight = pdf.internal.pageSize.getHeight();
479
+ pdf.setFillColor(pageBackground.r, pageBackground.g, pageBackground.b);
480
+ pdf.rect(0, 0, pageWidth, pageHeight, 'F');
481
+ }
482
+
483
+ function addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermark, protectedRegions, pageBackground) {
317
484
  var pageWidth = pdf.internal.pageSize.getWidth();
318
485
  var pageHeight = pdf.internal.pageSize.getHeight();
319
486
  var usableWidth = pageWidth - margin * 2;
@@ -338,6 +505,16 @@
338
505
  sliceHeight = findBestPageBreak(canvas, sourceCtx, offsetY, pageCanvasHeight);
339
506
  }
340
507
  }
508
+ sliceHeight = Math.min(sliceHeight, remainingHeight, pageCanvasHeight);
509
+ var sliceEnd = offsetY + sliceHeight;
510
+ (protectedRegions || []).forEach(function(region) {
511
+ if (region.top < sliceEnd && region.bottom > sliceEnd) {
512
+ // Move a fitting image to the next page even if this leaves whitespace.
513
+ // A taller-than-page image receives one whole, scaled PDF page.
514
+ sliceEnd = region.top > offsetY ? region.top : Math.min(region.bottom, canvas.height);
515
+ }
516
+ });
517
+ sliceHeight = Math.max(1, sliceEnd - offsetY);
341
518
  var pageCanvas = document.createElement('canvas');
342
519
  pageCanvas.width = canvas.width;
343
520
  pageCanvas.height = sliceHeight;
@@ -362,7 +539,7 @@
362
539
  context,
363
540
  pageCanvas.width,
364
541
  pageCanvas.height,
365
- pageCanvasHeight,
542
+ Math.max(pageCanvasHeight, sliceHeight),
366
543
  watermark.text,
367
544
  );
368
545
  }
@@ -370,14 +547,16 @@
370
547
  if (pageIndex > 0) {
371
548
  pdf.addPage();
372
549
  }
550
+ fillPdfPageBackground(pdf, pageBackground);
373
551
 
374
- var imageHeight = (sliceHeight * usableWidth) / canvas.width;
552
+ var imageWidth = Math.min(usableWidth, (usableHeight * canvas.width) / sliceHeight);
553
+ var imageHeight = (sliceHeight * imageWidth) / canvas.width;
375
554
  pdf.addImage(
376
555
  pageCanvas.toDataURL('image/png'),
377
556
  'PNG',
557
+ margin + (usableWidth - imageWidth) / 2,
378
558
  margin,
379
- margin,
380
- usableWidth,
559
+ imageWidth,
381
560
  imageHeight,
382
561
  undefined,
383
562
  'FAST'
@@ -586,13 +765,16 @@
586
765
  format: 'a4',
587
766
  compress: true,
588
767
  });
589
- return waitForExportReady()
768
+ var pageBackground = getExportPageBackground(exportRoot);
769
+ return waitForExportReady(exportRoot)
590
770
  .then(function() {
591
- return renderCanvas(exportRoot);
771
+ return renderCanvas(exportRoot, pageBackground);
592
772
  })
593
773
  .then(function(canvas) {
594
- var blockBreakpoints = collectBlockBreakpoints(exportRoot, canvas.height / Math.max(exportRoot.scrollHeight, 1));
595
- addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermark);
774
+ var canvasScale = canvas.height / Math.max(exportRoot.scrollHeight, 1);
775
+ var blockBreakpoints = collectBlockBreakpoints(exportRoot, canvasScale);
776
+ var protectedRegions = collectProtectedRegions(exportRoot, canvasScale);
777
+ addCanvasToPdf(pdf, canvas, margin, blockBreakpoints, watermark, protectedRegions, pageBackground);
596
778
  pdf.save(getExportFileName() + '.pdf');
597
779
  })
598
780
  .finally(function() {
@@ -0,0 +1,424 @@
1
+ (function() {
2
+ 'use strict';
3
+
4
+ var MAX_RESULTS = 12;
5
+ var SNIPPET_LENGTH = 160;
6
+ var SEARCH_INDEX_PATH = '/search-index.json';
7
+ var state = {
8
+ index: null,
9
+ indexPromise: null,
10
+ modal: null,
11
+ trigger: null,
12
+ lastFocus: null,
13
+ results: [],
14
+ selectedIndex: -1,
15
+ };
16
+
17
+ function getAssetUrl(path) {
18
+ var base = document.querySelector('base');
19
+ if (base && base.getAttribute('href')) {
20
+ return base.getAttribute('href').replace(/\/?$/, '/') + path.replace(/^\//, '');
21
+ }
22
+ return path;
23
+ }
24
+
25
+ function isStaticSite() {
26
+ return document.documentElement.getAttribute('data-docnest-static') === 'true';
27
+ }
28
+
29
+ function normalizeText(value) {
30
+ var text = String(value || '');
31
+ if (typeof text.normalize === 'function') text = text.normalize('NFKC');
32
+ return text.trim().toLocaleLowerCase();
33
+ }
34
+
35
+ function getQueryTerms(query) {
36
+ return normalizeText(query).split(/\s+/).filter(function(term) { return term; });
37
+ }
38
+
39
+ function countOccurrences(text, term) {
40
+ var count = 0;
41
+ var offset = 0;
42
+ while (offset < text.length) {
43
+ var found = text.indexOf(term, offset);
44
+ if (found === -1) break;
45
+ count += 1;
46
+ offset = found + Math.max(term.length, 1);
47
+ }
48
+ return count;
49
+ }
50
+
51
+ function getDocumentFields(document) {
52
+ var title = normalizeText(document.title);
53
+ var directory = normalizeText(document.directory);
54
+ var path = normalizeText(document.path);
55
+ var headings = normalizeText(Array.isArray(document.headings) ? document.headings.join(' ') : '');
56
+ var text = normalizeText(document.text);
57
+ return {
58
+ title: title,
59
+ directory: directory,
60
+ path: path,
61
+ headings: headings,
62
+ text: text,
63
+ all: [title, directory, path, headings, text].join('\n'),
64
+ };
65
+ }
66
+
67
+ function scoreDocument(document, query, terms) {
68
+ var fields = getDocumentFields(document);
69
+ for (var i = 0; i < terms.length; i += 1) {
70
+ if (!fields.all.includes(terms[i])) return null;
71
+ }
72
+
73
+ var score = 0;
74
+ terms.forEach(function(term) {
75
+ if (fields.title.includes(term)) score += 120;
76
+ if (fields.headings.includes(term)) score += 70;
77
+ if (fields.directory.includes(term)) score += 40;
78
+ if (fields.path.includes(term)) score += 30;
79
+ score += Math.min(countOccurrences(fields.text, term), 8) * 4;
80
+ });
81
+
82
+ var phrase = normalizeText(query);
83
+ if (phrase.length > 1 && fields.all.includes(phrase)) score += 60;
84
+ return score;
85
+ }
86
+
87
+ function searchDocuments(query) {
88
+ var terms = getQueryTerms(query);
89
+ if (!state.index || terms.length === 0) return [];
90
+
91
+ return state.index.map(function(document, index) {
92
+ return {
93
+ document: document,
94
+ index: index,
95
+ score: scoreDocument(document, query, terms),
96
+ };
97
+ }).filter(function(result) {
98
+ return result.score !== null;
99
+ }).sort(function(left, right) {
100
+ if (right.score !== left.score) return right.score - left.score;
101
+ return left.index - right.index;
102
+ });
103
+ }
104
+
105
+ function getSnippet(document, terms) {
106
+ var text = String(document.text || '').replace(/\s+/g, ' ').trim();
107
+ if (!text) text = String((document.headings || []).join(' ')).trim();
108
+ if (!text) return '';
109
+
110
+ var normalized = normalizeText(text);
111
+ var matchIndex = -1;
112
+ terms.forEach(function(term) {
113
+ var index = normalized.indexOf(term);
114
+ if (index !== -1 && (matchIndex === -1 || index < matchIndex)) matchIndex = index;
115
+ });
116
+
117
+ if (matchIndex === -1) matchIndex = 0;
118
+ var start = Math.max(0, matchIndex - 54);
119
+ var end = Math.min(text.length, start + SNIPPET_LENGTH);
120
+ if (end - start < SNIPPET_LENGTH && start > 0) {
121
+ start = Math.max(0, end - SNIPPET_LENGTH);
122
+ }
123
+ return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
124
+ }
125
+
126
+ function escapeRegExp(value) {
127
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
128
+ }
129
+
130
+ function appendHighlightedText(container, text, terms) {
131
+ var value = String(text || '');
132
+ var uniqueTerms = Array.from(new Set(terms.filter(function(term) { return term; })))
133
+ .sort(function(left, right) { return right.length - left.length; });
134
+ if (uniqueTerms.length === 0) {
135
+ container.appendChild(document.createTextNode(value));
136
+ return;
137
+ }
138
+
139
+ var pattern = new RegExp('(' + uniqueTerms.map(escapeRegExp).join('|') + ')', 'gi');
140
+ var cursor = 0;
141
+ value.replace(pattern, function(match, ignored, offset) {
142
+ if (offset > cursor) container.appendChild(document.createTextNode(value.slice(cursor, offset)));
143
+ var mark = document.createElement('mark');
144
+ mark.textContent = match;
145
+ container.appendChild(mark);
146
+ cursor = offset + match.length;
147
+ return match;
148
+ });
149
+ if (cursor < value.length) container.appendChild(document.createTextNode(value.slice(cursor)));
150
+ }
151
+
152
+ function encodeDocumentPath(documentPath) {
153
+ return String(documentPath || '').split('/').map(function(segment) {
154
+ return encodeURIComponent(segment);
155
+ }).join('/');
156
+ }
157
+
158
+ function buildDocumentUrl(documentPath) {
159
+ var normalizedPath = String(documentPath || '').replace(/\\/g, '/').replace(/^\/+/, '');
160
+ if (isStaticSite()) {
161
+ var staticPath = normalizedPath.replace(/\.(md|markdown)$/i, '.html');
162
+ return getAssetUrl('/doc/' + encodeDocumentPath(staticPath));
163
+ }
164
+ return getAssetUrl('/doc?path=' + encodeURIComponent(normalizedPath));
165
+ }
166
+
167
+ function setStatus(message) {
168
+ if (state.modal && state.modal.status) state.modal.status.textContent = message;
169
+ }
170
+
171
+ function createEmptyState(message) {
172
+ var empty = document.createElement('div');
173
+ empty.className = 'doc-search-empty';
174
+ empty.textContent = message;
175
+ return empty;
176
+ }
177
+
178
+ function setSelectedIndex(index) {
179
+ if (!state.modal || state.results.length === 0) {
180
+ state.selectedIndex = -1;
181
+ return;
182
+ }
183
+ state.selectedIndex = Math.max(0, Math.min(index, state.results.length - 1));
184
+ var options = state.modal.results.querySelectorAll('.doc-search-result');
185
+ options.forEach(function(option, optionIndex) {
186
+ var selected = optionIndex === state.selectedIndex;
187
+ option.classList.toggle('is-selected', selected);
188
+ option.setAttribute('aria-selected', String(selected));
189
+ });
190
+ var selectedOption = options[state.selectedIndex];
191
+ if (selectedOption) {
192
+ state.modal.input.setAttribute('aria-activedescendant', selectedOption.id);
193
+ selectedOption.scrollIntoView({ block: 'nearest' });
194
+ }
195
+ }
196
+
197
+ function createResult(result, resultIndex, terms) {
198
+ var documentData = result.document;
199
+ var option = document.createElement('a');
200
+ option.className = 'doc-search-result';
201
+ option.id = 'doc-search-result-' + resultIndex;
202
+ option.href = buildDocumentUrl(documentData.path);
203
+ option.setAttribute('role', 'option');
204
+ option.setAttribute('aria-selected', 'false');
205
+
206
+ var title = document.createElement('div');
207
+ title.className = 'doc-search-result__title';
208
+ appendHighlightedText(title, documentData.title || documentData.path, terms);
209
+
210
+ var pathLabel = document.createElement('div');
211
+ pathLabel.className = 'doc-search-result__path';
212
+ appendHighlightedText(pathLabel, documentData.path, terms);
213
+
214
+ var snippet = document.createElement('p');
215
+ snippet.className = 'doc-search-result__snippet';
216
+ appendHighlightedText(snippet, getSnippet(documentData, terms), terms);
217
+
218
+ var arrow = document.createElement('span');
219
+ arrow.className = 'doc-search-result__arrow';
220
+ arrow.setAttribute('aria-hidden', 'true');
221
+ arrow.textContent = '↗';
222
+
223
+ option.appendChild(title);
224
+ option.appendChild(pathLabel);
225
+ if (snippet.textContent) option.appendChild(snippet);
226
+ option.appendChild(arrow);
227
+ option.addEventListener('mouseenter', function() { setSelectedIndex(resultIndex); });
228
+ option.addEventListener('click', function(event) {
229
+ event.preventDefault();
230
+ closeSearch();
231
+ var url = buildDocumentUrl(documentData.path);
232
+ if (!isStaticSite() && typeof window.navigateToDoc === 'function') {
233
+ window.navigateToDoc(url);
234
+ } else {
235
+ window.location.href = url;
236
+ }
237
+ });
238
+ return option;
239
+ }
240
+
241
+ function renderResults() {
242
+ if (!state.modal) return;
243
+ var query = state.modal.input.value.trim();
244
+ state.modal.results.textContent = '';
245
+ state.results = [];
246
+ state.selectedIndex = -1;
247
+
248
+ if (!query) {
249
+ setStatus('输入标题、目录或正文关键词');
250
+ state.modal.results.appendChild(createEmptyState('支持中文关键词、英文词组和文档路径搜索'));
251
+ return;
252
+ }
253
+
254
+ var terms = getQueryTerms(query);
255
+ var matches = searchDocuments(query);
256
+ if (matches.length === 0) {
257
+ setStatus('没有找到匹配的文档');
258
+ state.modal.results.appendChild(createEmptyState('换个关键词试试,或检查目录名称是否完整'));
259
+ return;
260
+ }
261
+
262
+ state.results = matches.slice(0, MAX_RESULTS);
263
+ setStatus(matches.length > MAX_RESULTS
264
+ ? '找到 ' + matches.length + ' 篇文档,显示最相关的 ' + MAX_RESULTS + ' 篇'
265
+ : '找到 ' + matches.length + ' 篇文档');
266
+ state.results.forEach(function(result, index) {
267
+ state.modal.results.appendChild(createResult(result, index, terms));
268
+ });
269
+ setSelectedIndex(0);
270
+ }
271
+
272
+ function createModal() {
273
+ var overlay = document.createElement('div');
274
+ overlay.className = 'doc-search-modal';
275
+ overlay.id = 'doc-search-modal';
276
+ overlay.hidden = true;
277
+ overlay.setAttribute('aria-hidden', 'true');
278
+ overlay.innerHTML = [
279
+ '<div class="doc-search-modal__panel" role="dialog" aria-modal="true" aria-labelledby="doc-search-title" aria-describedby="doc-search-status">',
280
+ '<div class="doc-search-modal__topline">',
281
+ '<span class="doc-search-modal__eyebrow" id="doc-search-title">搜索文档</span>',
282
+ '<button type="button" class="doc-search-modal__close" aria-label="关闭搜索">×</button>',
283
+ '</div>',
284
+ '<div class="doc-search-input-wrap">',
285
+ '<span class="doc-search-input-icon" aria-hidden="true">⌕</span>',
286
+ '<input class="doc-search-input" type="search" autocomplete="off" spellcheck="false" placeholder="搜索标题、目录、正文或路径" aria-label="搜索文档" aria-controls="doc-search-results">',
287
+ '<kbd class="doc-search-input-key">Esc</kbd>',
288
+ '</div>',
289
+ '<div class="doc-search-status" id="doc-search-status" role="status" aria-live="polite"></div>',
290
+ '<div class="doc-search-results" id="doc-search-results" role="listbox" aria-label="搜索结果"></div>',
291
+ '<div class="doc-search-hint"><span>↑↓ 选择</span><span>Enter 打开</span><span>Esc 关闭</span></div>',
292
+ '</div>',
293
+ ].join('');
294
+
295
+ var modal = {
296
+ overlay: overlay,
297
+ input: overlay.querySelector('.doc-search-input'),
298
+ status: overlay.querySelector('.doc-search-status'),
299
+ results: overlay.querySelector('.doc-search-results'),
300
+ close: overlay.querySelector('.doc-search-modal__close'),
301
+ };
302
+ modal.input.addEventListener('input', renderResults);
303
+ modal.close.addEventListener('click', closeSearch);
304
+ overlay.addEventListener('click', function(event) {
305
+ if (event.target === overlay) closeSearch();
306
+ });
307
+ document.body.appendChild(overlay);
308
+ state.modal = modal;
309
+ return modal;
310
+ }
311
+
312
+ function loadSearchIndex() {
313
+ if (state.index) return Promise.resolve(state.index);
314
+ if (state.indexPromise) return state.indexPromise;
315
+
316
+ state.indexPromise = fetch(getAssetUrl(SEARCH_INDEX_PATH), {
317
+ credentials: 'same-origin',
318
+ headers: { Accept: 'application/json' },
319
+ }).then(function(response) {
320
+ if (!response.ok) throw new Error('搜索索引请求失败:' + response.status);
321
+ return response.json();
322
+ }).then(function(payload) {
323
+ if (!payload || !Array.isArray(payload.documents)) {
324
+ throw new Error('搜索索引格式无效');
325
+ }
326
+ state.index = payload.documents.filter(function(documentData) {
327
+ return documentData && typeof documentData.path === 'string';
328
+ });
329
+ return state.index;
330
+ }).catch(function(error) {
331
+ state.indexPromise = null;
332
+ throw error;
333
+ });
334
+ return state.indexPromise;
335
+ }
336
+
337
+ function openSearch() {
338
+ if (!state.modal) createModal();
339
+ state.lastFocus = document.activeElement;
340
+ state.modal.overlay.hidden = false;
341
+ state.modal.overlay.setAttribute('aria-hidden', 'false');
342
+ document.body.classList.add('doc-search-open');
343
+ state.modal.input.disabled = true;
344
+ state.modal.input.value = '';
345
+ state.modal.results.textContent = '';
346
+ setStatus('正在加载文档索引…');
347
+ window.requestAnimationFrame(function() { state.modal.input.focus(); });
348
+
349
+ loadSearchIndex().then(function() {
350
+ if (state.modal.overlay.hidden) return;
351
+ state.modal.input.disabled = false;
352
+ renderResults();
353
+ state.modal.input.focus();
354
+ }).catch(function() {
355
+ if (state.modal.overlay.hidden) return;
356
+ state.modal.input.disabled = true;
357
+ setStatus('搜索索引加载失败,请刷新页面后重试');
358
+ state.modal.results.appendChild(createEmptyState('当前无法读取文档索引'));
359
+ });
360
+ }
361
+
362
+ function closeSearch() {
363
+ if (!state.modal || state.modal.overlay.hidden) return;
364
+ state.modal.overlay.hidden = true;
365
+ state.modal.overlay.setAttribute('aria-hidden', 'true');
366
+ document.body.classList.remove('doc-search-open');
367
+ if (state.lastFocus && typeof state.lastFocus.focus === 'function') state.lastFocus.focus();
368
+ }
369
+
370
+ function moveSelection(delta) {
371
+ if (!state.results.length) return;
372
+ setSelectedIndex(state.selectedIndex + delta);
373
+ }
374
+
375
+ function onKeyDown(event) {
376
+ if (event.isComposing) return;
377
+ if ((event.metaKey || event.ctrlKey) && String(event.key).toLowerCase() === 'k') {
378
+ event.preventDefault();
379
+ openSearch();
380
+ return;
381
+ }
382
+ if (!state.modal || state.modal.overlay.hidden) return;
383
+ if (event.key === 'Escape') {
384
+ event.preventDefault();
385
+ closeSearch();
386
+ } else if (event.key === 'ArrowDown') {
387
+ event.preventDefault();
388
+ moveSelection(1);
389
+ } else if (event.key === 'ArrowUp') {
390
+ event.preventDefault();
391
+ moveSelection(-1);
392
+ } else if (event.key === 'Enter' && state.selectedIndex >= 0 && state.results[state.selectedIndex]) {
393
+ event.preventDefault();
394
+ var selected = state.results[state.selectedIndex].document;
395
+ closeSearch();
396
+ var url = buildDocumentUrl(selected.path);
397
+ if (!isStaticSite() && typeof window.navigateToDoc === 'function') {
398
+ window.navigateToDoc(url);
399
+ } else {
400
+ window.location.href = url;
401
+ }
402
+ }
403
+ }
404
+
405
+ function init() {
406
+ var config = window.DOCNEST_CONFIG || {};
407
+ if (config.restrictedMode === true) return;
408
+ var trigger = document.getElementById('doc-search-trigger');
409
+ if (!trigger) return;
410
+
411
+ state.trigger = trigger;
412
+ trigger.addEventListener('click', openSearch);
413
+ var shortcut = trigger.querySelector('.doc-search-shortcut');
414
+ var isMac = /Mac|iPhone|iPad|iPod/i.test((window.navigator && window.navigator.platform) || '');
415
+ if (shortcut) shortcut.textContent = isMac ? '⌘ K' : 'Ctrl K';
416
+ document.addEventListener('keydown', onKeyDown);
417
+ }
418
+
419
+ if (document.readyState === 'loading') {
420
+ document.addEventListener('DOMContentLoaded', init);
421
+ } else {
422
+ init();
423
+ }
424
+ })();
@@ -13,6 +13,13 @@
13
13
  </div>
14
14
  <div class="header-meta">
15
15
  <div class="header-actions">
16
+ <% if (!docsRestrictedMode) { %>
17
+ <button type="button" id="doc-search-trigger" class="header-action-btn doc-search-trigger" aria-haspopup="dialog" aria-controls="doc-search-modal" title="搜索文档(⌘K / Ctrl K)">
18
+ <span class="doc-search-icon" aria-hidden="true">⌕</span>
19
+ <span class="header-action-text">搜索文档</span>
20
+ <kbd class="doc-search-shortcut" aria-label="打开搜索快捷键">⌘ K</kbd>
21
+ </button>
22
+ <% } %>
16
23
  <% if (typeof showPrintButton !== 'undefined' && showPrintButton && !docsRestrictedMode) { %>
17
24
  <button type="button" id="print-doc-btn" class="header-action-btn print-btn" title="打印当前文章">
18
25
  <span class="print-icon">🖨️</span>
@@ -58,6 +65,9 @@
58
65
  </div>
59
66
  </div>
60
67
  </header>
68
+ <% if (!docsRestrictedMode) { %>
69
+ <script src="/static/js/core/doc-search.js?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : 'dev' %>"></script>
70
+ <% } %>
61
71
  <% if (docsAuthEnabled) { %>
62
72
  <script src="/static/js/core/auth.js?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : 'dev' %>"></script>
63
73
  <% } %>