@mandujs/core 0.54.32 → 0.55.0-beta.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.
Files changed (414) hide show
  1. package/README.md +654 -654
  2. package/package.json +106 -210
  3. package/scripts/postinstall-lock.ts +153 -153
  4. package/src/a11y/__tests__/run-audit.test.ts +333 -333
  5. package/src/a11y/fix-hints.ts +76 -76
  6. package/src/a11y/index.ts +18 -18
  7. package/src/a11y/run-audit.ts +15 -15
  8. package/src/a11y/types.ts +125 -125
  9. package/src/agent/__tests__/apply.test.ts +224 -0
  10. package/src/agent/__tests__/context.test.ts +97 -98
  11. package/src/agent/apply.ts +1031 -0
  12. package/src/agent/context.ts +552 -552
  13. package/src/agent/index.ts +4 -3
  14. package/src/agent/plan.ts +161 -243
  15. package/src/agent/repair.ts +239 -162
  16. package/src/agent/sync.ts +197 -198
  17. package/src/agent/types.ts +242 -94
  18. package/src/agent/verify.ts +55 -55
  19. package/src/auth/__tests__/login.test.ts +1 -1
  20. package/src/auth/__tests__/password.test.ts +15 -5
  21. package/src/auth/__tests__/reset.test.ts +2 -2
  22. package/src/auth/__tests__/tokens.test.ts +274 -274
  23. package/src/auth/__tests__/verification.test.ts +274 -274
  24. package/src/auth/index.ts +76 -76
  25. package/src/auth/login.ts +225 -225
  26. package/src/auth/password.ts +14 -2
  27. package/src/auth/reset.ts +243 -243
  28. package/src/auth/tokens.ts +612 -612
  29. package/src/auth/verification.ts +253 -253
  30. package/src/brain/__tests__/redactor.test.ts +94 -94
  31. package/src/brain/adapters/__tests__/_helpers.ts +83 -83
  32. package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
  33. package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
  34. package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
  35. package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
  36. package/src/brain/adapters/anthropic-oauth.ts +1 -1
  37. package/src/brain/adapters/chatgpt-auth.ts +300 -300
  38. package/src/brain/adapters/index.ts +319 -319
  39. package/src/brain/adapters/oauth-flow.ts +439 -439
  40. package/src/brain/consent.ts +240 -240
  41. package/src/brain/credentials.ts +396 -396
  42. package/src/brain/doctor/analyzer.ts +7 -7
  43. package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
  44. package/src/bundler/__tests__/build-runner.ts +166 -166
  45. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
  46. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  47. package/src/bundler/__tests__/css.test.ts +49 -20
  48. package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
  49. package/src/bundler/__tests__/extended-watch.test.ts +711 -711
  50. package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
  51. package/src/bundler/__tests__/generation.test.ts +447 -0
  52. package/src/bundler/__tests__/hdr.test.ts +24 -18
  53. package/src/bundler/__tests__/hmr-client.test.ts +62 -24
  54. package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
  55. package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
  56. package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
  57. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
  58. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
  59. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
  60. package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
  61. package/src/bundler/analyzer.ts +15 -15
  62. package/src/bundler/budget.ts +404 -404
  63. package/src/bundler/build.test.ts +898 -915
  64. package/src/bundler/build.ts +113 -24
  65. package/src/bundler/client-boundary-transform.ts +977 -977
  66. package/src/bundler/css.ts +65 -44
  67. package/src/bundler/dev.ts +105 -73
  68. package/src/bundler/fast-refresh-preamble.ts +47 -47
  69. package/src/bundler/generation.ts +602 -0
  70. package/src/bundler/index.ts +3 -3
  71. package/src/bundler/manifest-schema.ts +55 -40
  72. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  73. package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
  74. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
  75. package/src/bundler/plugins/block-generated-imports.ts +16 -15
  76. package/src/bundler/plugins/index.ts +83 -83
  77. package/src/bundler/plugins/react-compiler-config.ts +108 -108
  78. package/src/bundler/plugins/react-compiler-lint.ts +253 -253
  79. package/src/bundler/plugins/react-compiler.ts +162 -162
  80. package/src/bundler/prerender.ts +29 -9
  81. package/src/bundler/reverse-import-graph.ts +339 -339
  82. package/src/bundler/safe-build.test.ts +1 -1
  83. package/src/bundler/safe-build.ts +103 -103
  84. package/src/bundler/scenario-matrix.ts +229 -229
  85. package/src/bundler/types.ts +58 -56
  86. package/src/bundler/vendor-cache-types.ts +130 -130
  87. package/src/bundler/vendor-cache.ts +526 -526
  88. package/src/change/snapshot.ts +18 -18
  89. package/src/client/Form.tsx +105 -105
  90. package/src/client/Link.tsx +9 -9
  91. package/src/client/__tests__/props-serialization.test.ts +37 -37
  92. package/src/client/__tests__/use-sse.test.ts +153 -153
  93. package/src/client/globals.ts +1 -1
  94. package/src/client/hooks.ts +362 -362
  95. package/src/client/hydrate.ts +2 -2
  96. package/src/client/index.ts +1 -1
  97. package/src/client/island.ts +79 -79
  98. package/src/client/prefetch-helper.ts +55 -55
  99. package/src/client/props-serialization.ts +233 -233
  100. package/src/client/runtime-entry.ts +598 -598
  101. package/src/client/runtime.ts +1 -1
  102. package/src/client/serialize.ts +50 -50
  103. package/src/client/use-fetch.ts +6 -6
  104. package/src/client/use-head.ts +197 -197
  105. package/src/client/use-sse.ts +378 -378
  106. package/src/client/window-state.ts +101 -101
  107. package/src/components/Image-compat.ts +3 -0
  108. package/src/components/Image.tsx +162 -162
  109. package/src/config/validate.ts +1 -1
  110. package/src/config/watcher.ts +311 -311
  111. package/src/constants.ts +40 -40
  112. package/src/content/collection.ts +9 -9
  113. package/src/content/content-layer.ts +7 -7
  114. package/src/content/data-store.ts +245 -245
  115. package/src/content/frontmatter.ts +189 -189
  116. package/src/content/generate-types.ts +1 -1
  117. package/src/content/index.ts +2 -2
  118. package/src/content/loader-context.ts +171 -171
  119. package/src/content/loaders/api.ts +216 -216
  120. package/src/content/loaders/file.ts +172 -172
  121. package/src/content/loaders/glob.ts +253 -253
  122. package/src/content/loaders/index.ts +34 -34
  123. package/src/content/loaders/types.ts +137 -137
  124. package/src/content/meta-store.ts +209 -209
  125. package/src/content/prebuild.test.ts +571 -571
  126. package/src/content/prebuild.ts +636 -636
  127. package/src/content/schema.ts +20 -20
  128. package/src/content/sidebar.ts +630 -630
  129. package/src/content/slug.ts +110 -110
  130. package/src/content/types.ts +282 -282
  131. package/src/content/watcher.ts +135 -135
  132. package/src/contract/client-safe.test.ts +42 -42
  133. package/src/contract/client-safe.ts +114 -114
  134. package/src/contract/define.ts +11 -11
  135. package/src/contract/index.ts +1 -1
  136. package/src/contract/normalize.test.ts +276 -276
  137. package/src/contract/normalize.ts +410 -410
  138. package/src/contract/registry.test.ts +206 -206
  139. package/src/contract/route-helpers.ts +1 -1
  140. package/src/contract/rpc.ts +443 -443
  141. package/src/contract/types.ts +58 -58
  142. package/src/db/__tests__/db.test.ts +9 -9
  143. package/src/db/index.ts +2 -2
  144. package/src/db/migrations/history-table.ts +345 -345
  145. package/src/db/migrations/index.ts +3 -3
  146. package/src/db/migrations/lock.ts +324 -324
  147. package/src/db/migrations/runner.ts +650 -650
  148. package/src/deploy/cache.ts +140 -140
  149. package/src/deploy/compile/vercel.ts +344 -344
  150. package/src/deploy/index.ts +87 -87
  151. package/src/deploy/inference/brain.ts +268 -268
  152. package/src/deploy/inference/context.ts +82 -82
  153. package/src/deploy/inference/filling-extract.ts +245 -245
  154. package/src/deploy/inference/heuristic.ts +182 -182
  155. package/src/deploy/intent.ts +173 -173
  156. package/src/deploy/plan.ts +178 -178
  157. package/src/design/__tests__/agents-link.test.ts +109 -109
  158. package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
  159. package/src/design/__tests__/lint.test.ts +110 -110
  160. package/src/design/__tests__/parser.test.ts +195 -195
  161. package/src/design/__tests__/tailwind-theme.test.ts +229 -229
  162. package/src/design/agents-link.ts +165 -165
  163. package/src/design/diff.ts +138 -138
  164. package/src/design/extract.ts +285 -285
  165. package/src/design/index.ts +102 -102
  166. package/src/design/lint.ts +209 -209
  167. package/src/design/parser.ts +555 -555
  168. package/src/design/patch.ts +241 -241
  169. package/src/design/scaffold.ts +147 -147
  170. package/src/design/tailwind-theme.ts +441 -441
  171. package/src/design/types.ts +210 -210
  172. package/src/desktop/__tests__/smoke.test.ts +2 -2
  173. package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
  174. package/src/desktop/__tests__/window.test.ts +20 -99
  175. package/src/desktop/__tests__/worker.test.ts +266 -266
  176. package/src/desktop/index.ts +43 -43
  177. package/src/desktop/types.ts +158 -158
  178. package/src/desktop/webview-fallback.ts +17 -4
  179. package/src/desktop/window.ts +17 -8
  180. package/src/desktop/worker.ts +180 -180
  181. package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
  182. package/src/dev-error-overlay/index.ts +30 -30
  183. package/src/dev-error-overlay/overlay-injector.ts +243 -243
  184. package/src/dev-error-overlay/overlay-styles.ts +52 -52
  185. package/src/dev-error-overlay/types.ts +66 -66
  186. package/src/devtools/ai/context-builder.ts +375 -375
  187. package/src/devtools/ai/index.ts +25 -25
  188. package/src/devtools/ai/mcp-connector.ts +25 -25
  189. package/src/devtools/client/catchers/error-catcher.ts +344 -344
  190. package/src/devtools/client/catchers/index.ts +18 -18
  191. package/src/devtools/client/components/index.ts +39 -39
  192. package/src/devtools/client/components/mandu-character.tsx +331 -331
  193. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  194. package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
  195. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  196. package/src/devtools/client/components/panel/network-panel.tsx +291 -291
  197. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  198. package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
  199. package/src/devtools/client/filters/context-filters.ts +282 -282
  200. package/src/devtools/client/filters/index.ts +16 -16
  201. package/src/devtools/client/index.ts +63 -63
  202. package/src/devtools/hook/create-hook.ts +207 -207
  203. package/src/devtools/hook/index.ts +13 -13
  204. package/src/devtools/index.ts +439 -439
  205. package/src/devtools/init.ts +265 -265
  206. package/src/devtools/protocol.ts +237 -237
  207. package/src/devtools/server/index.ts +17 -17
  208. package/src/devtools/types.ts +35 -35
  209. package/src/devtools/worker/index.ts +25 -25
  210. package/src/devtools/worker/redaction-worker.ts +233 -233
  211. package/src/diagnose/__tests__/checks.test.ts +136 -136
  212. package/src/diagnose/checks.ts +185 -185
  213. package/src/diagnose/index.ts +17 -17
  214. package/src/diagnose/run.ts +10 -10
  215. package/src/diagnose/types.ts +53 -53
  216. package/src/email/__tests__/email.test.ts +355 -355
  217. package/src/email/index.ts +282 -282
  218. package/src/email/smtp.ts +64 -64
  219. package/src/error/domains.ts +265 -265
  220. package/src/error/types.ts +6 -6
  221. package/src/errors/extractor.ts +409 -409
  222. package/src/errors/index.ts +19 -19
  223. package/src/filling/__tests__/session-sqlite.test.ts +5 -1
  224. package/src/filling/auth.ts +308 -308
  225. package/src/filling/body-parse.test.ts +60 -60
  226. package/src/filling/cookie-codec.ts +5 -3
  227. package/src/filling/deps.ts +265 -265
  228. package/src/filling/head-method.test.ts +154 -154
  229. package/src/filling/session-sqlite.ts +617 -617
  230. package/src/filling/sse.ts +5 -5
  231. package/src/filling/ws.ts +78 -78
  232. package/src/generator/generate.ts +30 -30
  233. package/src/generator/index.ts +3 -3
  234. package/src/generator/templates.test.ts +48 -48
  235. package/src/generator/templates.ts +219 -219
  236. package/src/guard/__tests__/design-inline-class.test.ts +219 -219
  237. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
  238. package/src/guard/analyzer.ts +360 -360
  239. package/src/guard/auto-correct.ts +1 -1
  240. package/src/guard/check.ts +39 -24
  241. package/src/guard/config-guard.ts +13 -13
  242. package/src/guard/contract-guard.ts +9 -9
  243. package/src/guard/define-rule.ts +243 -243
  244. package/src/guard/design-inline-class.ts +393 -393
  245. package/src/guard/file-type.test.ts +24 -24
  246. package/src/guard/fs-routes-policy.ts +51 -51
  247. package/src/guard/graph.ts +1 -1
  248. package/src/guard/healing.ts +36 -36
  249. package/src/guard/index.ts +11 -11
  250. package/src/guard/presets/atomic.ts +70 -70
  251. package/src/guard/presets/clean.ts +77 -77
  252. package/src/guard/presets/fsd.ts +79 -79
  253. package/src/guard/presets/hexagonal.ts +68 -68
  254. package/src/guard/reporter.ts +442 -442
  255. package/src/guard/rule-presets.ts +379 -379
  256. package/src/guard/semantic-slots.ts +1 -1
  257. package/src/guard/suggestions.ts +358 -358
  258. package/src/guard/tsgolint-bridge.ts +512 -512
  259. package/src/guard/types.ts +348 -348
  260. package/src/guard/watcher.ts +405 -405
  261. package/src/i18n/define.ts +126 -126
  262. package/src/i18n/index.ts +52 -52
  263. package/src/i18n/message-registry.ts +173 -173
  264. package/src/i18n/types.ts +112 -112
  265. package/src/id/__tests__/id.test.ts +3 -3
  266. package/src/id/index.ts +105 -105
  267. package/src/index.ts +9 -48
  268. package/src/internal/client-boundary.ts +266 -266
  269. package/src/internal/index.ts +2 -2
  270. package/src/kitchen/api/agent-devtools-api.ts +779 -779
  271. package/src/kitchen/api/bundle-inspector.ts +179 -0
  272. package/src/kitchen/api/errors-grouping.ts +126 -126
  273. package/src/kitchen/api/file-api.ts +11 -11
  274. package/src/kitchen/kitchen-handler.ts +36 -0
  275. package/src/kitchen/kitchen-ui.ts +456 -0
  276. package/src/logging/index.ts +22 -22
  277. package/src/logging/transports.ts +365 -365
  278. package/src/middleware/bridge.ts +147 -147
  279. package/src/middleware/compose.ts +134 -134
  280. package/src/middleware/compress.ts +62 -62
  281. package/src/middleware/cors.ts +47 -47
  282. package/src/middleware/csrf.ts +328 -328
  283. package/src/middleware/define.ts +132 -132
  284. package/src/middleware/jwt.ts +134 -134
  285. package/src/middleware/logger.ts +58 -58
  286. package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
  287. package/src/middleware/oauth/index.ts +505 -505
  288. package/src/middleware/oauth/providers.ts +115 -115
  289. package/src/middleware/rate-limit/index.ts +522 -522
  290. package/src/middleware/rate-limit/sqlite-store.ts +382 -382
  291. package/src/middleware/scheduler-cron.ts +96 -96
  292. package/src/middleware/secure/__tests__/secure.test.ts +360 -360
  293. package/src/middleware/secure/csp.ts +193 -193
  294. package/src/middleware/session.ts +174 -174
  295. package/src/middleware/timeout.ts +55 -55
  296. package/src/observability/logger-adapter.ts +36 -36
  297. package/src/observability/sqlite-store.ts +254 -254
  298. package/src/openapi/generator.ts +1 -1
  299. package/src/openapi/openapi.test.ts +43 -43
  300. package/src/perf/__tests__/user-marks.test.ts +354 -354
  301. package/src/perf/index.ts +133 -133
  302. package/src/perf/user-marks.ts +1 -1
  303. package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
  304. package/src/plugins/__tests__/runner.test.ts +409 -409
  305. package/src/plugins/define.ts +124 -124
  306. package/src/plugins/examples/dep-check-plugin.ts +80 -80
  307. package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
  308. package/src/plugins/examples/sitemap-plugin.ts +65 -65
  309. package/src/plugins/runner.ts +361 -361
  310. package/src/plugins/types.ts +368 -368
  311. package/src/report/index.ts +1 -1
  312. package/src/resource/__tests__/generator.test.ts +7 -7
  313. package/src/resource/__tests__/schema.test.ts +14 -14
  314. package/src/resource/ddl/__tests__/diff.test.ts +639 -639
  315. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  316. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
  317. package/src/resource/ddl/emit.ts +146 -146
  318. package/src/resource/ddl/persistence-types.ts +218 -218
  319. package/src/resource/ddl/type-map.ts +223 -223
  320. package/src/resource/ddl/types.ts +232 -232
  321. package/src/resource/generator-repo.ts +630 -630
  322. package/src/resource/generator-schema.ts +11 -11
  323. package/src/resource/generators/slot.ts +72 -72
  324. package/src/resource/schema.ts +21 -21
  325. package/src/router/client-entry.test.ts +227 -227
  326. package/src/router/client-entry.ts +218 -218
  327. package/src/router/fs-patterns.test.ts +96 -96
  328. package/src/router/fs-routes.test.ts +532 -532
  329. package/src/router/fs-routes.ts +53 -53
  330. package/src/router/fs-scanner.ts +216 -216
  331. package/src/router/fs-types.ts +19 -19
  332. package/src/router/route-source-analyzer.ts +521 -521
  333. package/src/routes/index.ts +74 -74
  334. package/src/routes/metadata-routes.ts +427 -427
  335. package/src/routes/types.ts +341 -341
  336. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  337. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
  338. package/src/runtime/__tests__/hdr-client.test.ts +223 -223
  339. package/src/runtime/__tests__/http-errors.test.ts +117 -117
  340. package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
  341. package/src/runtime/__tests__/not-found.test.ts +152 -152
  342. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  343. package/src/runtime/__tests__/page-render-response.test.ts +517 -517
  344. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  345. package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
  346. package/src/runtime/adapter.ts +47 -47
  347. package/src/runtime/boundary.tsx +252 -252
  348. package/src/runtime/cache.ts +494 -494
  349. package/src/runtime/compose.ts +222 -222
  350. package/src/runtime/devtools-adapter.ts +68 -68
  351. package/src/runtime/escape.ts +34 -34
  352. package/src/runtime/fast-refresh-runtime.ts +322 -322
  353. package/src/runtime/fast-refresh-types.ts +1 -1
  354. package/src/runtime/handler.ts +65 -65
  355. package/src/runtime/handlers.ts +30 -11
  356. package/src/runtime/http-errors.ts +113 -113
  357. package/src/runtime/image-handler.ts +1 -1
  358. package/src/runtime/lifecycle.ts +381 -381
  359. package/src/runtime/logger.test.ts +345 -345
  360. package/src/runtime/middleware.ts +264 -264
  361. package/src/runtime/not-found.ts +93 -93
  362. package/src/runtime/observability-lifecycle.ts +290 -290
  363. package/src/runtime/openapi-endpoint.ts +236 -236
  364. package/src/runtime/page-render-response.ts +287 -274
  365. package/src/runtime/ppr.ts +74 -74
  366. package/src/runtime/rate-limit.ts +1 -1
  367. package/src/runtime/redirect.ts +1 -1
  368. package/src/runtime/registry.ts +171 -171
  369. package/src/runtime/request-middleware.ts +31 -31
  370. package/src/runtime/router.test.ts +4 -4
  371. package/src/runtime/router.ts +10 -10
  372. package/src/runtime/server.ts +127 -211
  373. package/src/runtime/shims.ts +48 -48
  374. package/src/runtime/ssr.ts +43 -17
  375. package/src/runtime/static-files.ts +369 -289
  376. package/src/runtime/streaming-ssr.ts +219 -180
  377. package/src/runtime/trace.ts +144 -144
  378. package/src/scheduler/__tests__/scheduler.test.ts +12 -11
  379. package/src/scheduler/index.ts +11 -5
  380. package/src/scheduler/validate.ts +169 -169
  381. package/src/seo/index.ts +219 -219
  382. package/src/seo/integration/ssr.ts +306 -306
  383. package/src/seo/render/basic.ts +435 -435
  384. package/src/seo/render/index.ts +143 -143
  385. package/src/seo/render/jsonld.ts +539 -539
  386. package/src/seo/render/opengraph.ts +197 -197
  387. package/src/seo/render/robots.ts +116 -116
  388. package/src/seo/render/sitemap.ts +137 -137
  389. package/src/seo/render/twitter.ts +127 -127
  390. package/src/seo/resolve/opengraph.ts +143 -143
  391. package/src/seo/resolve/robots.ts +73 -73
  392. package/src/seo/resolve/title.ts +94 -94
  393. package/src/seo/resolve/twitter.ts +73 -73
  394. package/src/seo/resolve/url.ts +104 -104
  395. package/src/seo/routes/index.ts +290 -290
  396. package/src/seo/types.ts +588 -588
  397. package/src/slot/validator.ts +39 -39
  398. package/src/spec/schema.ts +35 -35
  399. package/src/storage/s3/__tests__/s3.test.ts +479 -479
  400. package/src/storage/s3/index.ts +412 -412
  401. package/src/testing/__tests__/assertions.test.ts +632 -632
  402. package/src/testing/__tests__/reporter.test.ts +454 -454
  403. package/src/testing/assertions.ts +986 -986
  404. package/src/testing/db.ts +157 -157
  405. package/src/testing/index.ts +9 -3
  406. package/src/testing/lcov.ts +192 -0
  407. package/src/testing/mocks.ts +203 -203
  408. package/src/testing/session.ts +190 -190
  409. package/src/types/branded.ts +56 -56
  410. package/src/types/index.ts +1 -1
  411. package/src/utils/safe-io.ts +188 -188
  412. package/src/utils/string-safe.ts +298 -298
  413. package/src/watcher/__tests__/watcher.test.ts +59 -59
  414. package/src/watcher/watcher.ts +61 -61
@@ -1,630 +1,630 @@
1
- /**
2
- * Sidebar Generator (Issues #199, #205)
3
- *
4
- * Takes a loaded `Collection` and produces a nested navigation tree
5
- * suitable for rendering a docs sidebar.
6
- *
7
- * Two output shapes are supported:
8
- *
9
- * 1. **`SidebarNode[]`** (default) — a lightweight
10
- * `{ title, href, children }` tree. Backward-compatible with the
11
- * Wave D MVP signature: `await generateSidebar(collection)`.
12
- *
13
- * 2. **`Category[]`** (Issue #205) — a richer tree with `slug`,
14
- * `icon`, `order`, and `items` so docs sites can render
15
- * sectioned navigation with icons and explicit ordering. Enable
16
- * via `generateCategoryTree(collection, options)`.
17
- *
18
- * # `_meta.json` support (Issue #205)
19
- *
20
- * When a directory contains a `_meta.json` file alongside its
21
- * markdown entries, the generator reads it for per-directory metadata
22
- * and ordering hints:
23
- *
24
- * ```json
25
- * {
26
- * "title": "Getting Started",
27
- * "icon": "rocket",
28
- * "order": 1,
29
- * "pages": ["intro", "install", "quickstart"]
30
- * }
31
- * ```
32
- *
33
- * The `pages` field takes precedence over frontmatter `order` and
34
- * filename alphabetical — it is the explicit escape hatch when the
35
- * author wants a specific nav order that does not match any
36
- * mechanical rule. Missing `pages` entries fall back to frontmatter
37
- * `order` ascending, then filename alphabetical (numeric-aware so
38
- * `10-foo` sorts after `2-foo`).
39
- *
40
- * # Ordering precedence (siblings at the same depth)
41
- *
42
- * 1. Explicit index in parent's `_meta.json` `pages: []`.
43
- * 2. `order` field — directory `_meta.json` for categories,
44
- * frontmatter `order` for leaf entries. Lower values first.
45
- * 3. Numeric-aware filename comparison (title or slug).
46
- *
47
- * # Draft entries
48
- *
49
- * Entries with `data.draft === true` are **filtered out by default**.
50
- * Callers can surface them with `includeDrafts: true` (preview builds).
51
- */
52
-
53
- import * as fs from "fs";
54
- import * as path from "path";
55
- import type { Collection, CollectionEntry } from "./collection";
56
- export type { Collection };
57
-
58
- /** A node in the sidebar tree (legacy + MVP shape). */
59
- export interface SidebarNode {
60
- title: string;
61
- href: string;
62
- /** Present only on branch nodes (grouped categories). */
63
- children?: SidebarNode[];
64
- /** Draft/external flags — populated by the generator, caller-facing. */
65
- draft?: boolean;
66
- }
67
-
68
- /**
69
- * Rich category node produced by `generateCategoryTree` (Issue #205).
70
- * `items` mixes child categories and leaf entries — siblings at any
71
- * depth are ordered consistently under the same precedence rules.
72
- */
73
- export interface Category {
74
- /** Directory-relative slug. For nested categories this includes
75
- * parent slugs (`getting-started/install`). For the root-level
76
- * synthetic category, this is `""`. */
77
- slug: string;
78
- /** Resolved title — `_meta.json.title` > directory name > slug. */
79
- title: string;
80
- /** Optional icon identifier from `_meta.json.icon`. */
81
- icon?: string;
82
- /** Ordering key (`_meta.json.order`). Missing = Infinity. */
83
- order?: number;
84
- /** Mixed children: `Category` branches or leaf `CategoryEntry`s. */
85
- items: Array<Category | CategoryEntry>;
86
- /** Absolute href for the category landing, when an `index` entry exists. */
87
- href?: string;
88
- /** Tag so callers can discriminate without `'items' in x`. */
89
- kind: "category";
90
- }
91
-
92
- /** Leaf entry emitted into a `Category.items` array. */
93
- export interface CategoryEntry {
94
- /** Absolute href (prefixed by `basePath`). */
95
- href: string;
96
- /** Resolved title — frontmatter `title` > slug. */
97
- title: string;
98
- /** Optional per-entry icon from frontmatter. */
99
- icon?: string;
100
- /** Frontmatter `order`, when present. */
101
- order?: number;
102
- /** The original slug (directory-relative). */
103
- slug: string;
104
- /** `data.draft === true` — surfaced only when `includeDrafts` is on. */
105
- draft?: boolean;
106
- /** Tag so callers can discriminate without `'items' in x`. */
107
- kind: "entry";
108
- }
109
-
110
- /** Shape of a `_meta.json` file as parsed by the sidebar generator. */
111
- export interface DirMeta {
112
- /** Override for the category title. */
113
- title?: string;
114
- /** Icon identifier (consumer-defined, e.g. a lucide name). */
115
- icon?: string;
116
- /** Ordering key among siblings — ascending. */
117
- order?: number;
118
- /**
119
- * Explicit list of child slugs (directory-relative, extension-free)
120
- * that controls the order and membership of `items`. Entries not
121
- * listed here are appended in the default order after the listed
122
- * entries — authors get a "pin these at top" ergonomic without
123
- * having to list every file.
124
- */
125
- pages?: string[];
126
- }
127
-
128
- /** Options controlling sidebar shape and filtering. */
129
- export interface GenerateSidebarOptions<T> {
130
- /**
131
- * Href prefix prepended to every node. Defaults to `/` so a slug
132
- * of `intro` becomes `/intro`. Projects mounting docs under
133
- * `/docs/` should pass `basePath: '/docs'`.
134
- */
135
- basePath?: string;
136
- /**
137
- * Extract the title shown in the sidebar. Defaults to
138
- * `entry.data.title ?? entry.slug`. Implementations that store
139
- * the sidebar label in a different field (e.g. `nav_title`) can
140
- * override here.
141
- */
142
- getTitle?: (entry: CollectionEntry<T>) => string;
143
- /**
144
- * When true, include entries with `data.draft === true`. Defaults
145
- * to false so production builds never leak unpublished pages into
146
- * the nav.
147
- */
148
- includeDrafts?: boolean;
149
- /**
150
- * Custom comparator applied to siblings at every level. When
151
- * omitted, the default (order-then-title) comparator is used —
152
- * consistent with `Collection.all()`'s default sort so the
153
- * sidebar order matches the `.all()` order.
154
- */
155
- sortNodes?: (a: SidebarNode, b: SidebarNode, depth: number) => number;
156
- /**
157
- * When a directory has no own "index" entry, synthesize a branch
158
- * node from the directory name. Default: true. Disable when a
159
- * project prefers flat nav with all leaves at root.
160
- */
161
- synthesizeGroups?: boolean;
162
- /**
163
- * When true, read `_meta.json` from each directory for titles,
164
- * icons, explicit `pages` ordering, and numeric `order`. Default:
165
- * true. Set to false for projects that don't use the convention —
166
- * the generator gracefully skips missing files regardless, so
167
- * disabling is purely a performance knob for very large trees.
168
- */
169
- useDirMeta?: boolean;
170
- }
171
-
172
- interface InternalNode {
173
- title: string;
174
- href: string;
175
- order: number;
176
- draft: boolean;
177
- slugSegments: string[];
178
- children: Map<string, InternalNode>;
179
- /** Source entry if this node corresponds to a real file. */
180
- entry?: CollectionEntry<unknown>;
181
- /** Directory-level metadata (from `_meta.json`, when present). */
182
- dirMeta?: DirMeta;
183
- /** Icon resolved from dir meta OR entry frontmatter. */
184
- icon?: string;
185
- }
186
-
187
- /**
188
- * Build a sidebar tree from a collection. Awaits `collection.all()`
189
- * internally so callers don't need to load beforehand.
190
- */
191
- export async function generateSidebar<T>(
192
- collection: Collection<T>,
193
- options: GenerateSidebarOptions<T> = {}
194
- ): Promise<SidebarNode[]> {
195
- const {
196
- basePath = "/",
197
- getTitle,
198
- includeDrafts = false,
199
- sortNodes,
200
- synthesizeGroups = true,
201
- useDirMeta = true,
202
- } = options;
203
- const entries = await collection.all();
204
- const visible = includeDrafts
205
- ? entries
206
- : entries.filter((e) => !(e.data as { draft?: unknown })?.draft);
207
-
208
- const root = new Map<string, InternalNode>();
209
- for (const entry of visible) {
210
- insertEntry(root, entry, getTitle, basePath, synthesizeGroups);
211
- }
212
-
213
- // Apply `_meta.json` metadata to the tree — only when the caller
214
- // opts in (default) AND the collection has a resolvable root.
215
- // Missing directories are a no-op so this is safe for synthetic
216
- // or virtual collections.
217
- if (useDirMeta) {
218
- const collectionRoot = resolveCollectionRoot(collection);
219
- if (collectionRoot) {
220
- applyDirMeta(root, collectionRoot, []);
221
- }
222
- }
223
-
224
- const tree = toSidebarNodes(root, basePath, synthesizeGroups);
225
- sortTreeWithMeta(tree, root, 0, sortNodes);
226
- return tree;
227
- }
228
-
229
- /**
230
- * Build a `Category[]` tree with rich metadata (slug, icon, order,
231
- * items). The `Category` shape is the preferred output for new docs
232
- * sites — legacy callers can keep using `generateSidebar`.
233
- *
234
- * Uses the same `_meta.json` conventions as `generateSidebar`. The
235
- * synthetic root category is flattened — the return value is the
236
- * array of top-level categories / entries, not a single wrapping
237
- * root (consistent with how `generateSidebar` returns `SidebarNode[]`).
238
- */
239
- export async function generateCategoryTree<T>(
240
- collection: Collection<T>,
241
- options: GenerateSidebarOptions<T> = {}
242
- ): Promise<Array<Category | CategoryEntry>> {
243
- const {
244
- basePath = "/",
245
- getTitle,
246
- includeDrafts = false,
247
- synthesizeGroups = true,
248
- useDirMeta = true,
249
- } = options;
250
- const entries = await collection.all();
251
- const visible = includeDrafts
252
- ? entries
253
- : entries.filter((e) => !(e.data as { draft?: unknown })?.draft);
254
-
255
- const root = new Map<string, InternalNode>();
256
- for (const entry of visible) {
257
- insertEntry(root, entry, getTitle, basePath, synthesizeGroups);
258
- }
259
- if (useDirMeta) {
260
- const collectionRoot = resolveCollectionRoot(collection);
261
- if (collectionRoot) {
262
- applyDirMeta(root, collectionRoot, []);
263
- }
264
- }
265
-
266
- return buildCategoryArray(root, []);
267
- }
268
-
269
- function insertEntry<T>(
270
- root: Map<string, InternalNode>,
271
- entry: CollectionEntry<T>,
272
- getTitle: ((entry: CollectionEntry<T>) => string) | undefined,
273
- basePath: string,
274
- synthesizeGroups: boolean
275
- ): void {
276
- const segments = entry.slug === "" ? [""] : entry.slug.split("/");
277
- let cursor = root;
278
- for (let i = 0; i < segments.length; i++) {
279
- const seg = segments[i];
280
- const isLeaf = i === segments.length - 1;
281
- let node = cursor.get(seg);
282
- if (!node) {
283
- node = {
284
- title: seg || "index",
285
- href: joinHref(basePath, segments.slice(0, i + 1).join("/")),
286
- order: Number.POSITIVE_INFINITY,
287
- draft: false,
288
- slugSegments: segments.slice(0, i + 1),
289
- children: new Map(),
290
- };
291
- cursor.set(seg, node);
292
- }
293
- if (isLeaf) {
294
- // Attach entry data to this node — overrides synthesized title.
295
- node.entry = entry as CollectionEntry<unknown>;
296
- node.title = getTitle
297
- ? getTitle(entry)
298
- : typeof (entry.data as { title?: unknown })?.title === "string"
299
- ? String((entry.data as { title: string }).title)
300
- : entry.slug || "index";
301
- const rawOrder = (entry.data as { order?: unknown })?.order;
302
- if (typeof rawOrder === "number") node.order = rawOrder;
303
- node.draft = Boolean((entry.data as { draft?: unknown })?.draft);
304
- const rawIcon = (entry.data as { icon?: unknown })?.icon;
305
- if (typeof rawIcon === "string") node.icon = rawIcon;
306
- node.href = joinHref(basePath, entry.slug);
307
- } else if (!synthesizeGroups) {
308
- // When groups are disabled, promote deeply-nested leaves to
309
- // flat root entries. We still traverse for consistent sorting.
310
- cursor = node.children;
311
- continue;
312
- }
313
- cursor = node.children;
314
- }
315
- }
316
-
317
- function toSidebarNodes(
318
- map: Map<string, InternalNode>,
319
- _basePath: string,
320
- _synthesizeGroups: boolean
321
- ): SidebarNode[] {
322
- const out: SidebarNode[] = [];
323
- for (const [key, node] of map) {
324
- // `__dir__` is a synthetic meta-only key created by
325
- // `applyDirMeta` — never emit it as a real sidebar node.
326
- if (key === "__dir__") continue;
327
- const children =
328
- node.children.size > 0
329
- ? toSidebarNodes(node.children, _basePath, _synthesizeGroups)
330
- : undefined;
331
- const sidebarNode: SidebarNode = {
332
- title: node.title,
333
- href: node.href,
334
- };
335
- if (node.draft) sidebarNode.draft = true;
336
- if (children) sidebarNode.children = children;
337
- out.push(sidebarNode);
338
- }
339
- return out;
340
- }
341
-
342
- /**
343
- * Sort the sidebar tree using the internal metadata (dir meta's
344
- * `pages` / `order` and leaf `order`). We route through the
345
- * InternalNode map to access `pages` ordering, then fall back to
346
- * the public `sortNodes` comparator for anything left over.
347
- */
348
- function sortTreeWithMeta(
349
- nodes: SidebarNode[],
350
- internalMap: Map<string, InternalNode>,
351
- depth: number,
352
- custom: ((a: SidebarNode, b: SidebarNode, depth: number) => number) | undefined
353
- ): void {
354
- // Build an index from SidebarNode.href -> InternalNode so we can
355
- // resolve meta for each public node without another pass.
356
- const byHref = new Map<string, InternalNode>();
357
- for (const node of internalMap.values()) {
358
- byHref.set(node.href, node);
359
- }
360
-
361
- // `pages` list from the parent dir meta — precomputed by the
362
- // caller when we recurse into a specific subtree. At the top
363
- // level we look at the root-level `_meta.json` (stored under the
364
- // synthetic `""` key when present).
365
- const rootMeta = internalMap.get("__dir__")?.dirMeta;
366
- const explicitPages = rootMeta?.pages ?? [];
367
- const pagesIndex = new Map<string, number>();
368
- explicitPages.forEach((slug, idx) => {
369
- // `pages` entries are relative slugs (just the last segment).
370
- pagesIndex.set(slug, idx);
371
- });
372
-
373
- const fallbackNumeric = (a: SidebarNode, b: SidebarNode): number =>
374
- a.title.localeCompare(b.title, undefined, { numeric: true });
375
-
376
- nodes.sort((a, b) => {
377
- const aInternal = byHref.get(a.href);
378
- const bInternal = byHref.get(b.href);
379
-
380
- // 1. Explicit `pages` position wins absolutely.
381
- const aLastSeg = lastSlugSegment(aInternal);
382
- const bLastSeg = lastSlugSegment(bInternal);
383
- const aIdx = aLastSeg !== undefined ? pagesIndex.get(aLastSeg) : undefined;
384
- const bIdx = bLastSeg !== undefined ? pagesIndex.get(bLastSeg) : undefined;
385
- if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
386
- if (aIdx !== undefined) return -1;
387
- if (bIdx !== undefined) return 1;
388
-
389
- // 2. Caller-supplied comparator.
390
- if (custom) {
391
- const c = custom(a, b, depth);
392
- if (c !== 0) return c;
393
- }
394
-
395
- // 3. Numeric `order` from frontmatter / dir meta.
396
- const aOrder = categoryOrder(aInternal);
397
- const bOrder = categoryOrder(bInternal);
398
- if (aOrder !== bOrder) return aOrder - bOrder;
399
-
400
- // 4. Numeric-aware title collation.
401
- return fallbackNumeric(a, b);
402
- });
403
-
404
- for (const node of nodes) {
405
- if (node.children) {
406
- // Recurse into the child InternalNode map so nested pages/order
407
- // metadata applies at each level.
408
- const internal = byHref.get(node.href);
409
- if (internal) {
410
- const childrenMap = internal.children;
411
- // Seed the child map's `__dir__` proxy with the parent's
412
- // dir meta for recursion — we previously stored dir meta on
413
- // the parent node itself.
414
- if (internal.dirMeta) {
415
- childrenMap.set("__dir__", {
416
- ...internal,
417
- dirMeta: internal.dirMeta,
418
- children: new Map(),
419
- });
420
- }
421
- sortTreeWithMeta(node.children, childrenMap, depth + 1, custom);
422
- childrenMap.delete("__dir__");
423
- } else {
424
- node.children.sort(fallbackNumeric);
425
- for (const child of node.children) {
426
- if (child.children) sortTreeWithMeta(child.children, new Map(), depth + 1, custom);
427
- }
428
- }
429
- }
430
- }
431
- }
432
-
433
- function lastSlugSegment(node: InternalNode | undefined): string | undefined {
434
- if (!node) return undefined;
435
- return node.slugSegments[node.slugSegments.length - 1];
436
- }
437
-
438
- function categoryOrder(node: InternalNode | undefined): number {
439
- if (!node) return Number.POSITIVE_INFINITY;
440
- if (typeof node.dirMeta?.order === "number") return node.dirMeta.order;
441
- return node.order;
442
- }
443
-
444
- function joinHref(base: string, slug: string): string {
445
- const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
446
- if (!slug) return normalizedBase || "/";
447
- const normalizedSlug = slug.startsWith("/") ? slug.slice(1) : slug;
448
- return `${normalizedBase}/${normalizedSlug}`.replace(/\/+/g, "/");
449
- }
450
-
451
- /**
452
- * Resolve the filesystem root for a collection without reaching into
453
- * its private state. We rely on the public `options.path` + optional
454
- * `options.root` to recreate the path; if either is missing (e.g. a
455
- * synthetic collection built from `new Collection({ path: "" })`),
456
- * we return `null` and skip dir-meta loading.
457
- */
458
- function resolveCollectionRoot<T>(collection: Collection<T>): string | null {
459
- const opts = collection.options;
460
- if (!opts.path) return null;
461
- const root = opts.root ?? process.cwd();
462
- const resolved = path.isAbsolute(opts.path)
463
- ? opts.path
464
- : path.resolve(root, opts.path);
465
- // Skip dir-meta entirely if the directory doesn't exist on disk —
466
- // synthetic collections shouldn't crash on first load.
467
- if (!fs.existsSync(resolved)) return null;
468
- return resolved;
469
- }
470
-
471
- /**
472
- * Walk the internal tree, loading `_meta.json` at each directory
473
- * level when present. Non-existent files are silently skipped;
474
- * malformed JSON emits a one-line warning (so authors can diagnose
475
- * typos) and continues with empty meta.
476
- */
477
- function applyDirMeta(
478
- map: Map<string, InternalNode>,
479
- currentDir: string,
480
- pathSoFar: string[]
481
- ): void {
482
- // Look for a `_meta.json` at this directory level and attach it
483
- // to every child node so sorting/category generation can consult
484
- // the parent's metadata.
485
- const metaPath = path.join(currentDir, "_meta.json");
486
- let dirMeta: DirMeta | undefined;
487
- if (fs.existsSync(metaPath)) {
488
- try {
489
- const raw = fs.readFileSync(metaPath, "utf8");
490
- dirMeta = JSON.parse(raw) as DirMeta;
491
- } catch (err) {
492
- // A broken `_meta.json` should not crash the sidebar —
493
- // authors will see the warning and fix it. We fall back to
494
- // the same defaults as if the file were absent.
495
- console.warn(
496
- `[content] failed to parse ${metaPath}:`,
497
- err instanceof Error ? err.message : err
498
- );
499
- }
500
- }
501
-
502
- // Attach the dir meta to each node at this level so sorting /
503
- // category generation can pick it up. A `_meta.json` in docs/
504
- // describes its children — so we stash it under the SYNTHETIC
505
- // `__dir__` key of the parent map, which `sortTreeWithMeta` and
506
- // `buildCategoryArray` both look up.
507
- if (dirMeta) {
508
- // Pin the parent's dir meta onto the map itself via the
509
- // `__dir__` synthetic key. The key never collides with a
510
- // real slug segment because slug segments are URL-safe and
511
- // never start with `__`.
512
- map.set("__dir__", {
513
- title: dirMeta.title ?? path.basename(currentDir) ?? "",
514
- href: "",
515
- order: typeof dirMeta.order === "number" ? dirMeta.order : Number.POSITIVE_INFINITY,
516
- draft: false,
517
- slugSegments: pathSoFar,
518
- children: new Map(),
519
- dirMeta,
520
- icon: dirMeta.icon,
521
- });
522
- }
523
-
524
- // Recurse into every child directory.
525
- for (const [seg, node] of map) {
526
- if (seg === "__dir__") continue;
527
- // A child is a directory iff it has children OR the segment
528
- // maps to an actual directory on disk (the node may be a file
529
- // entry with no children but a dir-level sibling).
530
- const childDir = path.join(currentDir, seg);
531
- if (fs.existsSync(childDir) && fs.statSync(childDir).isDirectory()) {
532
- // Copy parent's dir meta's icon/order onto this branch
533
- // node so category output has the right values even if the
534
- // child directory has no _meta.json of its own.
535
- if (dirMeta) {
536
- // Record on the branch itself when it's a grouping
537
- // category (has children).
538
- if (node.children.size > 0) {
539
- // No-op: we'll fetch dir meta for this branch from its
540
- // own _meta.json during recursion.
541
- }
542
- }
543
- applyDirMeta(node.children, childDir, [...pathSoFar, seg]);
544
- }
545
- }
546
- }
547
-
548
- /**
549
- * Build the `Category | CategoryEntry` array from the internal
550
- * tree. Siblings at each depth are sorted under the same precedence
551
- * as `sortTreeWithMeta`: explicit `pages` > `order` > numeric title.
552
- */
553
- function buildCategoryArray(
554
- map: Map<string, InternalNode>,
555
- pathSoFar: string[]
556
- ): Array<Category | CategoryEntry> {
557
- const dirMetaNode = map.get("__dir__");
558
- const dirMeta = dirMetaNode?.dirMeta;
559
- const pagesIndex = new Map<string, number>();
560
- if (dirMeta?.pages) {
561
- dirMeta.pages.forEach((slug, idx) => pagesIndex.set(slug, idx));
562
- }
563
-
564
- const out: Array<Category | CategoryEntry> = [];
565
- for (const [seg, node] of map) {
566
- if (seg === "__dir__") continue;
567
- const segmentSlug = pathSoFar.concat(seg).join("/");
568
- if (node.children.size > 0) {
569
- // Branch — emit a Category.
570
- const childDirMetaNode = node.children.get("__dir__");
571
- const childDirMeta = childDirMetaNode?.dirMeta;
572
- const category: Category = {
573
- kind: "category",
574
- slug: segmentSlug,
575
- title:
576
- childDirMeta?.title ??
577
- (node.entry
578
- ? node.title
579
- : seg || "index"),
580
- items: buildCategoryArray(node.children, pathSoFar.concat(seg)),
581
- };
582
- if (typeof childDirMeta?.order === "number") {
583
- category.order = childDirMeta.order;
584
- } else if (node.order !== Number.POSITIVE_INFINITY) {
585
- category.order = node.order;
586
- }
587
- if (childDirMeta?.icon) category.icon = childDirMeta.icon;
588
- else if (node.icon) category.icon = node.icon;
589
- // If the branch has an entry attached (e.g. `docs/guide.md`
590
- // AND `docs/guide/` both exist), expose the href so the
591
- // category can also be clickable.
592
- if (node.entry) category.href = node.href;
593
- out.push(category);
594
- } else {
595
- // Leaf — emit a CategoryEntry.
596
- const entry: CategoryEntry = {
597
- kind: "entry",
598
- slug: segmentSlug,
599
- title: node.title,
600
- href: node.href,
601
- };
602
- if (node.order !== Number.POSITIVE_INFINITY) entry.order = node.order;
603
- if (node.icon) entry.icon = node.icon;
604
- if (node.draft) entry.draft = true;
605
- out.push(entry);
606
- }
607
- }
608
-
609
- out.sort((a, b) => {
610
- const aKey = categorySlugSegment(a);
611
- const bKey = categorySlugSegment(b);
612
- const aIdx = pagesIndex.get(aKey);
613
- const bIdx = pagesIndex.get(bKey);
614
- if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
615
- if (aIdx !== undefined) return -1;
616
- if (bIdx !== undefined) return 1;
617
-
618
- const aOrder = a.order ?? Number.POSITIVE_INFINITY;
619
- const bOrder = b.order ?? Number.POSITIVE_INFINITY;
620
- if (aOrder !== bOrder) return aOrder - bOrder;
621
- return a.title.localeCompare(b.title, undefined, { numeric: true });
622
- });
623
-
624
- return out;
625
- }
626
-
627
- function categorySlugSegment(node: Category | CategoryEntry): string {
628
- const parts = node.slug.split("/");
629
- return parts[parts.length - 1] ?? node.slug;
630
- }
1
+ /**
2
+ * Sidebar Generator (Issues #199, #205)
3
+ *
4
+ * Takes a loaded `Collection` and produces a nested navigation tree
5
+ * suitable for rendering a docs sidebar.
6
+ *
7
+ * Two output shapes are supported:
8
+ *
9
+ * 1. **`SidebarNode[]`** (default) — a lightweight
10
+ * `{ title, href, children }` tree. Backward-compatible with the
11
+ * Wave D MVP signature: `await generateSidebar(collection)`.
12
+ *
13
+ * 2. **`Category[]`** (Issue #205) — a richer tree with `slug`,
14
+ * `icon`, `order`, and `items` so docs sites can render
15
+ * sectioned navigation with icons and explicit ordering. Enable
16
+ * via `generateCategoryTree(collection, options)`.
17
+ *
18
+ * # `_meta.json` support (Issue #205)
19
+ *
20
+ * When a directory contains a `_meta.json` file alongside its
21
+ * markdown entries, the generator reads it for per-directory metadata
22
+ * and ordering hints:
23
+ *
24
+ * ```json
25
+ * {
26
+ * "title": "Getting Started",
27
+ * "icon": "rocket",
28
+ * "order": 1,
29
+ * "pages": ["intro", "install", "quickstart"]
30
+ * }
31
+ * ```
32
+ *
33
+ * The `pages` field takes precedence over frontmatter `order` and
34
+ * filename alphabetical — it is the explicit escape hatch when the
35
+ * author wants a specific nav order that does not match any
36
+ * mechanical rule. Missing `pages` entries fall back to frontmatter
37
+ * `order` ascending, then filename alphabetical (numeric-aware so
38
+ * `10-foo` sorts after `2-foo`).
39
+ *
40
+ * # Ordering precedence (siblings at the same depth)
41
+ *
42
+ * 1. Explicit index in parent's `_meta.json` `pages: []`.
43
+ * 2. `order` field — directory `_meta.json` for categories,
44
+ * frontmatter `order` for leaf entries. Lower values first.
45
+ * 3. Numeric-aware filename comparison (title or slug).
46
+ *
47
+ * # Draft entries
48
+ *
49
+ * Entries with `data.draft === true` are **filtered out by default**.
50
+ * Callers can surface them with `includeDrafts: true` (preview builds).
51
+ */
52
+
53
+ import * as fs from "fs";
54
+ import * as path from "path";
55
+ import type { Collection, CollectionEntry } from "./collection";
56
+ export type { Collection };
57
+
58
+ /** A node in the sidebar tree (legacy + MVP shape). */
59
+ export interface SidebarNode {
60
+ title: string;
61
+ href: string;
62
+ /** Present only on branch nodes (grouped categories). */
63
+ children?: SidebarNode[];
64
+ /** Draft/external flags — populated by the generator, caller-facing. */
65
+ draft?: boolean;
66
+ }
67
+
68
+ /**
69
+ * Rich category node produced by `generateCategoryTree` (Issue #205).
70
+ * `items` mixes child categories and leaf entries — siblings at any
71
+ * depth are ordered consistently under the same precedence rules.
72
+ */
73
+ export interface Category {
74
+ /** Directory-relative slug. For nested categories this includes
75
+ * parent slugs (`getting-started/install`). For the root-level
76
+ * synthetic category, this is `""`. */
77
+ slug: string;
78
+ /** Resolved title — `_meta.json.title` > directory name > slug. */
79
+ title: string;
80
+ /** Optional icon identifier from `_meta.json.icon`. */
81
+ icon?: string;
82
+ /** Ordering key (`_meta.json.order`). Missing = Infinity. */
83
+ order?: number;
84
+ /** Mixed children: `Category` branches or leaf `CategoryEntry`s. */
85
+ items: Array<Category | CategoryEntry>;
86
+ /** Absolute href for the category landing, when an `index` entry exists. */
87
+ href?: string;
88
+ /** Tag so callers can discriminate without `'items' in x`. */
89
+ kind: "category";
90
+ }
91
+
92
+ /** Leaf entry emitted into a `Category.items` array. */
93
+ export interface CategoryEntry {
94
+ /** Absolute href (prefixed by `basePath`). */
95
+ href: string;
96
+ /** Resolved title — frontmatter `title` > slug. */
97
+ title: string;
98
+ /** Optional per-entry icon from frontmatter. */
99
+ icon?: string;
100
+ /** Frontmatter `order`, when present. */
101
+ order?: number;
102
+ /** The original slug (directory-relative). */
103
+ slug: string;
104
+ /** `data.draft === true` — surfaced only when `includeDrafts` is on. */
105
+ draft?: boolean;
106
+ /** Tag so callers can discriminate without `'items' in x`. */
107
+ kind: "entry";
108
+ }
109
+
110
+ /** Shape of a `_meta.json` file as parsed by the sidebar generator. */
111
+ export interface DirMeta {
112
+ /** Override for the category title. */
113
+ title?: string;
114
+ /** Icon identifier (consumer-defined, e.g. a lucide name). */
115
+ icon?: string;
116
+ /** Ordering key among siblings — ascending. */
117
+ order?: number;
118
+ /**
119
+ * Explicit list of child slugs (directory-relative, extension-free)
120
+ * that controls the order and membership of `items`. Entries not
121
+ * listed here are appended in the default order after the listed
122
+ * entries — authors get a "pin these at top" ergonomic without
123
+ * having to list every file.
124
+ */
125
+ pages?: string[];
126
+ }
127
+
128
+ /** Options controlling sidebar shape and filtering. */
129
+ export interface GenerateSidebarOptions<T> {
130
+ /**
131
+ * Href prefix prepended to every node. Defaults to `/` so a slug
132
+ * of `intro` becomes `/intro`. Projects mounting docs under
133
+ * `/docs/` should pass `basePath: '/docs'`.
134
+ */
135
+ basePath?: string;
136
+ /**
137
+ * Extract the title shown in the sidebar. Defaults to
138
+ * `entry.data.title ?? entry.slug`. Implementations that store
139
+ * the sidebar label in a different field (e.g. `nav_title`) can
140
+ * override here.
141
+ */
142
+ getTitle?: (entry: CollectionEntry<T>) => string;
143
+ /**
144
+ * When true, include entries with `data.draft === true`. Defaults
145
+ * to false so production builds never leak unpublished pages into
146
+ * the nav.
147
+ */
148
+ includeDrafts?: boolean;
149
+ /**
150
+ * Custom comparator applied to siblings at every level. When
151
+ * omitted, the default (order-then-title) comparator is used —
152
+ * consistent with `Collection.all()`'s default sort so the
153
+ * sidebar order matches the `.all()` order.
154
+ */
155
+ sortNodes?: (a: SidebarNode, b: SidebarNode, depth: number) => number;
156
+ /**
157
+ * When a directory has no own "index" entry, synthesize a branch
158
+ * node from the directory name. Default: true. Disable when a
159
+ * project prefers flat nav with all leaves at root.
160
+ */
161
+ synthesizeGroups?: boolean;
162
+ /**
163
+ * When true, read `_meta.json` from each directory for titles,
164
+ * icons, explicit `pages` ordering, and numeric `order`. Default:
165
+ * true. Set to false for projects that don't use the convention —
166
+ * the generator gracefully skips missing files regardless, so
167
+ * disabling is purely a performance knob for very large trees.
168
+ */
169
+ useDirMeta?: boolean;
170
+ }
171
+
172
+ interface InternalNode {
173
+ title: string;
174
+ href: string;
175
+ order: number;
176
+ draft: boolean;
177
+ slugSegments: string[];
178
+ children: Map<string, InternalNode>;
179
+ /** Source entry if this node corresponds to a real file. */
180
+ entry?: CollectionEntry<unknown>;
181
+ /** Directory-level metadata (from `_meta.json`, when present). */
182
+ dirMeta?: DirMeta;
183
+ /** Icon resolved from dir meta OR entry frontmatter. */
184
+ icon?: string;
185
+ }
186
+
187
+ /**
188
+ * Build a sidebar tree from a collection. Awaits `collection.all()`
189
+ * internally so callers don't need to load beforehand.
190
+ */
191
+ export async function generateSidebar<T>(
192
+ collection: Collection<T>,
193
+ options: GenerateSidebarOptions<T> = {}
194
+ ): Promise<SidebarNode[]> {
195
+ const {
196
+ basePath = "/",
197
+ getTitle,
198
+ includeDrafts = false,
199
+ sortNodes,
200
+ synthesizeGroups = true,
201
+ useDirMeta = true,
202
+ } = options;
203
+ const entries = await collection.all();
204
+ const visible = includeDrafts
205
+ ? entries
206
+ : entries.filter((e) => !(e.data as { draft?: unknown })?.draft);
207
+
208
+ const root = new Map<string, InternalNode>();
209
+ for (const entry of visible) {
210
+ insertEntry(root, entry, getTitle, basePath, synthesizeGroups);
211
+ }
212
+
213
+ // Apply `_meta.json` metadata to the tree — only when the caller
214
+ // opts in (default) AND the collection has a resolvable root.
215
+ // Missing directories are a no-op so this is safe for synthetic
216
+ // or virtual collections.
217
+ if (useDirMeta) {
218
+ const collectionRoot = resolveCollectionRoot(collection);
219
+ if (collectionRoot) {
220
+ applyDirMeta(root, collectionRoot, []);
221
+ }
222
+ }
223
+
224
+ const tree = toSidebarNodes(root, basePath, synthesizeGroups);
225
+ sortTreeWithMeta(tree, root, 0, sortNodes);
226
+ return tree;
227
+ }
228
+
229
+ /**
230
+ * Build a `Category[]` tree with rich metadata (slug, icon, order,
231
+ * items). The `Category` shape is the preferred output for new docs
232
+ * sites — legacy callers can keep using `generateSidebar`.
233
+ *
234
+ * Uses the same `_meta.json` conventions as `generateSidebar`. The
235
+ * synthetic root category is flattened — the return value is the
236
+ * array of top-level categories / entries, not a single wrapping
237
+ * root (consistent with how `generateSidebar` returns `SidebarNode[]`).
238
+ */
239
+ export async function generateCategoryTree<T>(
240
+ collection: Collection<T>,
241
+ options: GenerateSidebarOptions<T> = {}
242
+ ): Promise<Array<Category | CategoryEntry>> {
243
+ const {
244
+ basePath = "/",
245
+ getTitle,
246
+ includeDrafts = false,
247
+ synthesizeGroups = true,
248
+ useDirMeta = true,
249
+ } = options;
250
+ const entries = await collection.all();
251
+ const visible = includeDrafts
252
+ ? entries
253
+ : entries.filter((e) => !(e.data as { draft?: unknown })?.draft);
254
+
255
+ const root = new Map<string, InternalNode>();
256
+ for (const entry of visible) {
257
+ insertEntry(root, entry, getTitle, basePath, synthesizeGroups);
258
+ }
259
+ if (useDirMeta) {
260
+ const collectionRoot = resolveCollectionRoot(collection);
261
+ if (collectionRoot) {
262
+ applyDirMeta(root, collectionRoot, []);
263
+ }
264
+ }
265
+
266
+ return buildCategoryArray(root, []);
267
+ }
268
+
269
+ function insertEntry<T>(
270
+ root: Map<string, InternalNode>,
271
+ entry: CollectionEntry<T>,
272
+ getTitle: ((entry: CollectionEntry<T>) => string) | undefined,
273
+ basePath: string,
274
+ synthesizeGroups: boolean
275
+ ): void {
276
+ const segments = entry.slug === "" ? [""] : entry.slug.split("/");
277
+ let cursor = root;
278
+ for (let i = 0; i < segments.length; i++) {
279
+ const seg = segments[i];
280
+ const isLeaf = i === segments.length - 1;
281
+ let node = cursor.get(seg);
282
+ if (!node) {
283
+ node = {
284
+ title: seg || "index",
285
+ href: joinHref(basePath, segments.slice(0, i + 1).join("/")),
286
+ order: Number.POSITIVE_INFINITY,
287
+ draft: false,
288
+ slugSegments: segments.slice(0, i + 1),
289
+ children: new Map(),
290
+ };
291
+ cursor.set(seg, node);
292
+ }
293
+ if (isLeaf) {
294
+ // Attach entry data to this node — overrides synthesized title.
295
+ node.entry = entry as CollectionEntry<unknown>;
296
+ node.title = getTitle
297
+ ? getTitle(entry)
298
+ : typeof (entry.data as { title?: unknown })?.title === "string"
299
+ ? String((entry.data as { title: string }).title)
300
+ : entry.slug || "index";
301
+ const rawOrder = (entry.data as { order?: unknown })?.order;
302
+ if (typeof rawOrder === "number") node.order = rawOrder;
303
+ node.draft = Boolean((entry.data as { draft?: unknown })?.draft);
304
+ const rawIcon = (entry.data as { icon?: unknown })?.icon;
305
+ if (typeof rawIcon === "string") node.icon = rawIcon;
306
+ node.href = joinHref(basePath, entry.slug);
307
+ } else if (!synthesizeGroups) {
308
+ // When groups are disabled, promote deeply-nested leaves to
309
+ // flat root entries. We still traverse for consistent sorting.
310
+ cursor = node.children;
311
+ continue;
312
+ }
313
+ cursor = node.children;
314
+ }
315
+ }
316
+
317
+ function toSidebarNodes(
318
+ map: Map<string, InternalNode>,
319
+ _basePath: string,
320
+ _synthesizeGroups: boolean
321
+ ): SidebarNode[] {
322
+ const out: SidebarNode[] = [];
323
+ for (const [key, node] of map) {
324
+ // `__dir__` is a synthetic meta-only key created by
325
+ // `applyDirMeta` — never emit it as a real sidebar node.
326
+ if (key === "__dir__") continue;
327
+ const children =
328
+ node.children.size > 0
329
+ ? toSidebarNodes(node.children, _basePath, _synthesizeGroups)
330
+ : undefined;
331
+ const sidebarNode: SidebarNode = {
332
+ title: node.title,
333
+ href: node.href,
334
+ };
335
+ if (node.draft) sidebarNode.draft = true;
336
+ if (children) sidebarNode.children = children;
337
+ out.push(sidebarNode);
338
+ }
339
+ return out;
340
+ }
341
+
342
+ /**
343
+ * Sort the sidebar tree using the internal metadata (dir meta's
344
+ * `pages` / `order` and leaf `order`). We route through the
345
+ * InternalNode map to access `pages` ordering, then fall back to
346
+ * the public `sortNodes` comparator for anything left over.
347
+ */
348
+ function sortTreeWithMeta(
349
+ nodes: SidebarNode[],
350
+ internalMap: Map<string, InternalNode>,
351
+ depth: number,
352
+ custom: ((a: SidebarNode, b: SidebarNode, depth: number) => number) | undefined
353
+ ): void {
354
+ // Build an index from SidebarNode.href -> InternalNode so we can
355
+ // resolve meta for each public node without another pass.
356
+ const byHref = new Map<string, InternalNode>();
357
+ for (const node of internalMap.values()) {
358
+ byHref.set(node.href, node);
359
+ }
360
+
361
+ // `pages` list from the parent dir meta — precomputed by the
362
+ // caller when we recurse into a specific subtree. At the top
363
+ // level we look at the root-level `_meta.json` (stored under the
364
+ // synthetic `""` key when present).
365
+ const rootMeta = internalMap.get("__dir__")?.dirMeta;
366
+ const explicitPages = rootMeta?.pages ?? [];
367
+ const pagesIndex = new Map<string, number>();
368
+ explicitPages.forEach((slug, idx) => {
369
+ // `pages` entries are relative slugs (just the last segment).
370
+ pagesIndex.set(slug, idx);
371
+ });
372
+
373
+ const fallbackNumeric = (a: SidebarNode, b: SidebarNode): number =>
374
+ a.title.localeCompare(b.title, undefined, { numeric: true });
375
+
376
+ nodes.sort((a, b) => {
377
+ const aInternal = byHref.get(a.href);
378
+ const bInternal = byHref.get(b.href);
379
+
380
+ // 1. Explicit `pages` position wins absolutely.
381
+ const aLastSeg = lastSlugSegment(aInternal);
382
+ const bLastSeg = lastSlugSegment(bInternal);
383
+ const aIdx = aLastSeg !== undefined ? pagesIndex.get(aLastSeg) : undefined;
384
+ const bIdx = bLastSeg !== undefined ? pagesIndex.get(bLastSeg) : undefined;
385
+ if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
386
+ if (aIdx !== undefined) return -1;
387
+ if (bIdx !== undefined) return 1;
388
+
389
+ // 2. Caller-supplied comparator.
390
+ if (custom) {
391
+ const c = custom(a, b, depth);
392
+ if (c !== 0) return c;
393
+ }
394
+
395
+ // 3. Numeric `order` from frontmatter / dir meta.
396
+ const aOrder = categoryOrder(aInternal);
397
+ const bOrder = categoryOrder(bInternal);
398
+ if (aOrder !== bOrder) return aOrder - bOrder;
399
+
400
+ // 4. Numeric-aware title collation.
401
+ return fallbackNumeric(a, b);
402
+ });
403
+
404
+ for (const node of nodes) {
405
+ if (node.children) {
406
+ // Recurse into the child InternalNode map so nested pages/order
407
+ // metadata applies at each level.
408
+ const internal = byHref.get(node.href);
409
+ if (internal) {
410
+ const childrenMap = internal.children;
411
+ // Seed the child map's `__dir__` proxy with the parent's
412
+ // dir meta for recursion — we previously stored dir meta on
413
+ // the parent node itself.
414
+ if (internal.dirMeta) {
415
+ childrenMap.set("__dir__", {
416
+ ...internal,
417
+ dirMeta: internal.dirMeta,
418
+ children: new Map(),
419
+ });
420
+ }
421
+ sortTreeWithMeta(node.children, childrenMap, depth + 1, custom);
422
+ childrenMap.delete("__dir__");
423
+ } else {
424
+ node.children.sort(fallbackNumeric);
425
+ for (const child of node.children) {
426
+ if (child.children) sortTreeWithMeta(child.children, new Map(), depth + 1, custom);
427
+ }
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ function lastSlugSegment(node: InternalNode | undefined): string | undefined {
434
+ if (!node) return undefined;
435
+ return node.slugSegments[node.slugSegments.length - 1];
436
+ }
437
+
438
+ function categoryOrder(node: InternalNode | undefined): number {
439
+ if (!node) return Number.POSITIVE_INFINITY;
440
+ if (typeof node.dirMeta?.order === "number") return node.dirMeta.order;
441
+ return node.order;
442
+ }
443
+
444
+ function joinHref(base: string, slug: string): string {
445
+ const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
446
+ if (!slug) return normalizedBase || "/";
447
+ const normalizedSlug = slug.startsWith("/") ? slug.slice(1) : slug;
448
+ return `${normalizedBase}/${normalizedSlug}`.replace(/\/+/g, "/");
449
+ }
450
+
451
+ /**
452
+ * Resolve the filesystem root for a collection without reaching into
453
+ * its private state. We rely on the public `options.path` + optional
454
+ * `options.root` to recreate the path; if either is missing (e.g. a
455
+ * synthetic collection built from `new Collection({ path: "" })`),
456
+ * we return `null` and skip dir-meta loading.
457
+ */
458
+ function resolveCollectionRoot<T>(collection: Collection<T>): string | null {
459
+ const opts = collection.options;
460
+ if (!opts.path) return null;
461
+ const root = opts.root ?? process.cwd();
462
+ const resolved = path.isAbsolute(opts.path)
463
+ ? opts.path
464
+ : path.resolve(root, opts.path);
465
+ // Skip dir-meta entirely if the directory doesn't exist on disk —
466
+ // synthetic collections shouldn't crash on first load.
467
+ if (!fs.existsSync(resolved)) return null;
468
+ return resolved;
469
+ }
470
+
471
+ /**
472
+ * Walk the internal tree, loading `_meta.json` at each directory
473
+ * level when present. Non-existent files are silently skipped;
474
+ * malformed JSON emits a one-line warning (so authors can diagnose
475
+ * typos) and continues with empty meta.
476
+ */
477
+ function applyDirMeta(
478
+ map: Map<string, InternalNode>,
479
+ currentDir: string,
480
+ pathSoFar: string[]
481
+ ): void {
482
+ // Look for a `_meta.json` at this directory level and attach it
483
+ // to every child node so sorting/category generation can consult
484
+ // the parent's metadata.
485
+ const metaPath = path.join(currentDir, "_meta.json");
486
+ let dirMeta: DirMeta | undefined;
487
+ if (fs.existsSync(metaPath)) {
488
+ try {
489
+ const raw = fs.readFileSync(metaPath, "utf8");
490
+ dirMeta = JSON.parse(raw) as DirMeta;
491
+ } catch (err) {
492
+ // A broken `_meta.json` should not crash the sidebar —
493
+ // authors will see the warning and fix it. We fall back to
494
+ // the same defaults as if the file were absent.
495
+ console.warn(
496
+ `[content] failed to parse ${metaPath}:`,
497
+ err instanceof Error ? err.message : err
498
+ );
499
+ }
500
+ }
501
+
502
+ // Attach the dir meta to each node at this level so sorting /
503
+ // category generation can pick it up. A `_meta.json` in docs/
504
+ // describes its children — so we stash it under the SYNTHETIC
505
+ // `__dir__` key of the parent map, which `sortTreeWithMeta` and
506
+ // `buildCategoryArray` both look up.
507
+ if (dirMeta) {
508
+ // Pin the parent's dir meta onto the map itself via the
509
+ // `__dir__` synthetic key. The key never collides with a
510
+ // real slug segment because slug segments are URL-safe and
511
+ // never start with `__`.
512
+ map.set("__dir__", {
513
+ title: dirMeta.title ?? path.basename(currentDir) ?? "",
514
+ href: "",
515
+ order: typeof dirMeta.order === "number" ? dirMeta.order : Number.POSITIVE_INFINITY,
516
+ draft: false,
517
+ slugSegments: pathSoFar,
518
+ children: new Map(),
519
+ dirMeta,
520
+ icon: dirMeta.icon,
521
+ });
522
+ }
523
+
524
+ // Recurse into every child directory.
525
+ for (const [seg, node] of map) {
526
+ if (seg === "__dir__") continue;
527
+ // A child is a directory iff it has children OR the segment
528
+ // maps to an actual directory on disk (the node may be a file
529
+ // entry with no children but a dir-level sibling).
530
+ const childDir = path.join(currentDir, seg);
531
+ if (fs.existsSync(childDir) && fs.statSync(childDir).isDirectory()) {
532
+ // Copy parent's dir meta's icon/order onto this branch
533
+ // node so category output has the right values even if the
534
+ // child directory has no _meta.json of its own.
535
+ if (dirMeta) {
536
+ // Record on the branch itself when it's a grouping
537
+ // category (has children).
538
+ if (node.children.size > 0) {
539
+ // No-op: we'll fetch dir meta for this branch from its
540
+ // own _meta.json during recursion.
541
+ }
542
+ }
543
+ applyDirMeta(node.children, childDir, [...pathSoFar, seg]);
544
+ }
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Build the `Category | CategoryEntry` array from the internal
550
+ * tree. Siblings at each depth are sorted under the same precedence
551
+ * as `sortTreeWithMeta`: explicit `pages` > `order` > numeric title.
552
+ */
553
+ function buildCategoryArray(
554
+ map: Map<string, InternalNode>,
555
+ pathSoFar: string[]
556
+ ): Array<Category | CategoryEntry> {
557
+ const dirMetaNode = map.get("__dir__");
558
+ const dirMeta = dirMetaNode?.dirMeta;
559
+ const pagesIndex = new Map<string, number>();
560
+ if (dirMeta?.pages) {
561
+ dirMeta.pages.forEach((slug, idx) => pagesIndex.set(slug, idx));
562
+ }
563
+
564
+ const out: Array<Category | CategoryEntry> = [];
565
+ for (const [seg, node] of map) {
566
+ if (seg === "__dir__") continue;
567
+ const segmentSlug = pathSoFar.concat(seg).join("/");
568
+ if (node.children.size > 0) {
569
+ // Branch — emit a Category.
570
+ const childDirMetaNode = node.children.get("__dir__");
571
+ const childDirMeta = childDirMetaNode?.dirMeta;
572
+ const category: Category = {
573
+ kind: "category",
574
+ slug: segmentSlug,
575
+ title:
576
+ childDirMeta?.title ??
577
+ (node.entry
578
+ ? node.title
579
+ : seg || "index"),
580
+ items: buildCategoryArray(node.children, pathSoFar.concat(seg)),
581
+ };
582
+ if (typeof childDirMeta?.order === "number") {
583
+ category.order = childDirMeta.order;
584
+ } else if (node.order !== Number.POSITIVE_INFINITY) {
585
+ category.order = node.order;
586
+ }
587
+ if (childDirMeta?.icon) category.icon = childDirMeta.icon;
588
+ else if (node.icon) category.icon = node.icon;
589
+ // If the branch has an entry attached (e.g. `docs/guide.md`
590
+ // AND `docs/guide/` both exist), expose the href so the
591
+ // category can also be clickable.
592
+ if (node.entry) category.href = node.href;
593
+ out.push(category);
594
+ } else {
595
+ // Leaf — emit a CategoryEntry.
596
+ const entry: CategoryEntry = {
597
+ kind: "entry",
598
+ slug: segmentSlug,
599
+ title: node.title,
600
+ href: node.href,
601
+ };
602
+ if (node.order !== Number.POSITIVE_INFINITY) entry.order = node.order;
603
+ if (node.icon) entry.icon = node.icon;
604
+ if (node.draft) entry.draft = true;
605
+ out.push(entry);
606
+ }
607
+ }
608
+
609
+ out.sort((a, b) => {
610
+ const aKey = categorySlugSegment(a);
611
+ const bKey = categorySlugSegment(b);
612
+ const aIdx = pagesIndex.get(aKey);
613
+ const bIdx = pagesIndex.get(bKey);
614
+ if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
615
+ if (aIdx !== undefined) return -1;
616
+ if (bIdx !== undefined) return 1;
617
+
618
+ const aOrder = a.order ?? Number.POSITIVE_INFINITY;
619
+ const bOrder = b.order ?? Number.POSITIVE_INFINITY;
620
+ if (aOrder !== bOrder) return aOrder - bOrder;
621
+ return a.title.localeCompare(b.title, undefined, { numeric: true });
622
+ });
623
+
624
+ return out;
625
+ }
626
+
627
+ function categorySlugSegment(node: Category | CategoryEntry): string {
628
+ const parts = node.slug.split("/");
629
+ return parts[parts.length - 1] ?? node.slug;
630
+ }