@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.
- package/README.md +654 -654
- package/package.json +106 -210
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +15 -15
- package/src/a11y/types.ts +125 -125
- package/src/agent/__tests__/apply.test.ts +224 -0
- package/src/agent/__tests__/context.test.ts +97 -98
- package/src/agent/apply.ts +1031 -0
- package/src/agent/context.ts +552 -552
- package/src/agent/index.ts +4 -3
- package/src/agent/plan.ts +161 -243
- package/src/agent/repair.ts +239 -162
- package/src/agent/sync.ts +197 -198
- package/src/agent/types.ts +242 -94
- package/src/agent/verify.ts +55 -55
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +15 -5
- package/src/auth/__tests__/reset.test.ts +2 -2
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +14 -2
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
- package/src/bundler/__tests__/build-runner.ts +166 -166
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +49 -20
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
- package/src/bundler/__tests__/generation.test.ts +447 -0
- package/src/bundler/__tests__/hdr.test.ts +24 -18
- package/src/bundler/__tests__/hmr-client.test.ts +62 -24
- package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
- package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +898 -915
- package/src/bundler/build.ts +113 -24
- package/src/bundler/client-boundary-transform.ts +977 -977
- package/src/bundler/css.ts +65 -44
- package/src/bundler/dev.ts +105 -73
- package/src/bundler/fast-refresh-preamble.ts +47 -47
- package/src/bundler/generation.ts +602 -0
- package/src/bundler/index.ts +3 -3
- package/src/bundler/manifest-schema.ts +55 -40
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +16 -15
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/prerender.ts +29 -9
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +1 -1
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +58 -56
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/props-serialization.test.ts +37 -37
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/island.ts +79 -79
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/props-serialization.ts +233 -233
- package/src/client/runtime-entry.ts +598 -598
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -50
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image-compat.ts +3 -0
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +1 -1
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +9 -9
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/generate-types.ts +1 -1
- package/src/content/index.ts +2 -2
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +9 -9
- package/src/db/index.ts +2 -2
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +82 -82
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/smoke.test.ts +2 -2
- package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
- package/src/desktop/__tests__/window.test.ts +20 -99
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/webview-fallback.ts +17 -4
- package/src/desktop/window.ts +17 -8
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/diagnose/__tests__/checks.test.ts +136 -136
- package/src/diagnose/checks.ts +185 -185
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +10 -10
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +5 -1
- package/src/filling/auth.ts +308 -308
- package/src/filling/body-parse.test.ts +60 -60
- package/src/filling/cookie-codec.ts +5 -3
- package/src/filling/deps.ts +265 -265
- package/src/filling/head-method.test.ts +154 -154
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.test.ts +48 -48
- package/src/generator/templates.ts +219 -219
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/check.ts +39 -24
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/index.ts +11 -11
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/__tests__/id.test.ts +3 -3
- package/src/id/index.ts +105 -105
- package/src/index.ts +9 -48
- package/src/internal/client-boundary.ts +266 -266
- package/src/internal/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -779
- package/src/kitchen/api/bundle-inspector.ts +179 -0
- package/src/kitchen/api/errors-grouping.ts +126 -126
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/kitchen/kitchen-handler.ts +36 -0
- package/src/kitchen/kitchen-ui.ts +456 -0
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/perf/user-marks.ts +1 -1
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +7 -7
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +227 -227
- package/src/router/client-entry.ts +218 -218
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.test.ts +532 -532
- package/src/router/fs-routes.ts +53 -53
- package/src/router/fs-scanner.ts +216 -216
- package/src/router/fs-types.ts +19 -19
- package/src/router/route-source-analyzer.ts +521 -521
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +517 -517
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/fast-refresh-types.ts +1 -1
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +30 -11
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/page-render-response.ts +287 -274
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/rate-limit.ts +1 -1
- package/src/runtime/redirect.ts +1 -1
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -10
- package/src/runtime/server.ts +127 -211
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +43 -17
- package/src/runtime/static-files.ts +369 -289
- package/src/runtime/streaming-ssr.ts +219 -180
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/__tests__/scheduler.test.ts +12 -11
- package/src/scheduler/index.ts +11 -5
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/spec/schema.ts +35 -35
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/index.ts +9 -3
- package/src/testing/lcov.ts +192 -0
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
package/src/content/sidebar.ts
CHANGED
|
@@ -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
|
+
}
|