handoff-app 1.2.2-7 → 2.0.0-0

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 (349) hide show
  1. package/Changelog.md +37 -15
  2. package/README.md +372 -118
  3. package/dist/app-builder/build.d.ts +41 -2
  4. package/dist/app-builder/build.js +455 -18
  5. package/dist/app-builder/client-config.d.ts +15 -2
  6. package/dist/app-builder/client-config.js +43 -4
  7. package/dist/app-builder/config-diff/snapshot.d.ts +1 -6
  8. package/dist/app-builder/config-diff/snapshot.js +2 -9
  9. package/dist/app-builder/index.d.ts +1 -1
  10. package/dist/app-builder/index.js +3 -1
  11. package/dist/app-builder/nav-shell.d.ts +23 -0
  12. package/dist/app-builder/nav-shell.js +80 -0
  13. package/dist/app-builder/static-export.d.ts +17 -0
  14. package/dist/app-builder/static-export.js +181 -0
  15. package/dist/app-builder/vercel-output.d.ts +39 -0
  16. package/dist/app-builder/vercel-output.js +259 -0
  17. package/dist/app-builder/watchers/utils.d.ts +2 -0
  18. package/dist/app-builder/watchers.d.ts +14 -4
  19. package/dist/app-builder/watchers.js +123 -28
  20. package/dist/app-builder/websocket.js +1 -1
  21. package/dist/artifacts/index.d.ts +2 -0
  22. package/dist/artifacts/index.js +13 -0
  23. package/dist/artifacts/types.d.ts +136 -0
  24. package/dist/artifacts/types.js +24 -0
  25. package/dist/artifacts/url.d.ts +49 -0
  26. package/dist/artifacts/url.js +86 -0
  27. package/dist/cache/build-cache.js +48 -4
  28. package/dist/cli.js +0 -0
  29. package/dist/commands/build/app.js +1 -2
  30. package/dist/commands/build/index.d.ts +17 -0
  31. package/dist/commands/build/index.js +66 -0
  32. package/dist/commands/checkout/index.d.ts +24 -0
  33. package/dist/commands/checkout/index.js +71 -0
  34. package/dist/commands/db/migrate.d.ts +11 -0
  35. package/dist/commands/db/migrate.js +33 -0
  36. package/dist/commands/index.js +8 -0
  37. package/dist/commands/publish/index.d.ts +21 -0
  38. package/dist/commands/publish/index.js +68 -0
  39. package/dist/config/defaults.js +15 -1
  40. package/dist/config/loader.js +6 -1
  41. package/dist/config/normalizers/declaration.js +4 -1
  42. package/dist/config/normalizers/page.d.ts +18 -0
  43. package/dist/config/normalizers/page.js +34 -0
  44. package/dist/config/normalizers/pattern.js +7 -1
  45. package/dist/config/runtime.d.ts +4 -0
  46. package/dist/config/runtime.js +53 -5
  47. package/dist/index.d.ts +65 -1
  48. package/dist/index.js +123 -3
  49. package/dist/nav/index.d.ts +83 -0
  50. package/dist/nav/index.js +9 -0
  51. package/dist/nav/registry.d.ts +15 -0
  52. package/dist/nav/registry.js +42 -0
  53. package/dist/nav/resolver.d.ts +8 -0
  54. package/dist/nav/resolver.js +192 -0
  55. package/dist/nav/workspace.d.ts +18 -0
  56. package/dist/nav/workspace.js +103 -0
  57. package/dist/pipeline/styles.js +7 -35
  58. package/dist/pipeline/token-transformers.d.ts +45 -0
  59. package/dist/pipeline/token-transformers.js +71 -0
  60. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +17 -0
  61. package/dist/registry/asset-storage/adapters/vercel-blob.js +87 -0
  62. package/dist/registry/asset-storage/define.d.ts +10 -0
  63. package/dist/registry/asset-storage/define.js +13 -0
  64. package/dist/registry/asset-storage/resolve.d.ts +45 -0
  65. package/dist/registry/asset-storage/resolve.js +36 -0
  66. package/dist/registry/asset-storage/types.d.ts +51 -0
  67. package/dist/registry/asset-storage/types.js +9 -0
  68. package/dist/registry/assets/layout.d.ts +22 -0
  69. package/dist/registry/assets/layout.js +30 -0
  70. package/dist/registry/assets/sets.d.ts +18 -0
  71. package/dist/registry/assets/sets.js +44 -0
  72. package/dist/registry/assets/transfer.d.ts +45 -0
  73. package/dist/registry/assets/transfer.js +11 -0
  74. package/dist/registry/checkout/assets.d.ts +16 -0
  75. package/dist/registry/checkout/assets.js +150 -0
  76. package/dist/registry/checkout/index.d.ts +33 -0
  77. package/dist/registry/checkout/index.js +610 -0
  78. package/dist/registry/checkout/tokens.d.ts +15 -0
  79. package/dist/registry/checkout/tokens.js +171 -0
  80. package/dist/registry/client.d.ts +61 -0
  81. package/dist/registry/client.js +211 -0
  82. package/dist/registry/connection.d.ts +30 -0
  83. package/dist/registry/connection.js +36 -0
  84. package/dist/registry/db/adapter.d.ts +43 -0
  85. package/dist/registry/db/adapter.js +59 -0
  86. package/dist/registry/db/client.d.ts +35 -0
  87. package/dist/registry/db/client.js +94 -0
  88. package/dist/registry/db/index.d.ts +5 -0
  89. package/dist/registry/db/index.js +24 -0
  90. package/dist/registry/db/migrate.d.ts +23 -0
  91. package/dist/registry/db/migrate.js +64 -0
  92. package/dist/registry/db/schema.d.ts +4869 -0
  93. package/dist/registry/db/schema.js +347 -0
  94. package/dist/registry/path.d.ts +4 -0
  95. package/dist/registry/path.js +31 -0
  96. package/dist/registry/publish/artifacts.d.ts +16 -0
  97. package/dist/registry/publish/artifacts.js +132 -0
  98. package/dist/registry/publish/assets.d.ts +16 -0
  99. package/dist/registry/publish/assets.js +129 -0
  100. package/dist/registry/publish/errors.d.ts +5 -0
  101. package/dist/registry/publish/errors.js +26 -0
  102. package/dist/registry/publish/index.d.ts +27 -0
  103. package/dist/registry/publish/index.js +126 -0
  104. package/dist/registry/publish/package.d.ts +27 -0
  105. package/dist/registry/publish/package.js +257 -0
  106. package/dist/registry/publish/publish-build.d.ts +12 -0
  107. package/dist/registry/publish/publish-build.js +43 -0
  108. package/dist/registry/publish/tokens.d.ts +18 -0
  109. package/dist/registry/publish/tokens.js +112 -0
  110. package/dist/registry/tokens/sets.d.ts +59 -0
  111. package/dist/registry/tokens/sets.js +109 -0
  112. package/dist/registry/tokens/transfer.d.ts +48 -0
  113. package/dist/registry/tokens/transfer.js +10 -0
  114. package/dist/registry/transfer.d.ts +75 -0
  115. package/dist/registry/transfer.js +11 -0
  116. package/dist/store/filesystem-assets.d.ts +17 -0
  117. package/dist/store/filesystem-assets.js +147 -0
  118. package/dist/store/filesystem.d.ts +76 -0
  119. package/dist/store/filesystem.js +208 -0
  120. package/dist/store/index.d.ts +20 -0
  121. package/dist/store/index.js +38 -0
  122. package/dist/store/registry.d.ts +77 -0
  123. package/dist/store/registry.js +350 -0
  124. package/dist/store/source-files.d.ts +24 -0
  125. package/dist/store/source-files.js +147 -0
  126. package/dist/store/types.d.ts +185 -0
  127. package/dist/store/types.js +12 -0
  128. package/dist/transformers/plugins/csf-render.js +17 -8
  129. package/dist/transformers/plugins/handlebars-previews.js +10 -3
  130. package/dist/transformers/plugins/ssr-render.js +116 -49
  131. package/dist/transformers/preview/component/api.js +3 -1
  132. package/dist/transformers/preview/component/artifacts.js +8 -1
  133. package/dist/transformers/preview/component/builder.js +13 -5
  134. package/dist/transformers/preview/component/css.js +9 -1
  135. package/dist/transformers/preview/component/javascript.d.ts +9 -3
  136. package/dist/transformers/preview/component/javascript.js +45 -17
  137. package/dist/transformers/preview/component/shared-artifacts.d.ts +83 -0
  138. package/dist/transformers/preview/component/shared-artifacts.js +92 -0
  139. package/dist/transformers/preview/component.js +5 -2
  140. package/dist/transformers/preview/pattern/builder.js +28 -9
  141. package/dist/transformers/preview/pattern/html.d.ts +13 -10
  142. package/dist/transformers/preview/pattern/html.js +62 -27
  143. package/dist/transformers/preview/types.d.ts +38 -0
  144. package/dist/transformers/utils/handlebars.d.ts +3 -0
  145. package/dist/transformers/utils/handlebars.js +19 -5
  146. package/dist/types/config.d.ts +118 -25
  147. package/dist/utils/menu-shell.d.ts +60 -0
  148. package/dist/utils/menu-shell.js +221 -0
  149. package/dist/utils/stable-stringify.d.ts +2 -0
  150. package/dist/utils/stable-stringify.js +8 -0
  151. package/docs/cli.md +4 -2
  152. package/drizzle/0000_init_registry_storage.sql +95 -0
  153. package/drizzle/0001_add_review_metadata.sql +2 -0
  154. package/drizzle/0002_add_pages.sql +28 -0
  155. package/drizzle/0003_add_tokens.sql +29 -0
  156. package/drizzle/0004_add_assets.sql +37 -0
  157. package/drizzle/meta/0000_snapshot.json +642 -0
  158. package/drizzle/meta/0001_snapshot.json +654 -0
  159. package/drizzle/meta/0002_snapshot.json +835 -0
  160. package/drizzle/meta/0003_snapshot.json +1023 -0
  161. package/drizzle/meta/0004_snapshot.json +1252 -0
  162. package/drizzle/meta/_journal.json +41 -0
  163. package/drizzle.config.ts +16 -0
  164. package/package.json +10 -1
  165. package/src/app/components/Component/Preview.tsx +3 -2
  166. package/src/app/components/Component/RegistryActions.tsx +150 -0
  167. package/src/app/components/Foundations/DisplayLogo.tsx +5 -3
  168. package/src/app/components/Layout/Header.tsx +21 -3
  169. package/src/app/components/Layout/Main.tsx +11 -3
  170. package/src/app/components/Navigation/MainNav.tsx +4 -2
  171. package/src/app/components/Navigation/MobileNav.tsx +4 -2
  172. package/src/app/components/Navigation/SideNav.tsx +46 -11
  173. package/src/app/components/NotFound.tsx +24 -0
  174. package/src/app/components/context/NavProvider.tsx +71 -0
  175. package/src/app/components/old/Header.tsx +3 -3
  176. package/src/app/components/util/index.ts +203 -380
  177. package/src/app/components/util/useCollectionAssets.ts +125 -0
  178. package/src/app/components/util/useFoundationTokens.ts +67 -0
  179. package/src/app/generated/default-pages.json +1 -0
  180. package/src/app/generated/nav-shell.json +1 -0
  181. package/src/app/lib/api/query.ts +6 -0
  182. package/src/app/lib/api/redact.ts +46 -0
  183. package/src/app/lib/asset-storage.ts +81 -0
  184. package/src/app/lib/docs-api/artifacts.ts +116 -0
  185. package/src/app/lib/docs-api/backend.ts +151 -0
  186. package/src/app/lib/docs-api/errors.ts +52 -0
  187. package/src/app/lib/docs-api/handler.ts +34 -0
  188. package/src/app/lib/docs-api/index.ts +7 -0
  189. package/src/app/lib/docs-api/records.ts +189 -0
  190. package/src/app/lib/docs-api/registry.ts +130 -0
  191. package/src/app/lib/docs-api/runtime-config.ts +173 -0
  192. package/src/app/lib/docs-api/serve.ts +37 -0
  193. package/src/app/lib/docs-api/token-detail.ts +19 -0
  194. package/src/app/lib/registry-api/allowlist.ts +163 -0
  195. package/src/app/lib/registry-api/asset-transfer.ts +363 -0
  196. package/src/app/lib/registry-api/auth.ts +68 -0
  197. package/src/app/lib/registry-api/entity-routes.ts +197 -0
  198. package/src/app/lib/registry-api/errors.ts +51 -0
  199. package/src/app/lib/registry-api/files.ts +135 -0
  200. package/src/app/lib/registry-api/handler.ts +96 -0
  201. package/src/app/lib/registry-api/index.ts +4 -0
  202. package/src/app/lib/registry-api/meta.ts +87 -0
  203. package/src/app/lib/registry-api/revalidate.ts +36 -0
  204. package/src/app/lib/registry-api/store.ts +348 -0
  205. package/src/app/lib/registry-api/token-transfer.ts +231 -0
  206. package/src/app/lib/registry-api/transfer.ts +513 -0
  207. package/src/app/lib/registry-api/validation.ts +74 -0
  208. package/src/app/lib/registry-connection.ts +58 -0
  209. package/src/app/lib/utils.ts +14 -0
  210. package/src/app/next.config.mjs +72 -13
  211. package/src/app/pages/[...slug]/index.tsx +74 -20
  212. package/src/app/pages/_app.tsx +5 -2
  213. package/src/app/pages/api/docs/artifacts/[...artifactPath].ts +16 -0
  214. package/src/app/pages/api/docs/assets/[collection]/[...path].ts +50 -0
  215. package/src/app/pages/api/docs/assets/[collection]/index.ts +22 -0
  216. package/src/app/pages/api/docs/components/[id]/assets/[asset].ts +33 -0
  217. package/src/app/pages/api/docs/components/[id]/inspect/[previewId].ts +20 -0
  218. package/src/app/pages/api/docs/components/[id]/previews/[previewId].ts +21 -0
  219. package/src/app/pages/api/docs/components/[id].ts +24 -0
  220. package/src/app/pages/api/docs/components.json.ts +12 -0
  221. package/src/app/pages/api/docs/nav.json.ts +48 -0
  222. package/src/app/pages/api/docs/patterns/[id]/preview.ts +19 -0
  223. package/src/app/pages/api/docs/patterns/[id].ts +24 -0
  224. package/src/app/pages/api/docs/patterns.json.ts +12 -0
  225. package/src/app/pages/api/docs/tokens/components/[id].ts +30 -0
  226. package/src/app/pages/api/docs/tokens/foundations/[type].ts +32 -0
  227. package/src/app/pages/api/registry/components/[id]/files/[...filePath].ts +19 -0
  228. package/src/app/pages/api/registry/components/[id]/files/index.ts +19 -0
  229. package/src/app/pages/api/registry/components/[id].ts +20 -0
  230. package/src/app/pages/api/registry/components/index.ts +19 -0
  231. package/src/app/pages/api/registry/health.ts +20 -0
  232. package/src/app/pages/api/registry/patterns/[id]/files/[...filePath].ts +19 -0
  233. package/src/app/pages/api/registry/patterns/[id]/files/index.ts +19 -0
  234. package/src/app/pages/api/registry/patterns/[id].ts +20 -0
  235. package/src/app/pages/api/registry/patterns/index.ts +19 -0
  236. package/src/app/pages/api/registry/transfer/[kind]/[id].ts +39 -0
  237. package/src/app/pages/api/registry/transfer/assets/[collection].ts +24 -0
  238. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +23 -0
  239. package/src/app/pages/api/registry/transfer/assets/blobs/have.ts +11 -0
  240. package/src/app/pages/api/registry/transfer/assets/index.ts +10 -0
  241. package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +26 -0
  242. package/src/app/pages/api/registry/transfer/tokens/index.ts +11 -0
  243. package/src/app/pages/assets/fonts.tsx +41 -17
  244. package/src/app/pages/assets/index.tsx +2 -2
  245. package/src/app/pages/assets/logos.tsx +2 -2
  246. package/src/app/pages/foundations/colors.tsx +18 -8
  247. package/src/app/pages/foundations/effects.tsx +18 -8
  248. package/src/app/pages/foundations/icons/[name]/index.tsx +26 -14
  249. package/src/app/pages/foundations/icons/index.tsx +13 -9
  250. package/src/app/pages/foundations/index.tsx +1 -1
  251. package/src/app/pages/foundations/logo.tsx +7 -4
  252. package/src/app/pages/foundations/typography.tsx +19 -9
  253. package/src/app/pages/index.tsx +1 -1
  254. package/src/app/pages/system/component/[component]/index.tsx +87 -17
  255. package/src/app/pages/system/index.tsx +13 -4
  256. package/src/app/pages/system/pattern/[pattern]/index.tsx +70 -14
  257. package/src/app/pages/system/tokens/components/[component]/index.tsx +42 -14
  258. package/src/app/pages/system/tokens/foundations/colors.tsx +7 -5
  259. package/src/app/pages/system/tokens/foundations/effects.tsx +7 -5
  260. package/src/app/pages/system/tokens/foundations/typography.tsx +8 -4
  261. package/src/app/tsconfig.json +2 -3
  262. package/src/app-builder/build.ts +555 -11
  263. package/src/app-builder/client-config.ts +61 -4
  264. package/src/app-builder/config-diff/snapshot.ts +1 -11
  265. package/src/app-builder/index.ts +1 -1
  266. package/src/app-builder/nav-shell.ts +74 -0
  267. package/src/app-builder/static-export.ts +215 -0
  268. package/src/app-builder/vercel-output.ts +297 -0
  269. package/src/app-builder/watchers/utils.ts +2 -0
  270. package/src/app-builder/watchers.ts +149 -25
  271. package/src/app-builder/websocket.ts +2 -2
  272. package/src/artifacts/README.md +14 -0
  273. package/src/artifacts/index.ts +17 -0
  274. package/src/artifacts/types.ts +146 -0
  275. package/src/artifacts/url.ts +90 -0
  276. package/src/cache/build-cache.ts +47 -2
  277. package/src/commands/build/app.ts +1 -2
  278. package/src/commands/build/index.ts +61 -0
  279. package/src/commands/checkout/index.ts +66 -0
  280. package/src/commands/db/migrate.ts +25 -0
  281. package/src/commands/index.ts +8 -0
  282. package/src/commands/publish/index.ts +63 -0
  283. package/src/config/defaults.ts +15 -0
  284. package/src/config/loader.ts +10 -1
  285. package/src/config/normalizers/declaration.ts +3 -0
  286. package/src/config/normalizers/page.ts +45 -0
  287. package/src/config/normalizers/pattern.ts +4 -1
  288. package/src/config/runtime.ts +48 -2
  289. package/src/index.ts +126 -3
  290. package/src/nav/index.ts +94 -0
  291. package/src/nav/registry.ts +54 -0
  292. package/src/nav/resolver.ts +217 -0
  293. package/src/nav/workspace.ts +87 -0
  294. package/src/pipeline/styles.ts +4 -35
  295. package/src/pipeline/token-transformers.ts +97 -0
  296. package/src/registry/asset-storage/adapters/vercel-blob.ts +51 -0
  297. package/src/registry/asset-storage/define.ts +15 -0
  298. package/src/registry/asset-storage/resolve.ts +72 -0
  299. package/src/registry/asset-storage/types.ts +46 -0
  300. package/src/registry/assets/layout.ts +38 -0
  301. package/src/registry/assets/sets.ts +48 -0
  302. package/src/registry/assets/transfer.ts +50 -0
  303. package/src/registry/checkout/assets.ts +126 -0
  304. package/src/registry/checkout/index.ts +596 -0
  305. package/src/registry/checkout/tokens.ts +152 -0
  306. package/src/registry/client.ts +234 -0
  307. package/src/registry/connection.ts +50 -0
  308. package/src/registry/db/adapter.ts +74 -0
  309. package/src/registry/db/client.ts +88 -0
  310. package/src/registry/db/index.ts +35 -0
  311. package/src/registry/db/migrate.ts +56 -0
  312. package/src/registry/db/schema.ts +444 -0
  313. package/src/registry/path.ts +26 -0
  314. package/src/registry/publish/artifacts.ts +147 -0
  315. package/src/registry/publish/assets.ts +129 -0
  316. package/src/registry/publish/errors.ts +34 -0
  317. package/src/registry/publish/index.ts +128 -0
  318. package/src/registry/publish/package.ts +272 -0
  319. package/src/registry/publish/publish-build.ts +51 -0
  320. package/src/registry/publish/tokens.ts +119 -0
  321. package/src/registry/tokens/sets.ts +132 -0
  322. package/src/registry/tokens/transfer.ts +53 -0
  323. package/src/registry/transfer.ts +82 -0
  324. package/src/store/filesystem-assets.ts +152 -0
  325. package/src/store/filesystem.ts +237 -0
  326. package/src/store/index.ts +62 -0
  327. package/src/store/registry.ts +334 -0
  328. package/src/store/source-files.ts +155 -0
  329. package/src/store/types.ts +210 -0
  330. package/src/transformers/plugins/csf-render.ts +31 -7
  331. package/src/transformers/plugins/handlebars-previews.ts +22 -3
  332. package/src/transformers/plugins/ssr-render.ts +145 -57
  333. package/src/transformers/preview/component/api.ts +2 -1
  334. package/src/transformers/preview/component/artifacts.ts +10 -1
  335. package/src/transformers/preview/component/builder.ts +9 -2
  336. package/src/transformers/preview/component/css.ts +10 -1
  337. package/src/transformers/preview/component/javascript.ts +55 -20
  338. package/src/transformers/preview/component/shared-artifacts.ts +146 -0
  339. package/src/transformers/preview/component.ts +5 -2
  340. package/src/transformers/preview/pattern/builder.ts +33 -7
  341. package/src/transformers/preview/pattern/html.ts +71 -27
  342. package/src/transformers/preview/types.ts +42 -0
  343. package/src/transformers/utils/handlebars.ts +31 -5
  344. package/src/types/config.ts +123 -25
  345. package/src/utils/menu-shell.ts +241 -0
  346. package/src/utils/stable-stringify.ts +7 -0
  347. package/templates/default/package.json.tpl +1 -1
  348. package/handoff.config.js +0 -90
  349. package/src/app/components/ComponentSearch.tsx +0 -93
@@ -0,0 +1,45 @@
1
+ import { PageListObject } from '../../transformers/preview/types';
2
+
3
+ type RawPageFrontmatter = Record<string, any>;
4
+
5
+ type NormalizePageOptions = {
6
+ /** The page id — its slug path (e.g. `guides/setup`), derived from the file location. */
7
+ id: string;
8
+ /** Logical route the page is served at (e.g. `/guides/setup`). */
9
+ routePath: string;
10
+ /** Absolute filesystem path of the source `.md` (workspace-only, stripped on publish). */
11
+ sourcePath: string;
12
+ };
13
+
14
+ /** Humanize a slug segment (`getting-started` → `Getting Started`) for a default title. */
15
+ const humanize = (segment: string): string =>
16
+ segment
17
+ .replace(/[-_]/g, ' ')
18
+ .replace(/\b\w/g, (c) => c.toUpperCase())
19
+ .trim();
20
+
21
+ /**
22
+ * Normalize a page's parsed markdown frontmatter into a {@link PageListObject}. Pages have no
23
+ * required authored fields (a bare `.md` is valid); the title falls back to a humanized last slug
24
+ * segment. The id is always the slug path (a page is addressed by its URL, so id ≡ route): the file
25
+ * location is the single source of identity for both filesystem and registry serving.
26
+ */
27
+ export const normalizePageDeclaration = (raw: RawPageFrontmatter, options: NormalizePageOptions): PageListObject => {
28
+ const lastSegment = options.id.split('/').pop() ?? options.id;
29
+
30
+ return {
31
+ id: options.id,
32
+ path: options.routePath,
33
+ sourcePath: options.sourcePath,
34
+ title: typeof raw.title === 'string' && raw.title.trim() ? raw.title : humanize(lastSegment),
35
+ description: typeof raw.description === 'string' ? raw.description : undefined,
36
+ group: typeof raw.group === 'string' ? raw.group : undefined,
37
+ weight: typeof raw.weight === 'number' ? raw.weight : undefined,
38
+ menuTitle: typeof raw.menuTitle === 'string' ? raw.menuTitle : undefined,
39
+ metaTitle: typeof raw.metaTitle === 'string' ? raw.metaTitle : undefined,
40
+ metaDescription: typeof raw.metaDescription === 'string' ? raw.metaDescription : undefined,
41
+ enabled: typeof raw.enabled === 'boolean' ? raw.enabled : undefined,
42
+ external: typeof raw.external === 'string' || typeof raw.external === 'boolean' ? raw.external : undefined,
43
+ menu: raw.menu && typeof raw.menu === 'object' ? raw.menu : undefined,
44
+ };
45
+ };
@@ -1,3 +1,4 @@
1
+ import path from 'path';
1
2
  import { PatternListObject } from '../../transformers/preview/types';
2
3
 
3
4
  type RawPatternDeclaration = Record<string, any>;
@@ -51,6 +52,8 @@ export const normalizePatternDeclaration = (
51
52
  group: typeof raw.group === 'string' ? raw.group : undefined,
52
53
  tags: Array.isArray(raw.tags) ? raw.tags.filter((t: any) => typeof t === 'string') : undefined,
53
54
  components,
54
- path: '',
55
+ // Source location of the pattern (declaration directory), retained so the store can expose a
56
+ // stable source reference for checkout/publish.
57
+ path: path.dirname(options.declarationPath),
55
58
  };
56
59
  };
@@ -1,5 +1,6 @@
1
1
  import esbuild from 'esbuild';
2
2
  import fs from 'fs-extra';
3
+ import matter from 'gray-matter';
3
4
  import { createRequire } from 'module';
4
5
  import path from 'path';
5
6
  import { ComponentListObject, PatternListObject } from '../transformers/preview/types';
@@ -9,6 +10,7 @@ import { Config, ConfigFileEntry, RuntimeConfig } from '../types/config';
9
10
  import { Logger } from '../utils/logger';
10
11
  import { normalizePathForCompare } from '../utils/path';
11
12
  import { normalizeComponentDeclaration } from './normalizers/declaration';
13
+ import { normalizePageDeclaration } from './normalizers/page';
12
14
  import { normalizePatternDeclaration } from './normalizers/pattern';
13
15
 
14
16
  /**
@@ -56,7 +58,7 @@ const findPreferredModernDeclaration = (componentDir: string, componentBaseName:
56
58
  return modernFiles[0];
57
59
  };
58
60
 
59
- const resolveComponentDeclaration = (componentDir: string, componentBaseName: string): DeclarationResolution | null => {
61
+ export const resolveComponentDeclaration = (componentDir: string, componentBaseName: string): DeclarationResolution | null => {
60
62
  const modernMatch = findPreferredModernDeclaration(componentDir, componentBaseName);
61
63
  const legacyFiles = getLegacyDeclarationFiles(componentBaseName);
62
64
  const legacyMatch = legacyFiles.find((candidate) => fs.existsSync(path.resolve(componentDir, candidate)));
@@ -171,6 +173,7 @@ export const initRuntimeConfig = (handoff: HandoffContext): [runtimeConfig: Runt
171
173
  js: undefined,
172
174
  components: {},
173
175
  patterns: {},
176
+ pages: {},
174
177
  },
175
178
  };
176
179
 
@@ -284,7 +287,12 @@ export const initRuntimeConfig = (handoff: HandoffContext): [runtimeConfig: Runt
284
287
  fallbackId: patternBaseName,
285
288
  });
286
289
  } catch (err) {
287
- Logger.error(`Failed to read or parse pattern config: ${resolvedPatternConfigPath}`, err);
290
+ // Treat as a warning rather than an error: the file may be empty or
291
+ // mid-edit. It is already tracked in configFiles above, so
292
+ // watchRuntimeConfiguration will pick it up once the file is saved
293
+ // with valid content.
294
+ Logger.warn(`Pattern config skipped (incomplete or invalid) — will retry on next save: ${resolvedPatternConfigPath}`);
295
+ Logger.debug(`Pattern parse detail:`, err);
288
296
  continue;
289
297
  }
290
298
 
@@ -293,6 +301,26 @@ export const initRuntimeConfig = (handoff: HandoffContext): [runtimeConfig: Runt
293
301
  }
294
302
  }
295
303
 
304
+ // -------------------------------------------------------------------------
305
+ // Discover markdown pages
306
+ // -------------------------------------------------------------------------
307
+ // Working pages under `<workingPath>/pages/` are first-class registry entities (publish/checkout).
308
+ // Discovery mirrors the catch-all/static-path walk: every `.md` except `index.md` becomes a page
309
+ // keyed by its slug path. Package `config/docs` defaults are NOT entities (they ship with the tool).
310
+ const pagesRoot = path.resolve(handoff.workingPath, 'pages');
311
+ for (const segments of collectPageMarkdownPaths(pagesRoot)) {
312
+ const slug = segments.join('/');
313
+ const sourcePath = path.resolve(pagesRoot, `${slug}.md`);
314
+ try {
315
+ const { data: frontmatter } = matter(fs.readFileSync(sourcePath, 'utf-8'));
316
+ const page = normalizePageDeclaration(frontmatter, { id: slug, routePath: `/${slug}`, sourcePath });
317
+ result.entries.pages[page.id] = page;
318
+ } catch (err) {
319
+ Logger.warn(`Page skipped (unreadable or invalid frontmatter): ${sourcePath}`);
320
+ Logger.debug(`Page parse detail:`, err);
321
+ }
322
+ }
323
+
296
324
  // -------------------------------------------------------------------------
297
325
  // Inject synthetic previews from patterns onto components
298
326
  // -------------------------------------------------------------------------
@@ -301,6 +329,24 @@ export const initRuntimeConfig = (handoff: HandoffContext): [runtimeConfig: Runt
301
329
  return [result, Array.from(configFiles), configFileIndex];
302
330
  };
303
331
 
332
+ /**
333
+ * Recursively collect `.md` files under a root, returning slug segments (relative path without the
334
+ * `.md`). `index.md` is excluded — section index content is served by dedicated/home routes.
335
+ */
336
+ const collectPageMarkdownPaths = (rootDir: string, relativeParts: string[] = []): string[][] => {
337
+ if (!fs.existsSync(rootDir)) return [];
338
+ const results: string[][] = [];
339
+ for (const entry of fs.readdirSync(rootDir)) {
340
+ const fullPath = path.join(rootDir, entry);
341
+ if (fs.lstatSync(fullPath).isDirectory()) {
342
+ results.push(...collectPageMarkdownPaths(fullPath, [...relativeParts, entry]));
343
+ } else if (entry.endsWith('.md') && entry !== 'index.md') {
344
+ results.push([...relativeParts, entry.replace(/\.md$/, '')]);
345
+ }
346
+ }
347
+ return results;
348
+ };
349
+
304
350
  /**
305
351
  * Returns a list of component directories for a given path.
306
352
  *
package/src/index.ts CHANGED
@@ -2,11 +2,12 @@ import 'dotenv/config';
2
2
  import fs from 'fs-extra';
3
3
  import { Types as CoreTypes, Handoff as HandoffRunner, Providers } from 'handoff-core';
4
4
  import path from 'path';
5
- import buildApp, { devApp, watchApp } from './app-builder';
5
+ import buildApp, { devApp, watchApp, type BuildPackage, type BuildTarget } from './app-builder';
6
6
  import { ejectConfig, ejectPages, ejectTheme } from './cli/eject';
7
7
  import { makeComponent, makePage, makeTemplate } from './cli/make';
8
8
  import { initConfigWithMetadata, initRuntimeConfig, validateConfig } from './config';
9
9
  import pipeline, { buildComponents, buildPatterns } from './pipeline';
10
+ import { createFilesystemStore, type HandoffStore } from './store';
10
11
  import processComponents, { ComponentSegment } from './transformers/preview/component/builder';
11
12
  import { Config, ConfigFileEntry, RuntimeConfig } from './types/config';
12
13
  import { Logger } from './utils/logger';
@@ -28,6 +29,19 @@ class Handoff {
28
29
  typography: {};
29
30
  };
30
31
 
32
+ private _store?: HandoffStore;
33
+
34
+ /**
35
+ * Normalized store over the current workspace runtime for components, patterns, pages, tokens,
36
+ * and assets. It is created lazily and reads live from `runtimeConfig`.
37
+ */
38
+ get store(): HandoffStore {
39
+ if (!this._store) {
40
+ this._store = createFilesystemStore(this);
41
+ }
42
+ return this._store;
43
+ }
44
+
31
45
  private _initialArgs: { debug?: boolean; force?: boolean; config?: Partial<Config> } = {};
32
46
  private _configFilePaths: string[] = [];
33
47
  private _configFileIndex: Map<string, ConfigFileEntry> = new Map();
@@ -103,9 +117,101 @@ class Handoff {
103
117
  return this;
104
118
  }
105
119
 
106
- async build(skipComponents?: boolean): Promise<Handoff> {
120
+ async build(target: BuildTarget = 'static', skipComponents?: boolean, buildPackage?: BuildPackage): Promise<Handoff> {
121
+ this.preRunner();
122
+ await buildApp(this, target, skipComponents, buildPackage);
123
+ return this;
124
+ }
125
+
126
+ /**
127
+ * Runs the package-owned registry database migrations against the configured PostgreSQL/Neon
128
+ * database. Independent of {@link build} — it only reads config + DB env vars and applies the
129
+ * bundled migration set.
130
+ */
131
+ async dbMigrate(): Promise<Handoff> {
132
+ if (!this.config) {
133
+ throw Error('Handoff not initialized');
134
+ }
135
+ const { runRegistryMigrations } = await import('./registry/db/migrate');
136
+ await runRegistryMigrations(this);
137
+ return this;
138
+ }
139
+
140
+ /**
141
+ * Publish a single component, pattern, or page from this connected workspace to the configured
142
+ * remote registry. Components and patterns receive a fresh targeted build before upload. Pages
143
+ * upload their record and markdown source because they render at runtime. The publish module is
144
+ * loaded lazily so the registry client and build code never enter the docs app bundle.
145
+ */
146
+ async publish(kind: 'component' | 'pattern' | 'page', id: string): Promise<Handoff> {
147
+ this.preRunner();
148
+ const { publishEntity } = await import('./registry/publish');
149
+ await publishEntity(this, kind, id);
150
+ return this;
151
+ }
152
+
153
+ /**
154
+ * Checkout a single component, pattern, or page from the connected remote registry. The checkout
155
+ * module is loaded lazily so the registry client never enters the docs app bundle.
156
+ */
157
+ async checkout(kind: 'component' | 'pattern' | 'page', id: string): Promise<Handoff> {
107
158
  this.preRunner();
108
- await buildApp(this, skipComponents);
159
+ const { checkoutEntity } = await import('./registry/checkout');
160
+ await checkoutEntity(this, kind, id);
161
+ return this;
162
+ }
163
+
164
+ /**
165
+ * Publish design token sets from this connected workspace to the configured remote registry. Runs a
166
+ * fresh token build (Figma extract + style transformers), discovers the logical sets, and uploads
167
+ * each changed set (its extracted record + generated artifacts). Publishes every set when `setId` is
168
+ * omitted, or only the named set (`foundation/colors`, `component/<id>`). Loaded lazily so the
169
+ * registry client/build code never enters the docs app bundle.
170
+ */
171
+ async publishTokens(setId?: string): Promise<Handoff> {
172
+ this.preRunner();
173
+ const { publishTokens } = await import('./registry/publish/tokens');
174
+ await publishTokens(this, setId);
175
+ return this;
176
+ }
177
+
178
+ /**
179
+ * Checkout design token sets from the connected remote registry into this workspace: reconstruct
180
+ * the canonical local `tokens.json` and restore the generated token files to their configured output
181
+ * paths. Checks out every published set when `setId` is omitted, or only the named set. Loaded lazily
182
+ * so the registry client never enters the docs app bundle.
183
+ */
184
+ async checkoutTokens(setId?: string): Promise<Handoff> {
185
+ this.preRunner();
186
+ const { checkoutTokens } = await import('./registry/checkout/tokens');
187
+ await checkoutTokens(this, setId);
188
+ return this;
189
+ }
190
+
191
+ /**
192
+ * Publish asset collections (icons/logos/fonts) from this connected workspace to the configured
193
+ * remote registry. Runs a fresh build (Figma extract + asset generation), discovers the collections,
194
+ * uploads only content hashes the registry lacks, and finalizes each collection manifest atomically.
195
+ * Publishes every collection when `collection` is omitted, or only the named one. Loaded lazily so
196
+ * the registry client/build code never enters the docs app bundle.
197
+ */
198
+ async publishAssets(collection?: string): Promise<Handoff> {
199
+ this.preRunner();
200
+ const { publishAssets } = await import('./registry/publish/assets');
201
+ await publishAssets(this, collection);
202
+ return this;
203
+ }
204
+
205
+ /**
206
+ * Checkout asset collections from the connected remote registry into this workspace: recreate the
207
+ * standard workspace asset files, collection JSON, icon sprite/manifest, and downloadable archives.
208
+ * Checks out every published collection when `collection` is omitted, or only the named one. Loaded
209
+ * lazily so the registry client never enters the docs app bundle.
210
+ */
211
+ async checkoutAssets(collection?: string): Promise<Handoff> {
212
+ this.preRunner();
213
+ const { checkoutAssets } = await import('./registry/checkout/assets');
214
+ await checkoutAssets(this, collection);
109
215
  return this;
110
216
  }
111
217
 
@@ -261,6 +367,15 @@ class Handoff {
261
367
  return path.join(this.getOutputPath(), 'tokens');
262
368
  }
263
369
 
370
+ /**
371
+ * Gets the generated docs API root (`<workingPath>/public/api`) — the tree holding the asset
372
+ * collection JSON, per-asset icon/logo bodies, and the icon sprite + manifest.
373
+ * @returns {string} The absolute path to the public docs API directory
374
+ */
375
+ getAssetsApiPath(): string {
376
+ return path.resolve(this.workingPath, 'public', 'api');
377
+ }
378
+
264
379
  /**
265
380
  * Gets the path to the icons.zip file
266
381
  * @returns {string} The absolute path to the icons.zip file
@@ -329,6 +444,14 @@ export {
329
444
  definePattern,
330
445
  defineReactComponent,
331
446
  } from './declarations';
447
+ export { defineAssetStorage } from './registry/asset-storage/define';
448
+ export type {
449
+ AssetStorage,
450
+ AssetStorageContext,
451
+ AssetStorageFactory,
452
+ AssetStorageInput,
453
+ AssetStorageReadResult,
454
+ } from './registry/asset-storage/types';
332
455
  export type {
333
456
  CsfDeclarationConfig,
334
457
  DeclarationPreview,
@@ -0,0 +1,94 @@
1
+ /** A mode-aware placeholder retained in the serialized navigation shell. */
2
+ export interface NavDynamicSlot {
3
+ kind: 'components' | 'patterns' | 'token-components';
4
+ type?: string;
5
+ }
6
+
7
+ /** A link nested within a navigation subsection. */
8
+ export interface NavMenuItem {
9
+ title: string;
10
+ path?: string;
11
+ image?: string;
12
+ menu?: NavMenuItem[];
13
+ }
14
+
15
+ /** A group or direct link beneath a top-level navigation section. */
16
+ export interface NavSubSection {
17
+ title: string;
18
+ path?: string;
19
+ image?: string;
20
+ menu?: NavMenuItem[];
21
+ dynamic?: NavDynamicSlot;
22
+ }
23
+
24
+ /** The canonical top-level navigation section shared by the build pipeline and docs app. */
25
+ export interface SectionLink {
26
+ title: string;
27
+ weight: number;
28
+ external?: string | boolean;
29
+ path: string;
30
+ subSections: NavSubSection[];
31
+ }
32
+
33
+ /** Minimal navigation entity served by the docs navigation endpoint. */
34
+ export interface NavEntity {
35
+ id: string;
36
+ title?: string;
37
+ group?: string;
38
+ type?: string;
39
+ }
40
+
41
+ /** A navigation token-set record. Only component sets are rendered in the component-token slot. */
42
+ export interface NavTokenSet extends NavEntity {
43
+ kind: 'foundation' | 'component';
44
+ }
45
+
46
+ /** A declarative submenu entry carried by a page record. */
47
+ export interface NavPageMenuDeclaration {
48
+ title?: string;
49
+ enabled?: boolean;
50
+ components?: boolean | string;
51
+ patterns?: boolean;
52
+ tokens?: boolean;
53
+ path?: string;
54
+ image?: string;
55
+ menu?: NavMenuItem[];
56
+ }
57
+
58
+ /** The page fields needed to compose page-authored navigation sections. */
59
+ export interface NavPageRecord {
60
+ id: string;
61
+ path?: string;
62
+ title?: string;
63
+ menuTitle?: string;
64
+ weight?: number;
65
+ enabled?: boolean;
66
+ external?: string | boolean;
67
+ menu?: Record<string, NavPageMenuDeclaration> | NavPageMenuDeclaration[];
68
+ }
69
+
70
+ /** Eager, already-resolved inputs to the pure navigation resolver. */
71
+ export interface NavSources {
72
+ shell: SectionLink[];
73
+ components: NavEntity[];
74
+ patterns: NavEntity[];
75
+ pages: NavPageRecord[];
76
+ tokenSets: NavTokenSet[];
77
+ tokenFoundations: NavMenuItem[];
78
+ basePath?: string;
79
+ }
80
+
81
+ export type NavMode = 'workspace' | 'registry';
82
+ export type NavLoad = 'initial' | 'refresh';
83
+
84
+ /** The render-ready navigation payload. */
85
+ export interface NavData {
86
+ shell: SectionLink[];
87
+ }
88
+
89
+ export { getNavData } from './resolver';
90
+ export type { GetNavDataInput } from './resolver';
91
+ export { getWorkspaceNavData } from './workspace';
92
+ export type { WorkspaceNavAdapterOptions, WorkspaceNavLoaders } from './workspace';
93
+ export { getRegistryNavData } from './registry';
94
+ export type { RegistryNavAdapterOptions, RegistryNavRecords } from './registry';
@@ -0,0 +1,54 @@
1
+ import { buildTokensFoundationsMenu } from '../utils/menu-shell';
2
+ import type { NavData, NavEntity, NavLoad, NavPageRecord, NavTokenSet, SectionLink } from '.';
3
+ import { getNavData } from './resolver';
4
+
5
+ export interface RegistryNavRecords {
6
+ components: NavEntity[];
7
+ patterns: NavEntity[];
8
+ pages: NavPageRecord[];
9
+ tokenSets: NavTokenSet[];
10
+ }
11
+
12
+ export interface RegistryNavAdapterOptions {
13
+ shell: SectionLink[];
14
+ load: NavLoad;
15
+ basePath?: string;
16
+ fetchRecords: () => Promise<RegistryNavRecords>;
17
+ }
18
+
19
+ const emptyRecords = (): RegistryNavRecords => ({ components: [], patterns: [], pages: [], tokenSets: [] });
20
+
21
+ /** Registry adapter: shell-only initial load, or one best-effort runtime-record fetch on refresh. */
22
+ export const getRegistryNavData = async (options: RegistryNavAdapterOptions): Promise<NavData> => {
23
+ let records = emptyRecords();
24
+ if (options.load === 'refresh') {
25
+ try {
26
+ records = (await options.fetchRecords()) ?? records;
27
+ } catch {
28
+ // Fall back to the baked navigation shell when the registry backend is unavailable.
29
+ }
30
+ }
31
+ const componentById = new Map(records.components.map((component) => [component.id, component]));
32
+ const tokenSets = records.tokenSets.map((set) => {
33
+ const id = set.id.startsWith('component/') ? set.id.slice('component/'.length) : set.id;
34
+ const component = componentById.get(id);
35
+ return {
36
+ ...set,
37
+ title: set.title || component?.title,
38
+ group: set.group ?? component?.group,
39
+ };
40
+ });
41
+ const normalizedBasePath = options.basePath ? `${options.basePath.replace(/^\/+|\/+$/g, '')}/` : '';
42
+
43
+ return getNavData({
44
+ mode: 'registry',
45
+ load: options.load,
46
+ sources: {
47
+ shell: options.shell,
48
+ ...records,
49
+ tokenSets,
50
+ tokenFoundations: buildTokensFoundationsMenu(normalizedBasePath),
51
+ basePath: options.basePath,
52
+ },
53
+ });
54
+ };
@@ -0,0 +1,217 @@
1
+ import { startCase } from 'lodash';
2
+ import type {
3
+ NavData,
4
+ NavEntity,
5
+ NavLoad,
6
+ NavMenuItem,
7
+ NavMode,
8
+ NavPageMenuDeclaration,
9
+ NavPageRecord,
10
+ NavSources,
11
+ NavSubSection,
12
+ SectionLink,
13
+ } from '.';
14
+
15
+ export interface GetNavDataInput {
16
+ mode: NavMode;
17
+ load: NavLoad;
18
+ sources: NavSources;
19
+ }
20
+
21
+ const normalizeBasePath = (basePath?: string): string => {
22
+ if (!basePath) return '';
23
+ return `${basePath.replace(/^\/+|\/+$/g, '')}/`;
24
+ };
25
+
26
+ const cloneMenu = (menu: NavMenuItem[] = []): NavMenuItem[] =>
27
+ menu.map((item) => ({ ...item, ...(item.menu ? { menu: cloneMenu(item.menu) } : {}) }));
28
+
29
+ const cloneSubSection = (section: NavSubSection): NavSubSection => ({
30
+ ...section,
31
+ ...(section.dynamic ? { dynamic: { ...section.dynamic } } : {}),
32
+ ...(section.menu ? { menu: cloneMenu(section.menu) } : {}),
33
+ });
34
+
35
+ const cloneSection = (section: SectionLink): SectionLink => ({
36
+ ...section,
37
+ subSections: section.subSections.map(cloneSubSection),
38
+ });
39
+
40
+ const entityTitle = (entity: NavEntity): string => entity.title || startCase(entity.id);
41
+
42
+ const buildEntityMenu = (entities: NavEntity[], segment: 'component' | 'pattern', basePath: string): NavMenuItem[] => {
43
+ const grouped = new Map<string, NavEntity[]>();
44
+ for (const entity of entities) {
45
+ const group = entity.group ?? '';
46
+ grouped.set(group, [...(grouped.get(group) ?? []), entity]);
47
+ }
48
+
49
+ return Array.from(grouped.entries())
50
+ .map(([group, records]) => ({
51
+ title: group || 'Uncategorized',
52
+ path: '',
53
+ menu: records
54
+ .map((record) => ({
55
+ title: entityTitle(record),
56
+ path: `${basePath}system/${segment}/${record.id}`,
57
+ }))
58
+ .sort((a, b) => a.title.localeCompare(b.title)),
59
+ }))
60
+ .map(({ path: _path, ...group }) => group)
61
+ .sort((a, b) => a.title.localeCompare(b.title));
62
+ };
63
+
64
+ const buildTokensMenu = (sources: NavSources, basePath: string): NavMenuItem[] => {
65
+ const foundations = cloneMenu(sources.tokenFoundations);
66
+ const componentSets = sources.tokenSets
67
+ .filter((set) => set.kind === 'component')
68
+ .map((set) => {
69
+ const id = set.id.startsWith('component/') ? set.id.slice('component/'.length) : set.id;
70
+ return {
71
+ title: set.title || startCase(id),
72
+ path: `${basePath}system/tokens/components/${id}`,
73
+ };
74
+ })
75
+ .sort((a, b) => a.title.localeCompare(b.title));
76
+
77
+ if (componentSets.length > 0) {
78
+ foundations.push({
79
+ title: 'Components',
80
+ path: `${basePath}system/tokens/components`,
81
+ menu: componentSets,
82
+ });
83
+ }
84
+ return foundations;
85
+ };
86
+
87
+ const fillSubSections = (subSections: NavSubSection[], sources: NavSources, keepEmpty: boolean): NavSubSection[] => {
88
+ const basePath = normalizeBasePath(sources.basePath);
89
+ const resolved: NavSubSection[] = [];
90
+
91
+ for (const original of subSections) {
92
+ const section = cloneSubSection(original);
93
+ if (section.dynamic?.kind === 'components') {
94
+ const entities = section.dynamic.type
95
+ ? sources.components.filter((component) => component.type === section.dynamic?.type)
96
+ : sources.components;
97
+ section.menu = buildEntityMenu(entities, 'component', basePath);
98
+ } else if (section.dynamic?.kind === 'patterns') {
99
+ section.menu = buildEntityMenu(sources.patterns, 'pattern', basePath);
100
+ } else if (section.dynamic?.kind === 'token-components') {
101
+ section.menu = buildTokensMenu(sources, basePath);
102
+ }
103
+
104
+ if (!section.dynamic || keepEmpty || (section.menu?.length ?? 0) > 0) {
105
+ resolved.push(section);
106
+ }
107
+ }
108
+ return resolved;
109
+ };
110
+
111
+ const pageMenuEntries = (menu: NavPageRecord['menu']): NavPageMenuDeclaration[] => (Array.isArray(menu) ? menu : Object.values(menu ?? {}));
112
+
113
+ const declarationToSubSection = (declaration: NavPageMenuDeclaration, sources: NavSources): NavSubSection | undefined => {
114
+ if (declaration.components) {
115
+ return {
116
+ title: declaration.title ?? 'Components',
117
+ menu: [],
118
+ dynamic: typeof declaration.components === 'string' ? { kind: 'components', type: declaration.components } : { kind: 'components' },
119
+ };
120
+ }
121
+ if (declaration.patterns) {
122
+ return { title: declaration.title ?? 'Patterns', menu: [], dynamic: { kind: 'patterns' } };
123
+ }
124
+ if (declaration.tokens) {
125
+ return {
126
+ title: 'Tokens',
127
+ menu: cloneMenu(sources.tokenFoundations),
128
+ dynamic: { kind: 'token-components' },
129
+ };
130
+ }
131
+ if (declaration.enabled === false) return undefined;
132
+ return {
133
+ title: declaration.title ?? '',
134
+ ...(declaration.path !== undefined ? { path: declaration.path } : {}),
135
+ ...(declaration.image !== undefined ? { image: declaration.image } : {}),
136
+ ...(declaration.menu !== undefined ? { menu: cloneMenu(declaration.menu) } : {}),
137
+ };
138
+ };
139
+
140
+ const buildChildPageItems = (parentId: string, pages: NavPageRecord[]): NavMenuItem[] => {
141
+ const prefix = `${parentId}/`;
142
+ const segments = Array.from(
143
+ new Set(pages.filter((page) => page.id.startsWith(prefix)).map((page) => page.id.slice(prefix.length).split('/')[0]))
144
+ );
145
+
146
+ return segments
147
+ .map((segment) => {
148
+ const id = `${parentId}/${segment}`;
149
+ const record = pages.find((page) => page.id === id);
150
+ const nested = buildChildPageItems(id, pages);
151
+ const item: NavMenuItem & { weight: number } = {
152
+ title: record?.menuTitle || record?.title || startCase(segment),
153
+ path: record?.path ?? `/${id}`,
154
+ weight: record?.weight ?? 0,
155
+ };
156
+ if (nested.length > 0) item.menu = nested;
157
+ return item;
158
+ })
159
+ .sort((a, b) => a.weight - b.weight || a.title.localeCompare(b.title))
160
+ .map(({ weight: _weight, ...item }) => item);
161
+ };
162
+
163
+ const buildPageSections = (sources: NavSources, keepEmpty: boolean): SectionLink[] => {
164
+ const pages = sources.pages.filter((page) => page.enabled !== false);
165
+ return pages
166
+ .filter((page) => !page.id.includes('/'))
167
+ .map((page) => {
168
+ const declared = page.menu
169
+ ? pageMenuEntries(page.menu)
170
+ .map((entry) => declarationToSubSection(entry, sources))
171
+ .filter((entry): entry is NavSubSection => entry !== undefined)
172
+ : [];
173
+ const children = page.menu ? [] : buildChildPageItems(page.id, pages);
174
+ const subSections = page.menu
175
+ ? fillSubSections(declared, sources, keepEmpty)
176
+ : children.length > 0
177
+ ? [{ title: page.menuTitle || page.title || startCase(page.id), menu: children }]
178
+ : [];
179
+ const external =
180
+ typeof page.external === 'string' &&
181
+ (page.external.startsWith('http://') || page.external.startsWith('https://') || page.external.startsWith('/'))
182
+ ? page.external
183
+ : false;
184
+ return {
185
+ title: page.menuTitle || page.title || startCase(page.id),
186
+ external,
187
+ weight: page.weight ?? 0,
188
+ path: page.path ?? `/${page.id}`,
189
+ subSections,
190
+ };
191
+ });
192
+ };
193
+
194
+ const resolveCompleteTree = (sources: NavSources, keepEmpty: boolean): SectionLink[] => {
195
+ const byPath = new Map(
196
+ sources.shell.map((section) => [
197
+ section.path,
198
+ { ...cloneSection(section), subSections: fillSubSections(section.subSections, sources, keepEmpty) },
199
+ ])
200
+ );
201
+ for (const section of buildPageSections(sources, keepEmpty)) {
202
+ byPath.set(section.path, section);
203
+ }
204
+ return Array.from(byPath.values()).sort((a, b) => a.weight - b.weight);
205
+ };
206
+
207
+ /** Resolve an eager navigation snapshot without fetching, mutation, or asynchronous work. */
208
+ export const getNavData = ({ mode, load, sources }: GetNavDataInput): NavData => {
209
+ if (mode === 'workspace') {
210
+ return { shell: resolveCompleteTree(sources, false) };
211
+ }
212
+
213
+ if (load === 'initial') {
214
+ return { shell: sources.shell.map(cloneSection) };
215
+ }
216
+ return { shell: resolveCompleteTree(sources, true) };
217
+ };