blume 0.0.0 → 0.1.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 (263) hide show
  1. package/bin/blume.mjs +18 -0
  2. package/dist/cli/index.js +11989 -0
  3. package/dist/cli/index.js.map +141 -0
  4. package/docs/01-quickstart.mdx +99 -0
  5. package/docs/02-deployment.mdx +129 -0
  6. package/docs/advanced/api-reference.mdx +114 -0
  7. package/docs/advanced/blog.mdx +121 -0
  8. package/docs/advanced/changelog.mdx +113 -0
  9. package/docs/advanced/custom-pages.mdx +187 -0
  10. package/docs/advanced/meta.ts +7 -0
  11. package/docs/changelog/v0-1-0.mdx +12 -0
  12. package/docs/changelog/v0-2-0.mdx +16 -0
  13. package/docs/configuration/ai.mdx +228 -0
  14. package/docs/configuration/analytics.mdx +98 -0
  15. package/docs/configuration/customization.mdx +91 -0
  16. package/docs/configuration/export.mdx +70 -0
  17. package/docs/configuration/index.mdx +271 -0
  18. package/docs/configuration/meta.ts +15 -0
  19. package/docs/configuration/search.mdx +172 -0
  20. package/docs/configuration/seo.mdx +196 -0
  21. package/docs/configuration/theming.mdx +178 -0
  22. package/docs/content/components.mdx +565 -0
  23. package/docs/content/i18n.mdx +205 -0
  24. package/docs/content/index.mdx +161 -0
  25. package/docs/content/islands.mdx +94 -0
  26. package/docs/content/meta.mdx +119 -0
  27. package/docs/content/meta.ts +15 -0
  28. package/docs/content/navigation.mdx +168 -0
  29. package/docs/content/sources.mdx +216 -0
  30. package/docs/content/syntax.mdx +445 -0
  31. package/docs/index.mdx +112 -0
  32. package/docs/reference/cli.mdx +43 -0
  33. package/docs/reference/frontmatter.mdx +74 -0
  34. package/docs/reference/meta.ts +7 -0
  35. package/package.json +140 -6
  36. package/src/ai/ask.ts +93 -0
  37. package/src/ai/llms.ts +65 -0
  38. package/src/ai/markdown.ts +31 -0
  39. package/src/ai/mcp/data.ts +74 -0
  40. package/src/ai/mcp/discovery.ts +49 -0
  41. package/src/ai/mcp/server.ts +225 -0
  42. package/src/ai/mcp/tools.ts +47 -0
  43. package/src/assets/icon.png +0 -0
  44. package/src/astro/generate.ts +878 -0
  45. package/src/astro/index.ts +4 -0
  46. package/src/astro/integration.ts +74 -0
  47. package/src/astro/islands.ts +131 -0
  48. package/src/astro/markdown-negotiation.ts +68 -0
  49. package/src/astro/pages.ts +28 -0
  50. package/src/astro/templates.ts +1199 -0
  51. package/src/cli/commands/add.ts +81 -0
  52. package/src/cli/commands/build.ts +103 -0
  53. package/src/cli/commands/dev.ts +108 -0
  54. package/src/cli/commands/doctor.ts +74 -0
  55. package/src/cli/commands/eject.ts +57 -0
  56. package/src/cli/commands/init.ts +98 -0
  57. package/src/cli/commands/migrate.ts +39 -0
  58. package/src/cli/commands/preview.ts +39 -0
  59. package/src/cli/commands/sync.ts +52 -0
  60. package/src/cli/commands/validate.ts +60 -0
  61. package/src/cli/index.ts +35 -0
  62. package/src/cli/log.ts +37 -0
  63. package/src/cli/prepare.ts +80 -0
  64. package/src/components/Icon.astro +99 -0
  65. package/src/components/content/Accordion.astro +8 -0
  66. package/src/components/content/AccordionItem.astro +121 -0
  67. package/src/components/content/AutoTypeTable.astro +51 -0
  68. package/src/components/content/Badge.astro +124 -0
  69. package/src/components/content/Callout.astro +73 -0
  70. package/src/components/content/Card.astro +104 -0
  71. package/src/components/content/CardGroup.astro +14 -0
  72. package/src/components/content/CodeGroup.astro +13 -0
  73. package/src/components/content/Color.astro +15 -0
  74. package/src/components/content/ColorItem.astro +87 -0
  75. package/src/components/content/ColorRow.astro +10 -0
  76. package/src/components/content/Column.astro +6 -0
  77. package/src/components/content/Columns.astro +9 -0
  78. package/src/components/content/Expandable.astro +11 -0
  79. package/src/components/content/FileTree.astro +8 -0
  80. package/src/components/content/Frame.astro +70 -0
  81. package/src/components/content/GithubInfo.astro +110 -0
  82. package/src/components/content/Math.astro +24 -0
  83. package/src/components/content/Panel.astro +20 -0
  84. package/src/components/content/Prompt.astro +129 -0
  85. package/src/components/content/Step.astro +34 -0
  86. package/src/components/content/Steps.astro +20 -0
  87. package/src/components/content/Tab.astro +40 -0
  88. package/src/components/content/Tabs.astro +273 -0
  89. package/src/components/content/Tile.astro +42 -0
  90. package/src/components/content/Tooltip.astro +68 -0
  91. package/src/components/content/Tree.astro +300 -0
  92. package/src/components/content/TreeFile.astro +15 -0
  93. package/src/components/content/TreeFolder.astro +62 -0
  94. package/src/components/content/TypeTable.astro +106 -0
  95. package/src/components/content/Update.astro +66 -0
  96. package/src/components/content/Visibility.astro +12 -0
  97. package/src/components/content/Warning.astro +9 -0
  98. package/src/components/content/auto-type-table.ts +141 -0
  99. package/src/components/content/github-info.ts +79 -0
  100. package/src/components/content/mermaid-element.ts +68 -0
  101. package/src/components/github-mark.ts +9 -0
  102. package/src/components/index.ts +14 -0
  103. package/src/components/islands/AskAI.astro +12 -0
  104. package/src/components/islands/ask-ai.tsx +156 -0
  105. package/src/components/layout/Analytics.astro +63 -0
  106. package/src/components/layout/Banner.astro +50 -0
  107. package/src/components/layout/Breadcrumbs.astro +31 -0
  108. package/src/components/layout/Favicon.astro +15 -0
  109. package/src/components/layout/Fonts.astro +14 -0
  110. package/src/components/layout/Header.astro +188 -0
  111. package/src/components/layout/LanguageSwitcher.astro +56 -0
  112. package/src/components/layout/NavTree.astro +462 -0
  113. package/src/components/layout/PageActions.astro +438 -0
  114. package/src/components/layout/PageFeedback.astro +58 -0
  115. package/src/components/layout/Pagination.astro +56 -0
  116. package/src/components/layout/ReferenceLayout.astro +102 -0
  117. package/src/components/layout/RootLayout.astro +533 -0
  118. package/src/components/layout/Search.astro +608 -0
  119. package/src/components/layout/TableOfContents.astro +68 -0
  120. package/src/components/layout/analytics-client.ts +38 -0
  121. package/src/components/layout/nav-utils.ts +87 -0
  122. package/src/components/layout/overrides.ts +32 -0
  123. package/src/components/layout/search/algolia.ts +43 -0
  124. package/src/components/layout/search/endpoint.ts +22 -0
  125. package/src/components/layout/search/flexsearch.ts +52 -0
  126. package/src/components/layout/search/orama-cloud.ts +41 -0
  127. package/src/components/layout/search/orama.ts +26 -0
  128. package/src/components/layout/search/pagefind.ts +43 -0
  129. package/src/components/layout/search/types.ts +163 -0
  130. package/src/components/layout/search/typesense.ts +60 -0
  131. package/src/components/layout/toc-element.ts +108 -0
  132. package/src/core/bridge.ts +92 -0
  133. package/src/core/config.ts +112 -0
  134. package/src/core/content.ts +50 -0
  135. package/src/core/define-components.ts +34 -0
  136. package/src/core/define-meta.ts +20 -0
  137. package/src/core/deployment-env.ts +73 -0
  138. package/src/core/diagnostics.ts +104 -0
  139. package/src/core/graph.ts +128 -0
  140. package/src/core/i18n-ui.ts +171 -0
  141. package/src/core/i18n.ts +169 -0
  142. package/src/core/last-modified.ts +88 -0
  143. package/src/core/links.ts +336 -0
  144. package/src/core/load-module.ts +15 -0
  145. package/src/core/manifest.ts +126 -0
  146. package/src/core/meta.ts +97 -0
  147. package/src/core/navigation.ts +392 -0
  148. package/src/core/package-root.ts +37 -0
  149. package/src/core/project-graph.ts +153 -0
  150. package/src/core/project.ts +56 -0
  151. package/src/core/schema.ts +1057 -0
  152. package/src/core/server-features.ts +23 -0
  153. package/src/core/sources/assets.ts +77 -0
  154. package/src/core/sources/cache.ts +122 -0
  155. package/src/core/sources/filesystem.ts +99 -0
  156. package/src/core/sources/mdx-remote.ts +216 -0
  157. package/src/core/sources/mintlify.ts +161 -0
  158. package/src/core/sources/normalize.ts +227 -0
  159. package/src/core/sources/notion.ts +440 -0
  160. package/src/core/sources/portable-text.ts +143 -0
  161. package/src/core/sources/read.ts +36 -0
  162. package/src/core/sources/resolve.ts +158 -0
  163. package/src/core/sources/sanity.ts +218 -0
  164. package/src/core/sources/types.ts +105 -0
  165. package/src/core/types.ts +261 -0
  166. package/src/core/ui-packs/ar.ts +47 -0
  167. package/src/core/ui-packs/bg.ts +47 -0
  168. package/src/core/ui-packs/bn.ts +47 -0
  169. package/src/core/ui-packs/ca.ts +47 -0
  170. package/src/core/ui-packs/cs.ts +47 -0
  171. package/src/core/ui-packs/da.ts +47 -0
  172. package/src/core/ui-packs/de.ts +47 -0
  173. package/src/core/ui-packs/el.ts +47 -0
  174. package/src/core/ui-packs/es.ts +47 -0
  175. package/src/core/ui-packs/fa.ts +47 -0
  176. package/src/core/ui-packs/fi.ts +47 -0
  177. package/src/core/ui-packs/fr.ts +47 -0
  178. package/src/core/ui-packs/he.ts +47 -0
  179. package/src/core/ui-packs/hi.ts +47 -0
  180. package/src/core/ui-packs/hr.ts +47 -0
  181. package/src/core/ui-packs/hu.ts +47 -0
  182. package/src/core/ui-packs/id.ts +47 -0
  183. package/src/core/ui-packs/index.ts +87 -0
  184. package/src/core/ui-packs/it.ts +47 -0
  185. package/src/core/ui-packs/ja.ts +47 -0
  186. package/src/core/ui-packs/ko.ts +47 -0
  187. package/src/core/ui-packs/nl.ts +47 -0
  188. package/src/core/ui-packs/no.ts +47 -0
  189. package/src/core/ui-packs/pl.ts +47 -0
  190. package/src/core/ui-packs/pt-br.ts +47 -0
  191. package/src/core/ui-packs/pt.ts +47 -0
  192. package/src/core/ui-packs/ro.ts +47 -0
  193. package/src/core/ui-packs/ru.ts +47 -0
  194. package/src/core/ui-packs/sk.ts +47 -0
  195. package/src/core/ui-packs/sr.ts +47 -0
  196. package/src/core/ui-packs/sv.ts +47 -0
  197. package/src/core/ui-packs/th.ts +47 -0
  198. package/src/core/ui-packs/tr.ts +47 -0
  199. package/src/core/ui-packs/uk.ts +47 -0
  200. package/src/core/ui-packs/vi.ts +47 -0
  201. package/src/core/ui-packs/zh-tw.ts +47 -0
  202. package/src/core/ui-packs/zh.ts +47 -0
  203. package/src/core/version.ts +23 -0
  204. package/src/deploy/robots.ts +20 -0
  205. package/src/deploy/rss.ts +128 -0
  206. package/src/deploy/sitemap.ts +28 -0
  207. package/src/index.ts +27 -0
  208. package/src/markdown/code-title.ts +71 -0
  209. package/src/markdown/directives.ts +83 -0
  210. package/src/markdown/heading-anchors.ts +137 -0
  211. package/src/markdown/index.ts +159 -0
  212. package/src/markdown/inline-code.ts +108 -0
  213. package/src/markdown/language-icon.ts +172 -0
  214. package/src/markdown/math.ts +32 -0
  215. package/src/markdown/mdast.ts +48 -0
  216. package/src/markdown/mermaid.ts +37 -0
  217. package/src/markdown/package-commands.ts +159 -0
  218. package/src/markdown/package-install.ts +40 -0
  219. package/src/migrate/fumadocs/config.ts +106 -0
  220. package/src/migrate/fumadocs/content.ts +365 -0
  221. package/src/migrate/fumadocs/frontmatter.ts +18 -0
  222. package/src/migrate/fumadocs/index.ts +252 -0
  223. package/src/migrate/fumadocs/meta.ts +114 -0
  224. package/src/migrate/migrate.ts +53 -0
  225. package/src/migrate/mintlify/config.ts +1040 -0
  226. package/src/migrate/mintlify/content.ts +98 -0
  227. package/src/migrate/mintlify/frontmatter.ts +126 -0
  228. package/src/migrate/mintlify/i18n.ts +51 -0
  229. package/src/migrate/mintlify/icons.ts +128 -0
  230. package/src/migrate/mintlify/index.ts +266 -0
  231. package/src/migrate/mintlify/snippets.ts +305 -0
  232. package/src/migrate/mintlify/transform.ts +81 -0
  233. package/src/migrate/nextra/content.ts +46 -0
  234. package/src/migrate/nextra/frontmatter.ts +40 -0
  235. package/src/migrate/nextra/index.ts +374 -0
  236. package/src/migrate/nextra/meta.ts +266 -0
  237. package/src/migrate/shared.ts +623 -0
  238. package/src/migrate/starlight/config.ts +459 -0
  239. package/src/migrate/starlight/content.ts +78 -0
  240. package/src/migrate/starlight/frontmatter.ts +111 -0
  241. package/src/migrate/starlight/i18n.ts +54 -0
  242. package/src/migrate/starlight/index.ts +131 -0
  243. package/src/og/card.ts +92 -0
  244. package/src/og/index.ts +2 -0
  245. package/src/openapi/scalar.ts +246 -0
  246. package/src/registry/eject.ts +263 -0
  247. package/src/registry/registry.ts +100 -0
  248. package/src/registry/rewrite-imports.ts +39 -0
  249. package/src/runtime/index.ts +14 -0
  250. package/src/search/build.ts +23 -0
  251. package/src/search/documents.ts +165 -0
  252. package/src/search/orama-index.ts +66 -0
  253. package/src/search/providers.ts +91 -0
  254. package/src/search/sync/algolia.ts +30 -0
  255. package/src/search/sync/index.ts +50 -0
  256. package/src/search/sync/orama-cloud.ts +40 -0
  257. package/src/search/sync/typesense.ts +65 -0
  258. package/src/seo/jsonld.ts +113 -0
  259. package/src/theme/entry.ts +608 -0
  260. package/src/theme/fonts.ts +198 -0
  261. package/src/theme/icons.ts +184 -0
  262. package/src/theme/palette.ts +143 -0
  263. package/src/theme/twoslash.ts +81 -0
@@ -0,0 +1,188 @@
1
+ ---
2
+ import type { UIStrings } from "../../core/i18n-ui.ts";
3
+ import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
4
+ import { GITHUB_MARK } from "../github-mark.ts";
5
+ import Icon from "../Icon.astro";
6
+ import LanguageSwitcher from "./LanguageSwitcher.astro";
7
+ import Search from "./Search.astro";
8
+
9
+ interface Props {
10
+ site: { title: string };
11
+ logo?: {
12
+ svg?: string;
13
+ light?: string;
14
+ dark?: string;
15
+ alt: string;
16
+ href: string;
17
+ } | null;
18
+ navigation: Navigation;
19
+ route: string;
20
+ searchEnabled: boolean;
21
+ askEnabled?: boolean;
22
+ // The mobile menu button toggles the docs sidebar drawer; custom pages
23
+ // without a sidebar (e.g. a landing page) pass `false` to hide it.
24
+ hasSidebar?: boolean;
25
+ searchStrings?: UIStrings["search"];
26
+ switcherStrings?: UIStrings["languageSwitcher"];
27
+ localeSwitch?: LocaleSwitchOption[];
28
+ /** Active locale for per-language search filtering. */
29
+ searchLocale?: string;
30
+ }
31
+
32
+ const {
33
+ site,
34
+ logo,
35
+ navigation,
36
+ route,
37
+ searchEnabled,
38
+ askEnabled = false,
39
+ hasSidebar = true,
40
+ searchStrings,
41
+ switcherStrings,
42
+ localeSwitch,
43
+ searchLocale,
44
+ } = Astro.props;
45
+
46
+ // The logo is resolved upstream: an inline `svg` (so a currentColor mark
47
+ // follows the theme) or image URL(s) for an <img>.
48
+ const logoSvg = logo?.svg;
49
+ const logoLight = logo?.light;
50
+ const logoDark = logo?.dark ?? logo?.light;
51
+ const logoAlt = logo?.alt ?? "";
52
+ const brandHref = logo?.href ?? "/";
53
+
54
+ const iconButton =
55
+ "inline-flex size-9 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
56
+
57
+ // Delegated handlers for the theme toggle, mobile nav drawer, and banner
58
+ // dismiss. Living with the header means every page that renders it — docs pages
59
+ // and custom pages alike — gets identical behavior from one source.
60
+ //
61
+ // Toggling the theme flips colors; without suppressing transitions the animated
62
+ // styles fade while the non-animated ones snap, so we disable all transitions
63
+ // for the swap (next-themes' disableTransitionOnChange trick), force a
64
+ // synchronous style flush, then restore them on the next tick.
65
+ const clickScript = `document.addEventListener("click",(e)=>{const t=e.target.closest("[data-blume-theme-toggle]");if(t){const d=document.documentElement;const next=d.dataset.theme==="dark"?"light":"dark";const s=document.createElement("style");s.appendChild(document.createTextNode("*,*::before,*::after{transition:none!important}"));document.head.appendChild(s);d.dataset.theme=next;localStorage.setItem("blume-theme",next);window.getComputedStyle(d).opacity;setTimeout(()=>document.head.removeChild(s),1);}const n=e.target.closest("[data-blume-nav-toggle]");if(n){document.documentElement.toggleAttribute("data-blume-nav-open");}const b=e.target.closest("[data-blume-banner-dismiss]");if(b){const bar=b.closest("[data-blume-banner]");const k=bar&&bar.getAttribute("data-banner-key");if(k){localStorage.setItem("blume-banner:"+k,"1");}document.documentElement.setAttribute("data-blume-banner-hidden","");}});`;
66
+ ---
67
+
68
+ <header
69
+ class="sticky top-0 z-40 flex h-16 items-center gap-3 bg-background/90 px-4 backdrop-blur md:px-6"
70
+ >
71
+ {
72
+ hasSidebar && (
73
+ <button
74
+ aria-label="Toggle navigation"
75
+ class={`${iconButton} lg:hidden`}
76
+ data-blume-nav-toggle
77
+ type="button"
78
+ >
79
+ <Icon name="menu" size={20} />
80
+ </button>
81
+ )
82
+ }
83
+ <a
84
+ class="inline-flex items-center gap-2 font-semibold text-base text-foreground"
85
+ href={brandHref}
86
+ >
87
+ {
88
+ logoSvg && (
89
+ <span
90
+ aria-hidden="true"
91
+ class="inline-flex h-5 items-center [&>svg]:h-5 [&>svg]:w-auto"
92
+ set:html={logoSvg}
93
+ />
94
+ )
95
+ }
96
+ {
97
+ !logoSvg &&
98
+ logoLight &&
99
+ (logoLight === logoDark ? (
100
+ <img alt={logoAlt} class="h-5 w-auto" src={logoLight} />
101
+ ) : (
102
+ <>
103
+ <img alt={logoAlt} class="h-5 w-auto dark:hidden" src={logoLight} />
104
+ <img
105
+ alt={logoAlt}
106
+ class="hidden h-5 w-auto dark:block"
107
+ src={logoDark}
108
+ />
109
+ </>
110
+ ))
111
+ }
112
+ {site.title}
113
+ </a>
114
+ {
115
+ navigation.tabs.length > 0 && (
116
+ <nav aria-label="Sections" class="hidden gap-1 md:flex">
117
+ {navigation.tabs.map((tab) => (
118
+ <a
119
+ aria-current={
120
+ route === tab.path ||
121
+ (tab.path !== "/" && route.startsWith(tab.path))
122
+ ? "page"
123
+ : undefined
124
+ }
125
+ class="rounded-full px-3 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:text-foreground"
126
+ href={tab.path}
127
+ >
128
+ {tab.label}
129
+ </a>
130
+ ))}
131
+ </nav>
132
+ )
133
+ }
134
+ <div class="flex-1"></div>
135
+ <slot name="ask" />
136
+ {
137
+ localeSwitch && localeSwitch.length > 1 && (
138
+ <LanguageSwitcher
139
+ label={switcherStrings?.label ?? "Language"}
140
+ options={localeSwitch}
141
+ untranslatedLabel={switcherStrings?.untranslated ?? "Not translated"}
142
+ />
143
+ )
144
+ }
145
+ {
146
+ searchEnabled && (
147
+ <Search
148
+ askEnabled={askEnabled}
149
+ locale={searchLocale}
150
+ navigation={navigation}
151
+ strings={searchStrings}
152
+ />
153
+ )
154
+ }
155
+ <div class="flex items-center gap-2">
156
+ {
157
+ navigation.repoUrl && (
158
+ <a
159
+ aria-label="GitHub repository"
160
+ class={iconButton}
161
+ href={navigation.repoUrl}
162
+ rel="noreferrer"
163
+ target="_blank"
164
+ >
165
+ <svg
166
+ aria-hidden="true"
167
+ fill="currentColor"
168
+ height="18"
169
+ set:html={GITHUB_MARK}
170
+ viewBox="0 0 16 16"
171
+ width="18"
172
+ xmlns="http://www.w3.org/2000/svg"
173
+ />
174
+ </a>
175
+ )
176
+ }
177
+ <button
178
+ aria-label="Toggle color theme"
179
+ class={iconButton}
180
+ data-blume-theme-toggle
181
+ type="button"
182
+ >
183
+ <span class="inline-flex dark:hidden"><Icon name="sun" size={18} /></span>
184
+ <span class="hidden dark:inline-flex"><Icon name="moon" size={18} /></span>
185
+ </button>
186
+ </div>
187
+ </header>
188
+ <script is:inline set:html={clickScript} />
@@ -0,0 +1,56 @@
1
+ ---
2
+ import type { LocaleSwitchOption } from "../../core/types.ts";
3
+ import Icon from "../Icon.astro";
4
+
5
+ interface Props {
6
+ options: LocaleSwitchOption[];
7
+ label: string;
8
+ untranslatedLabel: string;
9
+ }
10
+
11
+ const { options, label, untranslatedLabel } = Astro.props;
12
+ const current = options.find((option) => option.current);
13
+
14
+ const iconButton =
15
+ "inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground";
16
+ const menuRowClass =
17
+ "flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=true]:text-foreground";
18
+ ---
19
+
20
+ {
21
+ options.length > 1 && (
22
+ <details class="group relative">
23
+ <summary
24
+ aria-label={label}
25
+ class={`${iconButton} list-none [&::-webkit-details-marker]:hidden`}
26
+ >
27
+ <Icon name="globe" size={16} />
28
+ <span class="max-sm:hidden">{current?.label ?? label}</span>
29
+ <Icon
30
+ class="transition-transform group-open:rotate-180"
31
+ name="chevron-down"
32
+ size={14}
33
+ />
34
+ </summary>
35
+ <div class="absolute end-0 z-50 mt-2 min-w-44 rounded-blume border border-border bg-background p-1 shadow-xl">
36
+ {options.map((option) => (
37
+ <a
38
+ aria-current={option.current ? "true" : undefined}
39
+ class={menuRowClass}
40
+ href={option.href}
41
+ hreflang={option.code}
42
+ lang={option.code}
43
+ >
44
+ <span class="flex-1">{option.label}</span>
45
+ {option.untranslated && (
46
+ <span class="text-muted-foreground text-xs italic">
47
+ {untranslatedLabel}
48
+ </span>
49
+ )}
50
+ {option.current && <Icon name="check" size={14} />}
51
+ </a>
52
+ ))}
53
+ </div>
54
+ </details>
55
+ )
56
+ }
@@ -0,0 +1,462 @@
1
+ ---
2
+ import type { NavNode } from "../../core/types.ts";
3
+ import Icon from "../Icon.astro";
4
+ import Self from "./NavTree.astro";
5
+
6
+ interface PagePanel {
7
+ id: string;
8
+ parentId: string;
9
+ label: string;
10
+ route?: string;
11
+ icon?: string;
12
+ children: NavNode[];
13
+ active: boolean;
14
+ }
15
+
16
+ interface Props {
17
+ items: NavNode[];
18
+ currentRoute: string;
19
+ depth?: number;
20
+ /** Id prefix used to address `page`-mode panels (`${prefix}.${index}`). */
21
+ idPrefix?: string;
22
+ /** True only for the top-level call; renders the `<blume-nav>` panel stack. */
23
+ root?: boolean;
24
+ }
25
+
26
+ const {
27
+ items,
28
+ currentRoute,
29
+ depth = 0,
30
+ idPrefix = "n",
31
+ root = depth === 0,
32
+ } = Astro.props;
33
+
34
+ const badgeClass =
35
+ "shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-medium text-[0.65rem] text-muted-foreground leading-none";
36
+ const deprecatedClass =
37
+ "shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 font-medium text-[0.65rem] text-amber-700 leading-none dark:text-amber-300";
38
+
39
+ const hasActivePage = (item: NavNode): boolean =>
40
+ item.kind === "page"
41
+ ? item.route === currentRoute
42
+ : item.route === currentRoute || item.children.some(hasActivePage);
43
+
44
+ // Collect every `page`-mode group in the tree as a panel that is hoisted to the
45
+ // top of the `<blume-nav>` stack. Panel ids match the `${prefix}.${index}`
46
+ // scheme the row renderer uses for `data-nav-to`, so a drill-in trigger and its
47
+ // panel always agree. `flat`/`group` ancestors keep their children in the same
48
+ // visual panel (parentId unchanged); a `page` group opens a new panel.
49
+ const collectPanels = (
50
+ nodes: NavNode[],
51
+ prefix: string,
52
+ parentId: string
53
+ ): PagePanel[] => {
54
+ const out: PagePanel[] = [];
55
+ nodes.forEach((node, index) => {
56
+ if (node.kind !== "group") {
57
+ return;
58
+ }
59
+ const id = `${prefix}.${index}`;
60
+ if ((node.display ?? "flat") === "page") {
61
+ out.push({
62
+ active: hasActivePage(node),
63
+ children: node.children,
64
+ icon: node.icon,
65
+ id,
66
+ label: node.label,
67
+ parentId,
68
+ route: node.route,
69
+ });
70
+ out.push(...collectPanels(node.children, id, id));
71
+ } else {
72
+ out.push(...collectPanels(node.children, id, parentId));
73
+ }
74
+ });
75
+ return out;
76
+ };
77
+
78
+ const panels = root ? collectPanels(items, idPrefix, "root") : [];
79
+ // Open the deepest panel that contains the current page, so a real page load
80
+ // lands on the right sub-panel (and degrades gracefully without JS).
81
+ const activePanels = panels.filter((panel) => panel.active);
82
+ const initialId =
83
+ activePanels.length > 0
84
+ ? activePanels.reduce((a, b) =>
85
+ b.id.split(".").length > a.id.split(".").length ? b : a
86
+ ).id
87
+ : "root";
88
+ ---
89
+
90
+ {
91
+ root && panels.length > 0 ? (
92
+ <blume-nav class="block" data-initial={initialId}>
93
+ <div data-nav-panel="root" hidden={initialId !== "root"}>
94
+ <Self
95
+ currentRoute={currentRoute}
96
+ depth={0}
97
+ idPrefix={idPrefix}
98
+ items={items}
99
+ root={false}
100
+ />
101
+ </div>
102
+ {panels.map((panel) => (
103
+ <div data-nav-panel={panel.id} hidden={panel.id !== initialId}>
104
+ <div class="mb-3 flex items-center gap-1.5">
105
+ <button
106
+ aria-label="Back"
107
+ class="-ml-1 flex shrink-0 items-center justify-center rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
108
+ data-nav-back={panel.parentId}
109
+ type="button"
110
+ >
111
+ <Icon name="arrow-left" size={16} />
112
+ </button>
113
+ {panel.route ? (
114
+ <a
115
+ aria-current={panel.route === currentRoute ? "page" : undefined}
116
+ class="min-w-0 flex-1 truncate font-semibold text-foreground text-sm hover:underline"
117
+ href={panel.route}
118
+ >
119
+ {panel.label}
120
+ </a>
121
+ ) : (
122
+ <span class="min-w-0 flex-1 truncate font-semibold text-foreground text-sm">
123
+ {panel.label}
124
+ </span>
125
+ )}
126
+ </div>
127
+ <Self
128
+ currentRoute={currentRoute}
129
+ depth={1}
130
+ idPrefix={panel.id}
131
+ items={panel.children}
132
+ root={false}
133
+ />
134
+ </div>
135
+ ))}
136
+ </blume-nav>
137
+ ) : (
138
+ <ul class="m-0 list-none p-0">
139
+ {items.map((item, index) => {
140
+ if (item.kind === "page") {
141
+ return (
142
+ <li>
143
+ <a
144
+ aria-current={item.route === currentRoute ? "page" : undefined}
145
+ class="block rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:font-medium aria-[current=page]:text-foreground"
146
+ href={item.route}
147
+ >
148
+ <span class="flex min-w-0 items-center gap-2">
149
+ {item.icon && (
150
+ <Icon
151
+ class="shrink-0 text-muted-foreground"
152
+ name={item.icon}
153
+ size={14}
154
+ />
155
+ )}
156
+ <span class="min-w-0 flex-1 truncate">{item.label}</span>
157
+ {item.badge && <span class={badgeClass}>{item.badge}</span>}
158
+ {item.deprecated && (
159
+ <span class={deprecatedClass}>deprecated</span>
160
+ )}
161
+ </span>
162
+ </a>
163
+ </li>
164
+ );
165
+ }
166
+
167
+ const display = item.display ?? "flat";
168
+ const id = `${idPrefix}.${index}`;
169
+ // Flat groups are section headers and want breathing room; collapsible
170
+ // `group`/`page` rows are single rows and should stack tightly.
171
+ const spacing =
172
+ index === 0 ? "mt-0" : display === "flat" ? "mt-6" : "mt-0.5";
173
+
174
+ // `page`: a single row that drills into this group's panel.
175
+ if (display === "page") {
176
+ return (
177
+ <li class={spacing}>
178
+ <button
179
+ class="flex w-full min-w-0 items-center gap-2 rounded-[0.65rem] px-2.5 py-1.5 text-left font-medium text-foreground text-sm transition-colors hover:bg-muted"
180
+ data-nav-to={id}
181
+ type="button"
182
+ >
183
+ {item.icon && (
184
+ <Icon
185
+ class="shrink-0 text-muted-foreground"
186
+ name={item.icon}
187
+ size={14}
188
+ />
189
+ )}
190
+ <span class="min-w-0 flex-1 truncate">{item.label}</span>
191
+ {item.badge && <span class={badgeClass}>{item.badge}</span>}
192
+ <Icon
193
+ class="shrink-0 text-muted-foreground"
194
+ name="chevron-right"
195
+ size={14}
196
+ />
197
+ </button>
198
+ </li>
199
+ );
200
+ }
201
+
202
+ // `group`: a collapsible `<details>` disclosure.
203
+ if (display === "group") {
204
+ const active = hasActivePage(item);
205
+ const open = depth === 0 || active || item.collapsed === false;
206
+ return (
207
+ <li class={spacing}>
208
+ <details class="group" open={open}>
209
+ <summary class="mb-1 flex cursor-pointer list-none items-center gap-1.5 rounded-[0.65rem] px-2.5 py-1.5 font-medium text-foreground text-sm transition-colors hover:bg-muted [&::-webkit-details-marker]:hidden">
210
+ {item.route ? (
211
+ <a
212
+ aria-current={
213
+ item.route === currentRoute ? "page" : undefined
214
+ }
215
+ class="-my-1 flex min-w-0 flex-1 items-center gap-1.5 rounded py-1 text-foreground transition-colors aria-[current=page]:font-semibold"
216
+ href={item.route}
217
+ >
218
+ {item.icon && (
219
+ <Icon
220
+ class="shrink-0 text-muted-foreground"
221
+ name={item.icon}
222
+ size={14}
223
+ />
224
+ )}
225
+ <span class="min-w-0 flex-1 truncate">{item.label}</span>
226
+ {item.badge && (
227
+ <span class={badgeClass}>{item.badge}</span>
228
+ )}
229
+ </a>
230
+ ) : (
231
+ <>
232
+ {item.icon && (
233
+ <Icon
234
+ class="shrink-0 text-muted-foreground"
235
+ name={item.icon}
236
+ size={14}
237
+ />
238
+ )}
239
+ <span class="min-w-0 flex-1 truncate">{item.label}</span>
240
+ {item.badge && (
241
+ <span class={badgeClass}>{item.badge}</span>
242
+ )}
243
+ </>
244
+ )}
245
+ <span class="shrink-0 text-muted-foreground transition-transform group-open:rotate-90">
246
+ <Icon name="chevron-right" size={13} />
247
+ </span>
248
+ </summary>
249
+ <div class="space-y-0.5 border-border border-l pl-3">
250
+ <Self
251
+ currentRoute={currentRoute}
252
+ depth={depth + 1}
253
+ idPrefix={id}
254
+ items={item.children}
255
+ root={false}
256
+ />
257
+ </div>
258
+ </details>
259
+ </li>
260
+ );
261
+ }
262
+
263
+ // `flat` (default): a non-collapsible header with its items beneath.
264
+ return (
265
+ <li class={spacing}>
266
+ <p class="mb-2 flex min-w-0 items-center gap-1.5 px-2.5 font-medium text-foreground text-sm">
267
+ {item.route ? (
268
+ <a
269
+ aria-current={item.route === currentRoute ? "page" : undefined}
270
+ class="-ml-1 flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-foreground transition-colors hover:bg-muted aria-[current=page]:bg-muted"
271
+ href={item.route}
272
+ >
273
+ {item.icon && (
274
+ <Icon
275
+ class="shrink-0 text-muted-foreground"
276
+ name={item.icon}
277
+ size={14}
278
+ />
279
+ )}
280
+ <span class="min-w-0 truncate">{item.label}</span>
281
+ {item.badge && (
282
+ <span class={`ml-auto ${badgeClass}`}>{item.badge}</span>
283
+ )}
284
+ </a>
285
+ ) : (
286
+ <>
287
+ {item.icon && (
288
+ <Icon
289
+ class="shrink-0 text-muted-foreground"
290
+ name={item.icon}
291
+ size={14}
292
+ />
293
+ )}
294
+ <span class="min-w-0 truncate">{item.label}</span>
295
+ {item.badge && (
296
+ <span class={`ml-auto ${badgeClass}`}>{item.badge}</span>
297
+ )}
298
+ </>
299
+ )}
300
+ </p>
301
+ <div class="space-y-0.5">
302
+ <Self
303
+ currentRoute={currentRoute}
304
+ depth={depth + 1}
305
+ idPrefix={id}
306
+ items={item.children}
307
+ root={false}
308
+ />
309
+ </div>
310
+ </li>
311
+ );
312
+ })}
313
+ </ul>
314
+ )
315
+ }
316
+
317
+ <script>
318
+ const DURATION = 260;
319
+ const EASING = "cubic-bezier(0.33, 1, 0.68, 1)";
320
+
321
+ class BlumeNav extends HTMLElement {
322
+ connectedCallback() {
323
+ if (this.dataset.ready) {
324
+ return;
325
+ }
326
+ this.dataset.ready = "1";
327
+ this.addEventListener("click", (event) => {
328
+ const target = event.target;
329
+ if (!(target instanceof Element)) {
330
+ return;
331
+ }
332
+ const drill = target.closest("[data-nav-to]");
333
+ if (drill && this.contains(drill)) {
334
+ const id = drill.getAttribute("data-nav-to");
335
+ if (id && this.panel(id)) {
336
+ event.preventDefault();
337
+ this.show(id, true);
338
+ }
339
+ return;
340
+ }
341
+ const back = target.closest("[data-nav-back]");
342
+ if (back && this.contains(back)) {
343
+ event.preventDefault();
344
+ this.show(back.getAttribute("data-nav-back") || "root", true);
345
+ }
346
+ });
347
+ // Initial panel is route-driven, so it snaps into place without a slide.
348
+ this.show(this.dataset.initial || "root", false);
349
+ }
350
+
351
+ panel(id) {
352
+ return this.querySelector(`[data-nav-panel="${id}"]`);
353
+ }
354
+
355
+ depth(id) {
356
+ return id === "root" ? 0 : id.split(".").length;
357
+ }
358
+
359
+ reduced() {
360
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
361
+ }
362
+
363
+ show(id, animate) {
364
+ const next = this.panel(id) || this.panel("root");
365
+ if (!next) {
366
+ return;
367
+ }
368
+ const resolved = next.getAttribute("data-nav-panel");
369
+ const prev = this.active;
370
+ if (prev === next) {
371
+ return;
372
+ }
373
+ // Settle any in-flight slide before starting the next one.
374
+ if (this.finishSlide) {
375
+ this.finishSlide();
376
+ }
377
+ this.active = next;
378
+ this.dataset.active = resolved;
379
+
380
+ if (!(animate && prev) || this.reduced()) {
381
+ for (const panel of this.querySelectorAll("[data-nav-panel]")) {
382
+ panel.hidden = panel !== next;
383
+ }
384
+ return;
385
+ }
386
+
387
+ const forward =
388
+ this.depth(resolved) > this.depth(prev.getAttribute("data-nav-panel"));
389
+ this.slide(prev, next, forward);
390
+ }
391
+
392
+ slide(out, into, forward) {
393
+ const rtl = getComputedStyle(this).direction === "rtl" ? -1 : 1;
394
+ const sign = (forward ? -1 : 1) * rtl;
395
+
396
+ const fromHeight = out.offsetHeight;
397
+ this.style.position = "relative";
398
+ this.style.overflow = "hidden";
399
+ this.style.height = `${fromHeight}px`;
400
+ into.hidden = false;
401
+ for (const panel of [out, into]) {
402
+ panel.style.position = "absolute";
403
+ panel.style.top = "0";
404
+ panel.style.insetInlineStart = "0";
405
+ panel.style.width = "100%";
406
+ }
407
+ const toHeight = into.offsetHeight;
408
+ this.style.height = `${toHeight}px`;
409
+
410
+ const options = { duration: DURATION, easing: EASING };
411
+ const animations = [
412
+ out.animate(
413
+ [
414
+ { transform: "translateX(0)" },
415
+ { transform: `translateX(${sign * 100}%)` },
416
+ ],
417
+ options
418
+ ),
419
+ into.animate(
420
+ [
421
+ { transform: `translateX(${-sign * 100}%)` },
422
+ { transform: "translateX(0)" },
423
+ ],
424
+ options
425
+ ),
426
+ this.animate(
427
+ [{ height: `${fromHeight}px` }, { height: `${toHeight}px` }],
428
+ options
429
+ ),
430
+ ];
431
+
432
+ let done = false;
433
+ const finish = () => {
434
+ if (done) {
435
+ return;
436
+ }
437
+ done = true;
438
+ for (const animation of animations) {
439
+ animation.cancel();
440
+ }
441
+ out.hidden = true;
442
+ for (const panel of [out, into]) {
443
+ panel.style.position = "";
444
+ panel.style.top = "";
445
+ panel.style.insetInlineStart = "";
446
+ panel.style.width = "";
447
+ panel.style.transform = "";
448
+ }
449
+ this.style.position = "";
450
+ this.style.overflow = "";
451
+ this.style.height = "";
452
+ this.finishSlide = null;
453
+ };
454
+ this.finishSlide = finish;
455
+ animations[0].finished.then(finish, finish);
456
+ }
457
+ }
458
+
459
+ if (!customElements.get("blume-nav")) {
460
+ customElements.define("blume-nav", BlumeNav);
461
+ }
462
+ </script>