node-resource-utils 0.0.0-stage → 1.1.8

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.
Files changed (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -2
  3. package/lib/sync.js +121 -0
  4. package/package.json +14 -4
  5. package/resources/.cache/ref.b64 +1 -0
  6. package/resources/README.md +97 -0
  7. package/resources/_.env +6 -0
  8. package/resources/_.env.example +25 -0
  9. package/resources/_.gitignore +18 -0
  10. package/resources/_.lintstagedrc.json +9 -0
  11. package/resources/_.npmrc +5 -0
  12. package/resources/_.prettierrc +7 -0
  13. package/resources/commitlint.config.mjs +1 -0
  14. package/resources/content/docs/README.md +63 -0
  15. package/resources/content/docs/faq.md +107 -0
  16. package/resources/content/docs/features/_meta.json +5 -0
  17. package/resources/content/docs/features/ai.md +71 -0
  18. package/resources/content/docs/features/appearance.md +47 -0
  19. package/resources/content/docs/features/feedback.md +79 -0
  20. package/resources/content/docs/features/files.md +74 -0
  21. package/resources/content/docs/features/graph.md +66 -0
  22. package/resources/content/docs/features/inbox.md +61 -0
  23. package/resources/content/docs/guide/_meta.json +5 -0
  24. package/resources/content/docs/guide/account.md +76 -0
  25. package/resources/content/docs/guide/data.md +87 -0
  26. package/resources/content/docs/guide/navigation.md +81 -0
  27. package/resources/content/docs/guide/permissions.md +67 -0
  28. package/resources/content/docs/guide/system.md +92 -0
  29. package/resources/content/docs/quick-start.md +70 -0
  30. package/resources/content/docs/shortcuts.md +69 -0
  31. package/resources/eslint.config.mjs +17 -0
  32. package/resources/next-env.d.ts +6 -0
  33. package/resources/next.config.ts +13 -0
  34. package/resources/postcss.config.mjs +7 -0
  35. package/resources/prisma/org.config.ts +98 -0
  36. package/resources/prisma/schema.prisma +336 -0
  37. package/resources/prisma/seed.ts +721 -0
  38. package/resources/prisma/seedSafe.ts +347 -0
  39. package/resources/prisma.config.ts +14 -0
  40. package/resources/scripts/check-config.mjs +773 -0
  41. package/resources/scripts/lib/schema-graph.mjs +410 -0
  42. package/resources/scripts/patch-next-trace.mjs +86 -0
  43. package/resources/scripts/suggest-relations.mjs +105 -0
  44. package/resources/src/app/(admin)/ai/page.tsx +469 -0
  45. package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
  46. package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
  47. package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
  48. package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
  49. package/resources/src/app/(admin)/error.tsx +96 -0
  50. package/resources/src/app/(admin)/feedback/page.tsx +573 -0
  51. package/resources/src/app/(admin)/graph/page.tsx +1224 -0
  52. package/resources/src/app/(admin)/inbox/page.tsx +31 -0
  53. package/resources/src/app/(admin)/layout.tsx +58 -0
  54. package/resources/src/app/(admin)/loading.tsx +31 -0
  55. package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
  56. package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
  57. package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
  58. package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
  59. package/resources/src/app/(admin)/system/users/page.tsx +620 -0
  60. package/resources/src/app/api/account-recovery/route.ts +132 -0
  61. package/resources/src/app/api/ai/act/route.ts +70 -0
  62. package/resources/src/app/api/ai/ask/route.ts +82 -0
  63. package/resources/src/app/api/ai/chat/route.ts +404 -0
  64. package/resources/src/app/api/ai/report/route.ts +164 -0
  65. package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
  66. package/resources/src/app/api/auth/change-password/route.ts +57 -0
  67. package/resources/src/app/api/auth/forgot/route.ts +98 -0
  68. package/resources/src/app/api/auth/register/route.ts +95 -0
  69. package/resources/src/app/api/auth/register-config/route.ts +51 -0
  70. package/resources/src/app/api/badges/route.ts +39 -0
  71. package/resources/src/app/api/depts/[id]/route.ts +145 -0
  72. package/resources/src/app/api/depts/route.ts +126 -0
  73. package/resources/src/app/api/docs/export/route.ts +87 -0
  74. package/resources/src/app/api/docs/parse/route.ts +57 -0
  75. package/resources/src/app/api/docs/search/route.ts +22 -0
  76. package/resources/src/app/api/employees/[id]/route.ts +147 -0
  77. package/resources/src/app/api/employees/export/route.ts +115 -0
  78. package/resources/src/app/api/employees/import/route.ts +205 -0
  79. package/resources/src/app/api/employees/route.ts +149 -0
  80. package/resources/src/app/api/export/route.ts +254 -0
  81. package/resources/src/app/api/favorites/route.ts +56 -0
  82. package/resources/src/app/api/feedback/ai/route.ts +31 -0
  83. package/resources/src/app/api/feedback/approve/route.ts +67 -0
  84. package/resources/src/app/api/feedback/report/route.ts +213 -0
  85. package/resources/src/app/api/feedback/route.ts +246 -0
  86. package/resources/src/app/api/files/[name]/route.ts +82 -0
  87. package/resources/src/app/api/graph/focus/route.ts +75 -0
  88. package/resources/src/app/api/graph/node/move/route.ts +48 -0
  89. package/resources/src/app/api/graph/node/route.ts +274 -0
  90. package/resources/src/app/api/graph/route.ts +272 -0
  91. package/resources/src/app/api/health/route.ts +11 -0
  92. package/resources/src/app/api/inbox/read/route.ts +36 -0
  93. package/resources/src/app/api/inbox/summary/route.ts +15 -0
  94. package/resources/src/app/api/logs/route.ts +82 -0
  95. package/resources/src/app/api/menus/[id]/route.ts +132 -0
  96. package/resources/src/app/api/menus/mine/route.ts +23 -0
  97. package/resources/src/app/api/menus/route.ts +89 -0
  98. package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
  99. package/resources/src/app/api/roles/[id]/route.ts +96 -0
  100. package/resources/src/app/api/roles/route.ts +114 -0
  101. package/resources/src/app/api/search/route.ts +22 -0
  102. package/resources/src/app/api/stats/route.ts +92 -0
  103. package/resources/src/app/api/upload/route.ts +38 -0
  104. package/resources/src/app/api/users/[id]/password/route.ts +55 -0
  105. package/resources/src/app/api/users/[id]/route.ts +145 -0
  106. package/resources/src/app/api/users/route.ts +153 -0
  107. package/resources/src/app/api/visits/route.ts +48 -0
  108. package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
  109. package/resources/src/app/change-password/page.tsx +19 -0
  110. package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
  111. package/resources/src/app/docs/layout.tsx +34 -0
  112. package/resources/src/app/docs/loading.tsx +19 -0
  113. package/resources/src/app/docs/not-found.tsx +25 -0
  114. package/resources/src/app/forgot/page.tsx +392 -0
  115. package/resources/src/app/global-error.tsx +75 -0
  116. package/resources/src/app/globals.css +235 -0
  117. package/resources/src/app/layout.tsx +44 -0
  118. package/resources/src/app/login/page.tsx +267 -0
  119. package/resources/src/app/page.tsx +14 -0
  120. package/resources/src/app/register/page.tsx +237 -0
  121. package/resources/src/auth.ts +215 -0
  122. package/resources/src/components/AiPanel.tsx +970 -0
  123. package/resources/src/components/AuthContext.tsx +82 -0
  124. package/resources/src/components/DataTable.tsx +459 -0
  125. package/resources/src/components/EChart.tsx +140 -0
  126. package/resources/src/components/FeedbackAiModal.tsx +476 -0
  127. package/resources/src/components/FeedbackReportModal.tsx +479 -0
  128. package/resources/src/components/Markdown.tsx +532 -0
  129. package/resources/src/components/OntologyGraph.tsx +1196 -0
  130. package/resources/src/components/Providers.tsx +99 -0
  131. package/resources/src/components/VisitTracker.tsx +89 -0
  132. package/resources/src/components/admin/AdminShell.tsx +749 -0
  133. package/resources/src/components/admin/CountBadge.tsx +67 -0
  134. package/resources/src/components/admin/GlobalSearch.tsx +509 -0
  135. package/resources/src/components/admin/InboxBell.tsx +78 -0
  136. package/resources/src/components/admin/VisitStats.tsx +145 -0
  137. package/resources/src/components/admin/menu-icons.tsx +56 -0
  138. package/resources/src/components/ai/AiInputForm.tsx +351 -0
  139. package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
  140. package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
  141. package/resources/src/components/docs/DocArticle.tsx +106 -0
  142. package/resources/src/components/docs/DocToc.tsx +100 -0
  143. package/resources/src/components/docs/DocsChrome.tsx +259 -0
  144. package/resources/src/components/docs/DocsHome.tsx +209 -0
  145. package/resources/src/components/docs/DocsSearch.tsx +365 -0
  146. package/resources/src/components/docs/docs.css +1458 -0
  147. package/resources/src/components/feedback/AsyncButton.tsx +108 -0
  148. package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
  149. package/resources/src/components/feedback/PressableCard.tsx +92 -0
  150. package/resources/src/components/feedback/README.md +97 -0
  151. package/resources/src/components/feedback/RouteProgress.tsx +108 -0
  152. package/resources/src/components/feedback/Skeleton.tsx +249 -0
  153. package/resources/src/components/feedback/StatCard.tsx +70 -0
  154. package/resources/src/components/feedback/TaskHint.tsx +153 -0
  155. package/resources/src/components/feedback/index.ts +93 -0
  156. package/resources/src/components/feedback/motion.css +204 -0
  157. package/resources/src/components/feedback/motion.ts +63 -0
  158. package/resources/src/components/feedback/useCountUp.ts +110 -0
  159. package/resources/src/components/feedback/usePending.ts +209 -0
  160. package/resources/src/components/feedback/useRowFlash.ts +40 -0
  161. package/resources/src/components/inbox/InboxView.tsx +409 -0
  162. package/resources/src/components/ui/button.tsx +35 -0
  163. package/resources/src/components/ui/card.tsx +37 -0
  164. package/resources/src/components/ui/input.tsx +19 -0
  165. package/resources/src/lib/ai.ts +310 -0
  166. package/resources/src/lib/aiContext.ts +357 -0
  167. package/resources/src/lib/aiForm.test.ts +191 -0
  168. package/resources/src/lib/aiForm.ts +532 -0
  169. package/resources/src/lib/aiFormTypes.ts +83 -0
  170. package/resources/src/lib/aiToken.ts +72 -0
  171. package/resources/src/lib/aiTools.ts +823 -0
  172. package/resources/src/lib/api.ts +160 -0
  173. package/resources/src/lib/appInfo.ts +128 -0
  174. package/resources/src/lib/appearance.ts +643 -0
  175. package/resources/src/lib/badge-count.ts +146 -0
  176. package/resources/src/lib/badges.test.ts +58 -0
  177. package/resources/src/lib/badges.ts +79 -0
  178. package/resources/src/lib/brand-mark.test.ts +87 -0
  179. package/resources/src/lib/brand-mark.ts +45 -0
  180. package/resources/src/lib/docs-shared.test.ts +148 -0
  181. package/resources/src/lib/docs-shared.ts +422 -0
  182. package/resources/src/lib/docs.ts +381 -0
  183. package/resources/src/lib/err-text.ts +21 -0
  184. package/resources/src/lib/excel.ts +152 -0
  185. package/resources/src/lib/favorite-path.ts +60 -0
  186. package/resources/src/lib/favorites.ts +59 -0
  187. package/resources/src/lib/features.ts +69 -0
  188. package/resources/src/lib/feedback-access.ts +17 -0
  189. package/resources/src/lib/feedbackAi.ts +1621 -0
  190. package/resources/src/lib/formRules.ts +76 -0
  191. package/resources/src/lib/format.ts +52 -0
  192. package/resources/src/lib/graph-cascade.ts +304 -0
  193. package/resources/src/lib/graph-focus.ts +301 -0
  194. package/resources/src/lib/graph-link.ts +17 -0
  195. package/resources/src/lib/graph-node-write.ts +150 -0
  196. package/resources/src/lib/graph-schema.ts +475 -0
  197. package/resources/src/lib/graph-scope.test.ts +46 -0
  198. package/resources/src/lib/graph-scope.ts +28 -0
  199. package/resources/src/lib/http.ts +157 -0
  200. package/resources/src/lib/inbox-types.ts +57 -0
  201. package/resources/src/lib/inbox.ts +55 -0
  202. package/resources/src/lib/menu-service.ts +204 -0
  203. package/resources/src/lib/notifications.ts +113 -0
  204. package/resources/src/lib/office.ts +531 -0
  205. package/resources/src/lib/ontology.ts +277 -0
  206. package/resources/src/lib/openaiCompat.ts +257 -0
  207. package/resources/src/lib/pending.ts +98 -0
  208. package/resources/src/lib/prisma.ts +30 -0
  209. package/resources/src/lib/rbac.test.ts +106 -0
  210. package/resources/src/lib/rbac.ts +185 -0
  211. package/resources/src/lib/search-data.ts +158 -0
  212. package/resources/src/lib/security-questions.ts +20 -0
  213. package/resources/src/lib/skin-theme.ts +155 -0
  214. package/resources/src/lib/system-config.ts +55 -0
  215. package/resources/src/lib/theme.ts +96 -0
  216. package/resources/src/lib/todos/feedback.ts +86 -0
  217. package/resources/src/lib/todos/index.ts +73 -0
  218. package/resources/src/lib/todos/types.ts +33 -0
  219. package/resources/src/lib/upload.ts +106 -0
  220. package/resources/src/lib/utils.test.ts +9 -0
  221. package/resources/src/lib/utils.ts +6 -0
  222. package/resources/src/lib/validation.test.ts +297 -0
  223. package/resources/src/lib/validation.ts +658 -0
  224. package/resources/src/lib/visit-stats.ts +109 -0
  225. package/resources/src/lib/visits.ts +111 -0
  226. package/resources/src/lib/zodField.ts +44 -0
  227. package/resources/tsconfig.json +41 -0
  228. package/resources/vitest.config.ts +16 -0
  229. package/resources/vitest.setup.ts +1 -0
@@ -0,0 +1,422 @@
1
+ /**
2
+ * 帮助文档的纯函数层。
3
+ *
4
+ * 这里只放**不碰文件系统**的逻辑:front matter 解析、标题锚点、目录提取、
5
+ * 搜索引擎与前后篇排序。服务端读取内容在 `docs.ts`,客户端渲染直接复用本文件,
6
+ * 于是标题 id、目录、搜索结果三处的规则永远一致。
7
+ */
8
+
9
+ /* ============================ 类型 ============================ */
10
+
11
+ export type DocTocItem = {
12
+ /** 标题锚点 id(与渲染出的 heading id 完全一致) */
13
+ id: string;
14
+ text: string;
15
+ level: number;
16
+ };
17
+
18
+ export type DocNavPage = {
19
+ slug: string;
20
+ title: string;
21
+ description: string;
22
+ order: number;
23
+ updated: string;
24
+ readingMinutes: number;
25
+ };
26
+
27
+ export type DocNavGroup = {
28
+ key: string;
29
+ title: string;
30
+ description: string;
31
+ order: number;
32
+ /** 分组直属页面 */
33
+ pages: DocNavPage[];
34
+ /** 嵌套子分组 */
35
+ groups: DocNavGroup[];
36
+ /** 递归页面总数 */
37
+ count: number;
38
+ };
39
+
40
+ export type DocNavSection =
41
+ | ({ type: 'page' } & DocNavPage)
42
+ | ({ type: 'group' } & DocNavGroup);
43
+
44
+ export type DocNav = {
45
+ /** 顶层页面与分组按 order 混排,侧边栏照这个顺序渲染 */
46
+ sections: DocNavSection[];
47
+ };
48
+
49
+ export type DocsStats = {
50
+ pageCount: number;
51
+ groupCount: number;
52
+ updatedAt: string;
53
+ };
54
+
55
+ export type DocRecord = {
56
+ slug: string;
57
+ title: string;
58
+ description: string;
59
+ tags: string[];
60
+ order: number;
61
+ hidden: boolean;
62
+ updated: string;
63
+ /** 已去掉 front matter 与首行 H1 的正文 */
64
+ body: string;
65
+ toc: DocTocItem[];
66
+ readingMinutes: number;
67
+ /** 所在分组链路,用于面包屑,如 ['使用指南'] */
68
+ groupTrail: string[];
69
+ };
70
+
71
+ export type DocSearchHit = {
72
+ slug: string;
73
+ title: string;
74
+ groupTitle: string;
75
+ description: string;
76
+ excerpt: string;
77
+ score: number;
78
+ };
79
+
80
+ /** 搜索函数需要的精简字段(避免把整篇正文传进打分函数) */
81
+ export type SearchableDoc = {
82
+ slug: string;
83
+ title: string;
84
+ description: string;
85
+ tags: string[];
86
+ groupTitle: string;
87
+ headings: string[];
88
+ plainText: string;
89
+ };
90
+
91
+ /* ============================ front matter ============================ */
92
+
93
+ export type FrontMatterValue = string | number | boolean | string[];
94
+
95
+ export type FrontMatter = {
96
+ data: Record<string, FrontMatterValue>;
97
+ /** front matter 之后的正文 */
98
+ body: string;
99
+ };
100
+
101
+ function parseScalar(raw: string): FrontMatterValue {
102
+ const v = raw.trim();
103
+ if (!v) return '';
104
+
105
+ if (
106
+ (v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
107
+ (v.startsWith("'") && v.endsWith("'") && v.length >= 2)
108
+ ) {
109
+ return v.slice(1, -1).replace(/\\"/g, '"').replace(/\\'/g, "'");
110
+ }
111
+
112
+ if (v.startsWith('[') && v.endsWith(']')) {
113
+ return v
114
+ .slice(1, -1)
115
+ .split(',')
116
+ .map((s) => parseScalar(s))
117
+ .filter((s): s is string => typeof s === 'string' && s.trim().length > 0)
118
+ .map((s) => s.trim());
119
+ }
120
+
121
+ if (v === 'true') return true;
122
+ if (v === 'false') return false;
123
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
124
+
125
+ return v;
126
+ }
127
+
128
+ /**
129
+ * 解析 `---` 包围的简易 front matter。
130
+ *
131
+ * 刻意不引入 yaml 依赖:比赛环境离线,而文档只需要 title / description /
132
+ * order / tags / updated / hidden 这几个标量。解析失败一律降级为空元数据,
133
+ * 正文照常渲染 —— 写错一个冒号不该让整篇文档打不开。
134
+ */
135
+ export function parseFrontMatter(raw: string): FrontMatter {
136
+ const text = String(raw ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
137
+ const lines = text.split('\n');
138
+ if (lines[0]?.trim() !== '---') return { data: {}, body: text };
139
+
140
+ let end = -1;
141
+ for (let i = 1; i < lines.length; i += 1) {
142
+ if (lines[i].trim() === '---') {
143
+ end = i;
144
+ break;
145
+ }
146
+ }
147
+ if (end < 0) return { data: {}, body: text };
148
+
149
+ const data: Record<string, FrontMatterValue> = {};
150
+ for (const line of lines.slice(1, end)) {
151
+ const trimmed = line.trim();
152
+ if (!trimmed || trimmed.startsWith('#')) continue;
153
+ const colon = trimmed.indexOf(':');
154
+ if (colon <= 0) continue;
155
+ const key = trimmed.slice(0, colon).trim().toLowerCase();
156
+ if (!key) continue;
157
+ data[key] = parseScalar(trimmed.slice(colon + 1));
158
+ }
159
+
160
+ return { data, body: lines.slice(end + 1).join('\n').replace(/^\n+/, '') };
161
+ }
162
+
163
+ /** front matter 的数组字段 → 字符串数组(也接受逗号分隔的单个字符串) */
164
+ export function toTags(value: FrontMatterValue | undefined): string[] {
165
+ if (Array.isArray(value)) return value.map((s) => s.trim()).filter(Boolean);
166
+ if (typeof value === 'string') {
167
+ return value
168
+ .split(/[,,、]/)
169
+ .map((s) => s.trim())
170
+ .filter(Boolean);
171
+ }
172
+ return [];
173
+ }
174
+
175
+ /* ============================ Markdown 文本工具 ============================ */
176
+
177
+ /** 去掉行内 Markdown 标记,保留可读文字(标题锚点、目录、搜索摘要共用) */
178
+ export function stripInlineMarkdown(text: string): string {
179
+ return String(text ?? '')
180
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
181
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
182
+ .replace(/`([^`]+)`/g, '$1')
183
+ .replace(/(\*\*|__)(.*?)\1/g, '$2')
184
+ .replace(/(\*|_)(.*?)\1/g, '$2')
185
+ .replace(/~~(.*?)~~/g, '$1')
186
+ .replace(/<[^>]+>/g, '')
187
+ .trim();
188
+ }
189
+
190
+ /**
191
+ * 标题 → 锚点 id。
192
+ *
193
+ * 同一篇文档里重名标题依次加 `-2`、`-3`。传入同一个 used 集合时,
194
+ * 服务端目录与客户端渲染得到的 id 必然一致,不会出现「点了目录跳不动」。
195
+ */
196
+ export function headingId(text: string, used?: Set<string>): string {
197
+ let base = stripInlineMarkdown(text)
198
+ .toLowerCase()
199
+ .replace(/[^\p{L}\p{N}\s-]/gu, ' ')
200
+ .replace(/\s+/g, '-')
201
+ .replace(/-+/g, '-')
202
+ .replace(/^-+|-+$/g, '');
203
+
204
+ if (!base) base = 'section';
205
+ if (!used) return base;
206
+
207
+ let id = base;
208
+ let n = 2;
209
+ while (used.has(id)) {
210
+ id = `${base}-${n}`;
211
+ n += 1;
212
+ }
213
+ used.add(id);
214
+ return id;
215
+ }
216
+
217
+ /**
218
+ * 提取正文目录(默认 h2 / h3)。
219
+ *
220
+ * 所有级别的标题都参与编号(与渲染器一致),只是 h1 / h4+ 不进目录。
221
+ */
222
+ export function extractToc(body: string, maxLevel = 3): DocTocItem[] {
223
+ const used = new Set<string>();
224
+ const out: DocTocItem[] = [];
225
+ let inFence = false;
226
+
227
+ for (const line of String(body ?? '').replace(/\r\n?/g, '\n').split('\n')) {
228
+ if (/^\s*```/.test(line)) {
229
+ inFence = !inFence;
230
+ continue;
231
+ }
232
+ if (inFence) continue;
233
+
234
+ const m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
235
+ if (!m) continue;
236
+
237
+ const level = m[1].length;
238
+ const text = stripInlineMarkdown(m[2]).trim();
239
+ if (!text) continue;
240
+
241
+ const id = headingId(text, used);
242
+ if (level >= 2 && level <= maxLevel) out.push({ id, text, level });
243
+ }
244
+
245
+ return out;
246
+ }
247
+
248
+ /** 去掉正文开头的第一个 H1(页面标题已由文档站渲染,正文再出现一次会重复) */
249
+ export function stripLeadingH1(body: string): string {
250
+ return String(body ?? '').replace(/^\s*#\s+.*(?:\n+|$)/, '');
251
+ }
252
+
253
+ /** 取正文第一个标题(通常是被 strip 掉的 H1) */
254
+ export function firstHeading(body: string): string {
255
+ const m = String(body ?? '')
256
+ .replace(/\r\n?/g, '\n')
257
+ .match(/^\s*#\s+(.+?)\s*$/m);
258
+ return m ? stripInlineMarkdown(m[1]).trim() : '';
259
+ }
260
+
261
+ /** 取正文第一段普通文字,作为缺省摘要 */
262
+ export function firstParagraph(body: string): string {
263
+ const lines = String(body ?? '').replace(/\r\n?/g, '\n').split('\n');
264
+ for (const line of lines) {
265
+ const t = line.trim();
266
+ if (!t) continue;
267
+ if (/^(#{1,6})\s+/.test(t)) continue;
268
+ if (/^```/.test(t)) continue;
269
+ if (/^\|?\s*:?-{2,}/.test(t)) continue;
270
+ if (/^>\s?/.test(t)) {
271
+ const quote = stripInlineMarkdown(t.replace(/^>\s?/, ''));
272
+ if (quote) return quote.slice(0, 160);
273
+ continue;
274
+ }
275
+ if (/^([-*+]|\d+[.)])\s+/.test(t)) {
276
+ const item = stripInlineMarkdown(t.replace(/^([-*+]|\d+[.)])\s+/, ''));
277
+ if (item) return item.slice(0, 160);
278
+ continue;
279
+ }
280
+ const text = stripInlineMarkdown(t);
281
+ if (text) return text.slice(0, 160);
282
+ }
283
+ return '';
284
+ }
285
+
286
+ /** Markdown → 纯文本,用于搜索与摘要(保留代码块内容,去掉标记符号) */
287
+ export function plainTextOf(body: string): string {
288
+ return String(body ?? '')
289
+ .replace(/\r\n?/g, '\n')
290
+ .replace(/^\s*```.*$/gm, ' ')
291
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
292
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
293
+ .replace(/`([^`]+)`/g, '$1')
294
+ .replace(/^\s{0,3}>\s?/gm, '')
295
+ .replace(/^\s{0,3}([-*+]|\d+[.)])\s+/gm, '')
296
+ .replace(/[*_~#|]/g, ' ')
297
+ .replace(/\s+/g, ' ')
298
+ .trim();
299
+ }
300
+
301
+ /** 中文按 350 字/分钟、英文按 200 词/分钟估算,最少 1 分钟 */
302
+ export function readingMinutesOf(text: string): number {
303
+ const cjk = (String(text ?? '').match(/[\u3400-\u9fff]/g) ?? []).length;
304
+ const latin = (String(text ?? '').match(/[A-Za-z0-9]+/g) ?? []).length;
305
+ return Math.max(1, Math.ceil(cjk / 350 + latin / 200));
306
+ }
307
+
308
+ /** 把命中词附近的一小段文字截出来,避免搜索结果只给一句摘要 */
309
+ export function excerptAround(text: string, tokens: string[], maxLength = 160): string {
310
+ const plain = String(text ?? '').replace(/\s+/g, ' ').trim();
311
+ if (!plain) return '';
312
+
313
+ const lower = plain.toLowerCase();
314
+ let hit = -1;
315
+ for (const t of tokens) {
316
+ const i = lower.indexOf(t.toLowerCase());
317
+ if (i >= 0 && (hit < 0 || i < hit)) hit = i;
318
+ }
319
+
320
+ if (hit < 0) return plain.slice(0, maxLength) + (plain.length > maxLength ? '…' : '');
321
+
322
+ // 窗口必须先保证包含命中词,再向两侧扩展;
323
+ // 只按 hit - 偏移 取起点时,短窗口可能整个落在命中词之前。
324
+ const start = Math.max(0, Math.min(hit - Math.floor(maxLength / 2), plain.length - maxLength));
325
+ const end = Math.min(
326
+ plain.length,
327
+ Math.max(start + maxLength, hit + Math.max(12, Math.ceil(maxLength * 0.25)))
328
+ );
329
+ return `${start > 0 ? '…' : ''}${plain.slice(start, end)}${end < plain.length ? '…' : ''}`;
330
+ }
331
+
332
+ /* ============================ 搜索 ============================ */
333
+
334
+ function tokenize(q: string): string[] {
335
+ return q
336
+ .toLowerCase()
337
+ .split(/[\s,,、/]+/)
338
+ .map((t) => t.trim())
339
+ .filter(Boolean);
340
+ }
341
+
342
+ /** 无序子序列兜底:用户只记得标题里的几个字,顺序对不上也能命中 */
343
+ function isSubsequence(text: string, token: string): boolean {
344
+ if (!token) return true;
345
+ let i = 0;
346
+ for (const ch of text) {
347
+ if (ch === token[i]) i += 1;
348
+ if (i >= token.length) return true;
349
+ }
350
+ return i >= token.length;
351
+ }
352
+
353
+ function scoreOf(doc: SearchableDoc, tokens: string[]): number | null {
354
+ const title = doc.title.toLowerCase();
355
+ const desc = doc.description.toLowerCase();
356
+ const group = doc.groupTitle.toLowerCase();
357
+ const tags = doc.tags.map((t) => t.toLowerCase());
358
+ const headings = doc.headings.map((h) => h.toLowerCase());
359
+ const body = doc.plainText.toLowerCase();
360
+
361
+ let total = 0;
362
+ for (const t of tokens) {
363
+ let s = 0;
364
+ if (title === t) s = 240;
365
+ else if (title.startsWith(t)) s = 140;
366
+ else if (title.includes(t)) s = 100;
367
+ else if (tags.some((k) => k === t || k.includes(t))) s = 65;
368
+ else if (group.includes(t)) s = 55;
369
+ else if (headings.some((h) => h.includes(t))) s = 45;
370
+ else if (desc.includes(t)) s = 30;
371
+ else {
372
+ const count = body.split(t).length - 1;
373
+ if (count > 0) s = 15 + Math.min(15, count * 2);
374
+ else if (isSubsequence(title, t)) s = 8;
375
+ }
376
+ if (!s) return null;
377
+ total += s;
378
+ }
379
+
380
+ if (tokens.length === 1 && title === tokens[0]) total += 120;
381
+ return total;
382
+ }
383
+
384
+ /**
385
+ * 文档搜索。
386
+ *
387
+ * 与全局搜索的约定一致:所有分词都命中才算命中(AND),
388
+ * 标题 > 标签 > 分组 > 小标题 > 摘要 > 正文,正文按出现次数小幅加分。
389
+ */
390
+ export function searchDocs(docs: SearchableDoc[], query: string, limit = 12): DocSearchHit[] {
391
+ const tokens = tokenize(query);
392
+ if (!tokens.length) return [];
393
+
394
+ const scored: { doc: SearchableDoc; score: number }[] = [];
395
+ for (const doc of docs) {
396
+ const score = scoreOf(doc, tokens);
397
+ if (score !== null) scored.push({ doc, score });
398
+ }
399
+ scored.sort((a, b) => b.score - a.score || a.doc.title.localeCompare(b.doc.title, 'zh-CN'));
400
+
401
+ return scored.slice(0, limit).map(({ doc, score }) => ({
402
+ slug: doc.slug,
403
+ title: doc.title,
404
+ groupTitle: doc.groupTitle,
405
+ description: doc.description,
406
+ excerpt: excerptAround(doc.plainText, tokens),
407
+ score,
408
+ }));
409
+ }
410
+
411
+ /* ============================ 展示工具 ============================ */
412
+
413
+ export function docHref(slug: string): string {
414
+ const clean = String(slug ?? '').replace(/^\/+|\/+$/g, '');
415
+ return clean ? `/docs/${clean}` : '/docs';
416
+ }
417
+
418
+ export function formatDocDate(value: string): string {
419
+ const v = String(value ?? '').trim();
420
+ if (!v) return '';
421
+ return v.slice(0, 10);
422
+ }