blume 1.6.1 → 1.6.3

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.
@@ -25,10 +25,13 @@ import {
25
25
  } from "pathe";
26
26
  import { glob } from "tinyglobby";
27
27
 
28
+ import { OPENAPI_PATH } from "../ai/api/paths.ts";
29
+ import { buildApiSpec } from "../ai/api/spec.ts";
28
30
  import { buildAskData } from "../ai/ask-data.ts";
29
31
  import { askBackendRuntimeDep, resolveAskBackend } from "../ai/ask.ts";
30
32
  import { buildRawMarkdown, markdownRoutePaths } from "../ai/markdown.ts";
31
33
  import { buildMcpData } from "../ai/mcp/data.ts";
34
+ import type { McpData } from "../ai/mcp/data.ts";
32
35
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
33
36
  import { normalizeBasePath } from "../core/base-path.ts";
34
37
  import { validateUsedComponents } from "../core/component-diagnostics.ts";
@@ -125,9 +128,15 @@ import {
125
128
  exampleSlug,
126
129
  islandMapTemplate,
127
130
  islandWrapperTemplate,
131
+ apiNavigationTemplate,
132
+ apiNotFoundTemplate,
133
+ apiPageTemplate,
134
+ apiPagesIndexTemplate,
135
+ apiSearchTemplate,
128
136
  mcpEndpointTemplate,
129
137
  mcpPageFile,
130
138
  mixedbreadSearchEndpointTemplate,
139
+ notFoundJsonTemplate,
131
140
  notFoundMarkdownTemplate,
132
141
  notFoundPageTemplate,
133
142
  ogEndpointTemplate,
@@ -1319,6 +1328,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1319
1328
  description: config.description,
1320
1329
  discovery: {
1321
1330
  agentReadability: config.seo.agentReadability,
1331
+ api: config.ai.api,
1322
1332
  llmsTxt: config.ai.llmsTxt.enabled,
1323
1333
  // Mirrors `buildSitemapFiles`: no site, no sitemap.
1324
1334
  sitemap: config.seo.sitemap && Boolean(config.deployment.site),
@@ -1505,20 +1515,32 @@ const planMcp = (
1505
1515
  type RuntimeModules = Map<RuntimeModuleId, string>;
1506
1516
 
1507
1517
  /**
1508
- * Publish the MCP data snapshot (`blume:mcp-data`) and write the server
1509
- * endpoint and discovery documents.
1518
+ * The agent data snapshot (`blume:mcp-data`) behind the MCP server and the
1519
+ * JSON docs API: built once per pass when either is on, published to the
1520
+ * runtime modules, and handed to both writers. Null when neither needs it.
1510
1521
  */
1511
- const writeMcpFiles = async (
1522
+ const publishAgentData = async (
1512
1523
  project: BlumeProject,
1524
+ plans: { api: ApiPlan; mcp: McpPlan },
1525
+ modules: RuntimeModules
1526
+ ): Promise<McpData | null> => {
1527
+ if (!(plans.mcp.enabled || plans.api.enabled)) {
1528
+ return null;
1529
+ }
1530
+ const data = await buildMcpData(project);
1531
+ modules.set("blume:mcp-data", JSON.stringify(data));
1532
+ return data;
1533
+ };
1534
+
1535
+ /** Write the MCP server endpoint and discovery documents. */
1536
+ const writeMcpFiles = async (
1513
1537
  plan: McpPlan,
1514
1538
  write: (path: string, content: string) => Promise<boolean>,
1515
- modules: RuntimeModules
1539
+ data: McpData | null
1516
1540
  ): Promise<void> => {
1517
- if (!plan.enabled) {
1541
+ if (!(plan.enabled && data)) {
1518
1542
  return;
1519
1543
  }
1520
- const data = await buildMcpData(project);
1521
- modules.set("blume:mcp-data", JSON.stringify(data));
1522
1544
  const discoveryInput = {
1523
1545
  base: data.base,
1524
1546
  name: data.name,
@@ -1542,6 +1564,129 @@ const writeMcpFiles = async (
1542
1564
  ]);
1543
1565
  };
1544
1566
 
1567
+ /** The resolved plan for the JSON docs API within a single generate pass. */
1568
+ interface ApiPlan {
1569
+ /**
1570
+ * Whether the `/api/` catch-all (JSON 404s) is written: server output, no
1571
+ * user page owning a rest route under `/api/`, and no content page served
1572
+ * from the `/api` namespace (the catch-all would outrank those pages).
1573
+ */
1574
+ catchAll: boolean;
1575
+ enabled: boolean;
1576
+ /** Whether the live endpoints (search) are written — server output only. */
1577
+ server: boolean;
1578
+ /**
1579
+ * Whether `/openapi.json` is generated: skipped when a `public/openapi.json`
1580
+ * or a user page owns the route, so a site can publish its own description.
1581
+ */
1582
+ spec: boolean;
1583
+ srcDir: string;
1584
+ }
1585
+
1586
+ /** Whether a user page pattern is a rest route under `/api/` (`/api/[...x]`). */
1587
+ const ownsApiRest = (page: { pattern: string }): boolean =>
1588
+ page.pattern.startsWith("/api/[");
1589
+
1590
+ /**
1591
+ * Whether a content route lives in the `/api` namespace — a docs section
1592
+ * about an API commonly does (`content/api/overview.md` → `/api/overview`).
1593
+ * Astro ranks `/api/[...path]` above the content catch-all (`/[...slug]`),
1594
+ * so the JSON 404 route would shadow those pages wherever routing decides
1595
+ * (the dev server, adapters that don't serve prerendered files first).
1596
+ */
1597
+ const contentUnderApi = (page: { route: string }): boolean =>
1598
+ page.route === "/api" || page.route.startsWith("/api/");
1599
+
1600
+ /**
1601
+ * Decide what the JSON docs API generates. The prerendered endpoints always
1602
+ * ride along when the feature is on (they live under Blume's own `/api/docs/`
1603
+ * namespace); the live ones need server output; the OpenAPI description yields
1604
+ * to one the project ships itself.
1605
+ */
1606
+ const planApi = (
1607
+ project: BlumeProject,
1608
+ srcDir: string,
1609
+ userPages: { pattern: string }[]
1610
+ ): ApiPlan => {
1611
+ const { config, context } = project;
1612
+ const server = config.deployment.output === "server";
1613
+ return {
1614
+ catchAll:
1615
+ server &&
1616
+ !userPages.some(ownsApiRest) &&
1617
+ !project.graph.pages.some(contentUnderApi),
1618
+ enabled: config.ai.api,
1619
+ server,
1620
+ spec:
1621
+ !routeIsTaken(userPages, project.graph.pages, OPENAPI_PATH) &&
1622
+ !existsSync(join(context.root, "public", "openapi.json")),
1623
+ srcDir,
1624
+ };
1625
+ };
1626
+
1627
+ /**
1628
+ * Write the JSON docs API: the prerendered page index, per-page documents, and
1629
+ * navigation; on server output the search endpoint and the `/api/` catch-all;
1630
+ * and the OpenAPI description of the whole agent-facing surface. The MCP
1631
+ * route reaches the description only when the server was actually planned (a
1632
+ * collision can disable it), so it never advertises an endpoint that isn't
1633
+ * there.
1634
+ */
1635
+ const writeApiFiles = async (
1636
+ project: BlumeProject,
1637
+ plan: ApiPlan,
1638
+ write: (path: string, content: string) => Promise<boolean>,
1639
+ data: McpData | null,
1640
+ mcp: McpPlan
1641
+ ): Promise<void> => {
1642
+ if (!(plan.enabled && data)) {
1643
+ return;
1644
+ }
1645
+ const { config } = project;
1646
+ const mcpRoute = mcp.enabled ? mcp.route : null;
1647
+ const apiDir = join(plan.srcDir, "pages", "api");
1648
+ const writes = [
1649
+ write(join(apiDir, "docs", "pages.json.ts"), apiPagesIndexTemplate()),
1650
+ write(
1651
+ join(apiDir, "docs", "pages", "[...route].json.ts"),
1652
+ apiPageTemplate()
1653
+ ),
1654
+ write(join(apiDir, "docs", "navigation.json.ts"), apiNavigationTemplate()),
1655
+ ];
1656
+ if (plan.server) {
1657
+ writes.push(write(join(apiDir, "docs", "search.ts"), apiSearchTemplate()));
1658
+ }
1659
+ if (plan.catchAll) {
1660
+ writes.push(
1661
+ write(
1662
+ join(apiDir, "[...path].ts"),
1663
+ apiNotFoundTemplate({ base: data.base, site: data.site })
1664
+ )
1665
+ );
1666
+ }
1667
+ if (plan.spec) {
1668
+ writes.push(
1669
+ write(
1670
+ join(plan.srcDir, "pages", "openapi.json.ts"),
1671
+ staticJsonEndpointTemplate(
1672
+ buildApiSpec({
1673
+ agentReadability: config.seo.agentReadability,
1674
+ base: data.base,
1675
+ description: config.description,
1676
+ llmsTxt: config.ai.llmsTxt.enabled,
1677
+ mcpRoute,
1678
+ name: config.title,
1679
+ search: plan.server,
1680
+ site: data.site,
1681
+ version: data.version,
1682
+ })
1683
+ )
1684
+ )
1685
+ );
1686
+ }
1687
+ await Promise.all(writes);
1688
+ };
1689
+
1545
1690
  /**
1546
1691
  * Decide whether to generate the playground's built-in CORS proxy endpoint.
1547
1692
  * Only the Blume renderer's playground with `proxy: true` needs it — a proxy
@@ -1661,10 +1806,12 @@ const writeAskFiles = async (
1661
1806
  /**
1662
1807
  * Write the default 404 page at Astro's reserved `src/pages/404.astro` path so
1663
1808
  * static builds emit `dist/404.html`, plus its Markdown twin at `404.md.ts`
1664
- * (`dist/404.md`) for agents that ask a missing URL for Markdown. Both are
1665
- * skipped when the project already owns `/404` (a custom `pages/404.astro` or
1666
- * a `404.md` content page), letting it be fully overridden without a route
1667
- * collision; `pruneOrphans` then removes any previously-generated copies.
1809
+ * (`dist/404.md`) for agents that ask a missing URL for Markdown and its JSON
1810
+ * twin at `404.json.ts` (`dist/404.json`, RFC 9457 problem details) for those
1811
+ * that ask for JSON. All three are skipped when the project already owns
1812
+ * `/404` (a custom `pages/404.astro` or a `404.md` content page), letting it
1813
+ * be fully overridden without a route collision; `pruneOrphans` then removes
1814
+ * any previously-generated copies.
1668
1815
  */
1669
1816
  const writeNotFoundPage = async (
1670
1817
  write: (path: string, content: string) => Promise<boolean>,
@@ -1678,6 +1825,7 @@ const writeNotFoundPage = async (
1678
1825
  await Promise.all([
1679
1826
  write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate()),
1680
1827
  write(join(srcDir, "pages", "404.md.ts"), notFoundMarkdownTemplate()),
1828
+ write(join(srcDir, "pages", "404.json.ts"), notFoundJsonTemplate()),
1681
1829
  ]);
1682
1830
  };
1683
1831
 
@@ -1866,6 +2014,11 @@ export const generateRuntime = async (
1866
2014
  const mcp = planMcp(project, srcDir, pages);
1867
2015
  pages.push(...mcp.discoveryPages);
1868
2016
 
2017
+ // The JSON docs API shares the MCP server's snapshot; build it once when
2018
+ // either is on.
2019
+ const api = planApi(project, srcDir, pages);
2020
+ const agentData = await publishAgentData(project, { api, mcp }, modules);
2021
+
1869
2022
  // The parsed OpenAPI specs behind the `blume:openapi` alias, also the source
1870
2023
  // of the proxy's origin allowlist below. The source parsed them during the
1871
2024
  // scan, so reading them here is free.
@@ -2027,7 +2180,8 @@ export const generateRuntime = async (
2027
2180
  )
2028
2181
  ),
2029
2182
  writeAskFiles(project, srcDir, write, modules),
2030
- writeMcpFiles(project, mcp, write, modules),
2183
+ writeMcpFiles(mcp, write, agentData),
2184
+ writeApiFiles(project, api, write, agentData, mcp),
2031
2185
  playgroundProxy.enabled
2032
2186
  ? write(playgroundProxy.entrypoint, playgroundProxyTemplate(proxyOrigins))
2033
2187
  : Promise.resolve(false),
@@ -2560,6 +2560,9 @@ const links = [
2560
2560
  ...(data.config.discovery.llmsTxt
2561
2561
  ? [{ href: href("/llms.txt"), label: nf.llms }]
2562
2562
  : []),
2563
+ ...(data.config.discovery.api
2564
+ ? [{ href: href("/openapi.json"), label: nf.api }]
2565
+ : []),
2563
2566
  ];
2564
2567
 
2565
2568
  const body = [
@@ -2584,6 +2587,160 @@ export function GET() {
2584
2587
  }
2585
2588
  `;
2586
2589
 
2590
+ /**
2591
+ * Generate `.blume/src/pages/404.json.ts`: the JSON twin of the default 404
2592
+ * page, prerendered to `dist/404.json` as RFC 9457 problem details. An agent
2593
+ * that asked for a missing page with `Accept: application/json` gets this body
2594
+ * with the 404 status instead of the HTML shell (Vercel server builds wire
2595
+ * that into the routing config, like the Markdown twin). Same recovery links
2596
+ * as the other variants, carried as `links` and spelled out in `resolution`.
2597
+ * Written alongside `404.astro` and skipped under the same rule.
2598
+ */
2599
+ export const notFoundJsonTemplate =
2600
+ (): string => `// Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
2601
+ import { problem } from "blume/ai/api/problem.ts";
2602
+ import { withBase } from "blume/components/islands/base-path.ts";
2603
+ import { absoluteUrl } from "blume/core/site-url.ts";
2604
+ import data from "blume:data";
2605
+
2606
+ export const prerender = true;
2607
+
2608
+ const nf = data.ui.notFound;
2609
+
2610
+ // Absolute for internal routes when the site is known; an external tab href
2611
+ // passes through untouched.
2612
+ const href = (path: string): string => {
2613
+ const based = withBase(path);
2614
+ return data.config.site && based.startsWith("/") && !based.startsWith("//")
2615
+ ? absoluteUrl(data.config.site, based)
2616
+ : based;
2617
+ };
2618
+
2619
+ // The recovery set of 404.astro: home, every top-level section (a tab links to
2620
+ // its resolved target), then the machine-readable indexes that exist.
2621
+ const links = [
2622
+ { href: href("/"), label: nf.home },
2623
+ ...data.navigation.tabs.map((tab) => ({
2624
+ href: href(tab.href ?? tab.path),
2625
+ label: tab.label,
2626
+ })),
2627
+ ...(data.config.discovery.sitemap
2628
+ ? [{ href: href("/sitemap.xml"), label: nf.sitemap }]
2629
+ : []),
2630
+ ...(data.config.discovery.llmsTxt
2631
+ ? [{ href: href("/llms.txt"), label: nf.llms }]
2632
+ : []),
2633
+ ...(data.config.discovery.api
2634
+ ? [{ href: href("/openapi.json"), label: nf.api }]
2635
+ : []),
2636
+ ];
2637
+
2638
+ const body = problem({
2639
+ code: "PAGE_NOT_FOUND",
2640
+ detail: nf.description,
2641
+ links,
2642
+ resolution: nf.suggestions + ": " + links.map((link) => link.href).join(", "),
2643
+ status: 404,
2644
+ title: nf.title,
2645
+ });
2646
+
2647
+ export function GET() {
2648
+ return new Response(JSON.stringify(body, null, 2) + "\\n", {
2649
+ headers: { "Content-Type": "application/problem+json; charset=utf-8" },
2650
+ });
2651
+ }
2652
+ `;
2653
+
2654
+ /**
2655
+ * Generate the prerendered JSON docs API endpoints under
2656
+ * `.blume/src/pages/api/docs/`: the page index (`pages.json`), one JSON
2657
+ * document per page (`pages/[...route].json`), and the navigation tree
2658
+ * (`navigation.json`). Each is a thin wrapper over `blume/ai/api/handlers.ts`
2659
+ * reading the same snapshot the MCP server serves (`blume:mcp-data`), so the
2660
+ * REST and MCP answers can never diverge.
2661
+ */
2662
+ export const apiPagesIndexTemplate = (): string =>
2663
+ `// Generated by Blume. Do not edit.
2664
+ import { pagesIndexResponse } from "blume/ai/api/handlers.ts";
2665
+ import data from "blume:mcp-data";
2666
+
2667
+ export const prerender = true;
2668
+
2669
+ export function GET() {
2670
+ return pagesIndexResponse(data);
2671
+ }
2672
+ `;
2673
+
2674
+ export const apiPageTemplate = (): string =>
2675
+ `// Generated by Blume. Do not edit.
2676
+ import { pageParams, pageResponse } from "blume/ai/api/handlers.ts";
2677
+ import data from "blume:mcp-data";
2678
+
2679
+ export const prerender = true;
2680
+
2681
+ export function getStaticPaths() {
2682
+ return pageParams(data);
2683
+ }
2684
+
2685
+ export function GET({ props }: { props: { route: string } }) {
2686
+ return pageResponse(data, props.route);
2687
+ }
2688
+ `;
2689
+
2690
+ export const apiNavigationTemplate = (): string =>
2691
+ `// Generated by Blume. Do not edit.
2692
+ import { navigationResponse } from "blume/ai/api/handlers.ts";
2693
+ import data from "blume:mcp-data";
2694
+
2695
+ export const prerender = true;
2696
+
2697
+ export function GET() {
2698
+ return navigationResponse(data);
2699
+ }
2700
+ `;
2701
+
2702
+ /**
2703
+ * Generate the live search endpoint (`.blume/src/pages/api/docs/search.ts`),
2704
+ * server output only: the REST twin of the MCP `search_docs` tool, over the
2705
+ * same snapshot and index.
2706
+ */
2707
+ export const apiSearchTemplate = (): string =>
2708
+ `// Generated by Blume. Do not edit.
2709
+ import type { APIRoute } from "astro";
2710
+ import { createSearchHandler } from "blume/ai/api/handlers.ts";
2711
+ import data from "blume:mcp-data";
2712
+
2713
+ export const prerender = false;
2714
+
2715
+ const handler = createSearchHandler(data);
2716
+
2717
+ export const GET: APIRoute = ({ request }) => handler(request);
2718
+ `;
2719
+
2720
+ /**
2721
+ * Generate the API namespace's catch-all (`.blume/src/pages/api/[...path].ts`),
2722
+ * server output only: any `/api/…` request no endpoint answers gets an RFC
2723
+ * 9457 problem document with the 404 status instead of the HTML not-found
2724
+ * page. Static segments always beat the rest parameter, so `/api/ask`, the
2725
+ * search proxy, and every prerendered docs endpoint keep winning. The site
2726
+ * context is baked in so the resolution links are absolute when the site is
2727
+ * known.
2728
+ */
2729
+ export const apiNotFoundTemplate = (context: {
2730
+ base: string;
2731
+ site: string | null;
2732
+ }): string =>
2733
+ `// Generated by Blume. Do not edit.
2734
+ import type { APIRoute } from "astro";
2735
+ import { apiNotFoundResponse } from "blume/ai/api/handlers.ts";
2736
+
2737
+ export const prerender = false;
2738
+
2739
+ const context = ${JSON.stringify(context)};
2740
+
2741
+ export const ALL: APIRoute = ({ request }) => apiNotFoundResponse(request, context);
2742
+ `;
2743
+
2587
2744
  /** The literal Astro hydration directive for an island's client mode. */
2588
2745
  const islandDirective = (spec: IslandSpec): string =>
2589
2746
  spec.client === "only"
@@ -334,18 +334,20 @@ const emitVercelNegotiation = async (
334
334
  // endpoint stamps it on dev/server-rendered responses itself.
335
335
  const rawMarkdown = await buildRawMarkdown(project);
336
336
  const home = rawMarkdown["/"];
337
- // The Markdown 404 routes point at the prerendered `404.md`; only wire them
338
- // when the build actually emitted it (a project that owns `/404` gets none).
339
- const notFoundMarkdown = existsSync(
340
- join(root, ".vercel", "output", "static", "404.md")
341
- );
337
+ // The Markdown and JSON 404 routes point at the prerendered twins; only
338
+ // wire each when the build actually emitted it (a project that owns `/404`
339
+ // gets none).
340
+ const staticDir = join(root, ".vercel", "output", "static");
342
341
  const injected = injectNegotiationRoutes(
343
342
  await readFile(configPath, "utf-8"),
344
343
  routePaths,
345
344
  buildHomeLinkHeader(config, routePaths),
346
345
  overrides,
347
346
  home ? markdownTokenCount(agentMarkdown(home)) : undefined,
348
- notFoundMarkdown
347
+ {
348
+ json: existsSync(join(staticDir, "404.json")),
349
+ markdown: existsSync(join(staticDir, "404.md")),
350
+ }
349
351
  );
350
352
  if (injected === null) {
351
353
  logger.warn(
@@ -10,10 +10,7 @@
10
10
  // examples are all supported. The source is highlighted with the same Shiki
11
11
  // setup as ordinary code fences.
12
12
  import data from "blume:data";
13
- import {
14
- CODE_PADDING_BLOCK_REM,
15
- FLUSH_CODE_PADDING_TOP_REM,
16
- } from "../../theme/code-block-padding.ts";
13
+ import { CODE_PADDING_BLOCK_REM } from "../../theme/code-block-padding.ts";
17
14
 
18
15
  import { highlightCode } from "../../markdown/index.ts";
19
16
  import { withBase } from "../islands/base-path.ts";
@@ -63,11 +60,10 @@ const codeHtml = entry
63
60
  // readers aren't hurt by the cap, since the source scrolls at any height.
64
61
  const LINE_PX = 21;
65
62
  const REM_PX = 16;
66
- // The pre's vertical padding, from the same constants the theme emits: the
67
- // copy-button strip on top (the pane is a flush block inside tabs) and the
68
- // plain inset below. The tab panel and the pre carry no border of their own.
69
- const PADDING_PX =
70
- (FLUSH_CODE_PADDING_TOP_REM + CODE_PADDING_BLOCK_REM) * REM_PX;
63
+ // The pre's vertical padding, from the same constant the theme emits: the
64
+ // plain inset above and below (the pane is a flush block inside tabs, with no
65
+ // language bar). The tab panel and the pre carry no border of their own.
66
+ const PADDING_PX = 2 * CODE_PADDING_BLOCK_REM * REM_PX;
71
67
  const ESTIMATE_MAX_PX = 400;
72
68
  // The floor also clamps the measured height client-side; it rides along on the
73
69
  // iframe as `data-blume-min-pane` so the script and this estimate can't drift.
@@ -62,19 +62,34 @@ const useDropdown = dropdown && !inline;
62
62
  data-sync={sync ? "true" : "false"}
63
63
  data-sync-key={syncKey}
64
64
  >
65
- <div
66
- class:list={[
67
- inline ? "not-prose" : "",
68
- useDropdown ? "" : "overflow-x-auto",
69
- ]}
70
- >
65
+ {/*
66
+ Header row: the scrolling tab strip (or the dropdown) grows, and an
67
+ actions slot sits fixed at the inline end, outside the scroller so it
68
+ never rides along with an overflowing strip. The docs layout fills it
69
+ with one copy button when every panel is a bare code block (CodeGroup,
70
+ ts2js pairs) — the strip is that group's chrome, so the button belongs
71
+ there rather than painted over the first line of each block. Empty
72
+ otherwise, and hidden so it adds no padding.
73
+ */}
74
+ <div class:list={["flex", inline ? "not-prose" : ""]}>
75
+ <div class:list={["min-w-0 grow", useDropdown ? "" : "overflow-x-auto"]}>
76
+ <div
77
+ class:list={[
78
+ useDropdown || borderBottom ? "border-border border-b" : "",
79
+ useDropdown ? "p-3" : inline ? "flex gap-4" : "flex gap-4 px-3",
80
+ ]}
81
+ data-blume-tablist
82
+ role={useDropdown ? undefined : "tablist"}
83
+ >
84
+ </div>
85
+ </div>
71
86
  <div
72
87
  class:list={[
88
+ "flex shrink-0 items-center gap-2 ps-2 empty:hidden",
89
+ inline ? "" : "pe-3",
73
90
  useDropdown || borderBottom ? "border-border border-b" : "",
74
- useDropdown ? "p-3" : inline ? "flex gap-4" : "flex gap-4 px-3",
75
91
  ]}
76
- data-blume-tablist
77
- role={useDropdown ? undefined : "tablist"}
92
+ data-blume-tab-actions
78
93
  >
79
94
  </div>
80
95
  </div>