blume 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/cli/index.js +1170 -820
  2. package/dist/cli/index.js.map +32 -27
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +154 -41
  6. package/dist/types/core/types.d.ts +7 -6
  7. package/dist/types/migrate/mintlify/config.d.ts +14 -0
  8. package/docs/01-quickstart.mdx +6 -2
  9. package/docs/02-deployment.mdx +3 -1
  10. package/docs/advanced/api-reference.mdx +37 -23
  11. package/docs/advanced/bridge.mdx +76 -0
  12. package/docs/advanced/custom-pages.mdx +3 -1
  13. package/docs/advanced/meta.ts +8 -1
  14. package/docs/advanced/migrate.mdx +123 -0
  15. package/docs/configuration/ai.mdx +3 -1
  16. package/docs/configuration/analytics.mdx +3 -1
  17. package/docs/configuration/export.mdx +6 -2
  18. package/docs/configuration/index.mdx +1 -1
  19. package/docs/configuration/seo.mdx +3 -1
  20. package/docs/content/components.mdx +55 -2
  21. package/docs/content/i18n.mdx +6 -2
  22. package/docs/content/islands.mdx +6 -2
  23. package/docs/content/meta.mdx +3 -1
  24. package/docs/content/syntax.mdx +40 -14
  25. package/docs/index.mdx +2 -2
  26. package/docs/reference/cli.mdx +29 -1
  27. package/docs/reference/frontmatter.mdx +5 -0
  28. package/package.json +11 -1
  29. package/src/astro/generate.ts +18 -9
  30. package/src/astro/templates.ts +28 -4
  31. package/src/cli/commands/build.ts +107 -63
  32. package/src/cli/commands/check.ts +20 -0
  33. package/src/cli/dev-lock.ts +13 -5
  34. package/src/cli/prepare.ts +3 -0
  35. package/src/components/BlumePage.astro +6 -0
  36. package/src/components/Icon.astro +13 -10
  37. package/src/components/content/ApiField.astro +75 -0
  38. package/src/components/content/ParamField.astro +39 -0
  39. package/src/components/content/RequestField.astro +23 -0
  40. package/src/components/content/ResponseField.astro +23 -0
  41. package/src/components/content/Step.astro +1 -1
  42. package/src/components/layout/Breadcrumbs.astro +7 -2
  43. package/src/components/layout/NavTree.astro +24 -8
  44. package/src/components/layout/RootLayout.astro +56 -34
  45. package/src/components/layout/Search.astro +1 -1
  46. package/src/components/openapi/ApiOverview.astro +84 -0
  47. package/src/components/openapi/MethodBadge.astro +28 -0
  48. package/src/components/openapi/Operation.astro +140 -0
  49. package/src/components/openapi/ParametersTable.astro +97 -0
  50. package/src/components/openapi/RequestBody.astro +58 -0
  51. package/src/components/openapi/RequestPanel.astro +169 -0
  52. package/src/components/openapi/Responses.astro +91 -0
  53. package/src/components/openapi/SchemaProperty.astro +118 -0
  54. package/src/components/openapi/SchemaTable.astro +86 -0
  55. package/src/components/openapi/helpers.ts +238 -0
  56. package/src/components/openapi/panel.ts +59 -0
  57. package/src/components/openapi/snippets.ts +201 -0
  58. package/src/core/builtin-tags.ts +5 -0
  59. package/src/core/data.ts +2 -0
  60. package/src/core/graph.ts +0 -3
  61. package/src/core/nav-diagnostics.ts +2 -12
  62. package/src/core/navigation.ts +0 -10
  63. package/src/core/project-graph.ts +5 -1
  64. package/src/core/project.ts +25 -3
  65. package/src/core/schema.ts +47 -14
  66. package/src/core/sources/mintlify.ts +1 -1
  67. package/src/core/sources/resolve.ts +28 -6
  68. package/src/core/types.ts +7 -7
  69. package/src/migrate/mintlify/config.ts +190 -97
  70. package/src/migrate/mintlify/content.ts +24 -2
  71. package/src/migrate/mintlify/index.ts +76 -2
  72. package/src/migrate/mintlify/transform.ts +2 -0
  73. package/src/openapi/model.ts +174 -0
  74. package/src/openapi/parse.ts +48 -0
  75. package/src/openapi/references.ts +164 -0
  76. package/src/openapi/render-mdx.ts +76 -0
  77. package/src/openapi/scalar.ts +15 -103
  78. package/src/openapi/source.ts +140 -0
  79. package/src/registry/eject.ts +15 -2
  80. package/src/theme/chrome-icons.ts +22 -0
  81. package/src/theme/icons.ts +151 -161
@@ -0,0 +1,75 @@
1
+ ---
2
+ // Shared field row behind the Mintlify-compat <ParamField>/<ResponseField>/
3
+ // <RequestField> components: a labeled name + type + badges, with the field's
4
+ // description rendered from the slot (which may hold rich MDX, including nested
5
+ // <Expandable> or further fields). Styled to match the native OpenAPI reference
6
+ // rows (src/components/openapi/), so a migrated site reads consistently.
7
+ interface Props {
8
+ default?: unknown;
9
+ deprecated?: unknown;
10
+ location?: string;
11
+ name?: string;
12
+ required?: unknown;
13
+ type?: string;
14
+ }
15
+
16
+ const {
17
+ default: defaultValue,
18
+ deprecated,
19
+ location,
20
+ name,
21
+ required,
22
+ type,
23
+ } = Astro.props;
24
+
25
+ // Booleans arrive as MDX shorthand (`required`) or strings (`required="true"`).
26
+ const isTrue = (value: unknown): boolean => value === true || value === "true";
27
+ const hasDefault =
28
+ defaultValue !== undefined && defaultValue !== null && defaultValue !== "";
29
+ const hasDescription = Astro.slots.has("default");
30
+ ---
31
+
32
+ <div class="not-prose my-2 rounded-blume border border-border px-4 py-3">
33
+ <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
34
+ {name && <code class="font-mono text-foreground text-sm">{name}</code>}
35
+ {
36
+ location && (
37
+ <span class="rounded bg-muted px-1.5 py-0.5 font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide">
38
+ {location}
39
+ </span>
40
+ )
41
+ }
42
+ {type && <span class="text-muted-foreground text-xs">{type}</span>}
43
+ {
44
+ isTrue(required) && (
45
+ <span class="font-medium text-[0.625rem] text-red-600 uppercase tracking-wide dark:text-red-400">
46
+ required
47
+ </span>
48
+ )
49
+ }
50
+ {
51
+ isTrue(deprecated) && (
52
+ <span class="font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide line-through">
53
+ deprecated
54
+ </span>
55
+ )
56
+ }
57
+ </div>
58
+ {
59
+ hasDefault && (
60
+ <div class="mt-1 text-muted-foreground text-xs">
61
+ Default:{" "}
62
+ <code class="rounded bg-muted px-1 py-0.5 text-foreground">
63
+ {String(defaultValue)}
64
+ </code>
65
+ </div>
66
+ )
67
+ }
68
+ {
69
+ hasDescription && (
70
+ <div class="mt-1.5 text-muted-foreground text-sm [&>:first-child]:mt-0 [&>:last-child]:mb-0">
71
+ <slot />
72
+ </div>
73
+ )
74
+ }
75
+ </div>
@@ -0,0 +1,39 @@
1
+ ---
2
+ // Mintlify-compat <ParamField>. Mintlify encodes the parameter's location in the
3
+ // attribute name (`<ParamField path="id" />`, `query`, `header`, `body`); the
4
+ // attribute's value is the field name. Falls back to a plain `name` prop.
5
+ import ApiField from "./ApiField.astro";
6
+
7
+ const {
8
+ body,
9
+ default: defaultValue,
10
+ deprecated,
11
+ header,
12
+ name,
13
+ path,
14
+ query,
15
+ required,
16
+ type,
17
+ } = Astro.props;
18
+
19
+ const locations = [
20
+ { key: "path", value: path },
21
+ { key: "query", value: query },
22
+ { key: "header", value: header },
23
+ { key: "body", value: body },
24
+ ] as const;
25
+ const located = locations.find((entry) => typeof entry.value === "string");
26
+ const fieldName = located ? located.value : name;
27
+ const location = located?.key;
28
+ ---
29
+
30
+ <ApiField
31
+ default={defaultValue}
32
+ deprecated={deprecated}
33
+ location={location}
34
+ name={fieldName}
35
+ required={required}
36
+ type={type}
37
+ >
38
+ <slot />
39
+ </ApiField>
@@ -0,0 +1,23 @@
1
+ ---
2
+ // Mintlify-compat <RequestField>: same shape as <ResponseField> — a named field
3
+ // (`name`, `type`, `required`, `default`, `deprecated`) with a slotted body.
4
+ import ApiField from "./ApiField.astro";
5
+
6
+ const {
7
+ default: defaultValue,
8
+ deprecated,
9
+ name,
10
+ required,
11
+ type,
12
+ } = Astro.props;
13
+ ---
14
+
15
+ <ApiField
16
+ default={defaultValue}
17
+ deprecated={deprecated}
18
+ name={name}
19
+ required={required}
20
+ type={type}
21
+ >
22
+ <slot />
23
+ </ApiField>
@@ -0,0 +1,23 @@
1
+ ---
2
+ // Mintlify-compat <ResponseField>: a named field (`name`, `type`, `required`,
3
+ // `default`, `deprecated`) with its description in the slot.
4
+ import ApiField from "./ApiField.astro";
5
+
6
+ const {
7
+ default: defaultValue,
8
+ deprecated,
9
+ name,
10
+ required,
11
+ type,
12
+ } = Astro.props;
13
+ ---
14
+
15
+ <ApiField
16
+ default={defaultValue}
17
+ deprecated={deprecated}
18
+ name={name}
19
+ required={required}
20
+ type={type}
21
+ >
22
+ <slot />
23
+ </ApiField>
@@ -18,7 +18,7 @@ const canRenderIcon =
18
18
  icon !== undefined &&
19
19
  (/^\s*<svg[\s\S]*<\/svg>\s*$/u.test(icon) ||
20
20
  isImageIcon(icon) ||
21
- hasIcon(icon, iconType));
21
+ hasIcon(icon, { iconType }));
22
22
  ---
23
23
 
24
24
  <div
@@ -4,9 +4,11 @@ import type { Crumb } from "./nav-utils.ts";
4
4
  interface Props {
5
5
  /** Full breadcrumb trail from the site root to the current page. */
6
6
  crumbs: Crumb[];
7
+ /** Left-align full width (for the wide API layout) instead of the prose measure. */
8
+ wide?: boolean;
7
9
  }
8
10
 
9
- const { crumbs } = Astro.props;
11
+ const { crumbs, wide = false } = Astro.props;
10
12
 
11
13
  // The built-in shows a single "eyebrow" crumb — the parent group — rather than
12
14
  // the whole trail. An override receives the complete list and can render more.
@@ -17,7 +19,10 @@ const eyebrowCrumb = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null;
17
19
  eyebrowCrumb && (
18
20
  <nav
19
21
  aria-label="Breadcrumb"
20
- class="mx-auto mb-2 max-w-[42rem] text-muted-foreground text-sm"
22
+ class:list={[
23
+ "mb-2 text-muted-foreground text-sm",
24
+ wide ? "max-w-none" : "mx-auto max-w-[42rem]",
25
+ ]}
21
26
  >
22
27
  {eyebrowCrumb.route ? (
23
28
  <a class="hover:text-foreground" href={eyebrowCrumb.route}>
@@ -31,8 +31,24 @@ const {
31
31
  root = depth === 0,
32
32
  } = Astro.props;
33
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";
34
+ const badgeBase =
35
+ "shrink-0 rounded-full px-1.5 py-0.5 font-medium text-[0.65rem] leading-none";
36
+ const badgeClass = `${badgeBase} bg-muted text-muted-foreground`;
37
+ // HTTP-method badges (from an OpenAPI reference's sidebar) are colour-coded;
38
+ // every other badge keeps the neutral style.
39
+ const METHOD_BADGE: Record<string, string> = {
40
+ DELETE: "bg-red-500/15 text-red-700 dark:text-red-300",
41
+ GET: "bg-green-500/15 text-green-700 dark:text-green-300",
42
+ HEAD: "bg-muted text-muted-foreground",
43
+ OPTIONS: "bg-muted text-muted-foreground",
44
+ PATCH: "bg-yellow-500/20 text-yellow-800 dark:text-yellow-300",
45
+ POST: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
46
+ PUT: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
47
+ };
48
+ const badgeClassFor = (badge: string): string => {
49
+ const method = METHOD_BADGE[badge.toUpperCase()];
50
+ return method ? `${badgeBase} ${method}` : badgeClass;
51
+ };
36
52
  const deprecatedClass =
37
53
  "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
54
 
@@ -154,7 +170,7 @@ const initialId =
154
170
  />
155
171
  )}
156
172
  <span class="min-w-0 flex-1 truncate">{item.label}</span>
157
- {item.badge && <span class={badgeClass}>{item.badge}</span>}
173
+ {item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
158
174
  {item.deprecated && (
159
175
  <span class={deprecatedClass}>deprecated</span>
160
176
  )}
@@ -188,7 +204,7 @@ const initialId =
188
204
  />
189
205
  )}
190
206
  <span class="min-w-0 flex-1 truncate">{item.label}</span>
191
- {item.badge && <span class={badgeClass}>{item.badge}</span>}
207
+ {item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
192
208
  <Icon
193
209
  class="shrink-0 text-muted-foreground"
194
210
  name="chevron-right"
@@ -224,7 +240,7 @@ const initialId =
224
240
  )}
225
241
  <span class="min-w-0 flex-1 truncate">{item.label}</span>
226
242
  {item.badge && (
227
- <span class={badgeClass}>{item.badge}</span>
243
+ <span class={badgeClassFor(item.badge)}>{item.badge}</span>
228
244
  )}
229
245
  </a>
230
246
  ) : (
@@ -238,7 +254,7 @@ const initialId =
238
254
  )}
239
255
  <span class="min-w-0 flex-1 truncate">{item.label}</span>
240
256
  {item.badge && (
241
- <span class={badgeClass}>{item.badge}</span>
257
+ <span class={badgeClassFor(item.badge)}>{item.badge}</span>
242
258
  )}
243
259
  </>
244
260
  )}
@@ -279,7 +295,7 @@ const initialId =
279
295
  )}
280
296
  <span class="min-w-0 truncate">{item.label}</span>
281
297
  {item.badge && (
282
- <span class={`ml-auto ${badgeClass}`}>{item.badge}</span>
298
+ <span class={`ml-auto ${badgeClassFor(item.badge)}`}>{item.badge}</span>
283
299
  )}
284
300
  </a>
285
301
  ) : (
@@ -293,7 +309,7 @@ const initialId =
293
309
  )}
294
310
  <span class="min-w-0 truncate">{item.label}</span>
295
311
  {item.badge && (
296
- <span class={`ml-auto ${badgeClass}`}>{item.badge}</span>
312
+ <span class={`ml-auto ${badgeClassFor(item.badge)}`}>{item.badge}</span>
297
313
  )}
298
314
  </>
299
315
  )}
@@ -200,9 +200,19 @@ const searchLocale =
200
200
  localeSwitch && localeSwitch.length > 1 ? locale : undefined;
201
201
  // TOC entries: the configured heading range, or none when the TOC is disabled
202
202
  // (an empty list makes TableOfContents render nothing).
203
- const tocHeadings = toc.enabled
204
- ? headings.filter((h) => h.depth >= toc.minLevel && h.depth <= toc.maxLevel)
205
- : [];
203
+ // API operation pages own a two-column body (docs + request panel) and their own
204
+ // right rail, so they drop the table of contents and widen the content column.
205
+ const isApiOperation = pageType === "openapi-operation";
206
+ const tocHeadings =
207
+ toc.enabled && !isApiOperation
208
+ ? headings.filter((h) => h.depth >= toc.minLevel && h.depth <= toc.maxLevel)
209
+ : [];
210
+ const gridClass = isApiOperation
211
+ ? "lg:grid-cols-[17.5rem_minmax(0,1fr)]"
212
+ : "lg:grid-cols-[17.5rem_minmax(0,1fr)] xl:grid-cols-[17.5rem_minmax(0,1fr)_17.5rem]";
213
+ const articleClass = isApiOperation
214
+ ? "prose max-w-none"
215
+ : "prose mx-auto max-w-[42rem]";
206
216
  const pageTitle = page.title ? `${page.title} - ${site.title}` : site.title;
207
217
  const description = page.description ?? site.description;
208
218
 
@@ -331,7 +341,7 @@ const bannerScript = banner?.dismissible
331
341
  >
332
342
  <slot name="ask" slot="ask" />
333
343
  </HeaderSlot>
334
- <div class="mx-auto grid grid-cols-1 items-start lg:grid-cols-[17.5rem_minmax(0,1fr)] xl:grid-cols-[17.5rem_minmax(0,1fr)_17.5rem]">
344
+ <div class:list={["mx-auto grid grid-cols-1 items-start", gridClass]}>
335
345
  <aside
336
346
  aria-label="Primary"
337
347
  class="fixed top-16 start-0 z-[35] h-[calc(100dvh-4rem)] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:z-auto lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4"
@@ -354,47 +364,59 @@ const bannerScript = banner?.dismissible
354
364
  </nav>
355
365
  </aside>
356
366
  <main class="min-w-0 px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
357
- <BreadcrumbsSlot crumbs={crumbs} />
367
+ <BreadcrumbsSlot crumbs={crumbs} wide={isApiOperation} />
358
368
  <TableOfContentsSlot
359
369
  headings={tocHeadings}
360
370
  title={strings.toc.title}
361
371
  variant="mobile"
362
372
  />
363
373
  <PageHeaderSlot headings={tocHeadings} page={page} route={page.route} />
364
- <article class="prose mx-auto max-w-[42rem]" dir={contentDir}>
374
+ <article class:list={[articleClass]} dir={contentDir}>
365
375
  <slot />
366
376
  </article>
367
- <PageFooterSlot headings={tocHeadings} page={page} route={page.route} />
368
377
  {
369
- formattedLastModified && (
370
- <p class="mx-auto mt-10 max-w-[42rem] text-muted-foreground text-sm">
371
- {strings.page.lastUpdated} {formattedLastModified}
372
- </p>
378
+ !isApiOperation && (
379
+ <>
380
+ <PageFooterSlot
381
+ headings={tocHeadings}
382
+ page={page}
383
+ route={page.route}
384
+ />
385
+ {formattedLastModified && (
386
+ <p class="mx-auto mt-10 max-w-[42rem] text-muted-foreground text-sm">
387
+ {strings.page.lastUpdated} {formattedLastModified}
388
+ </p>
389
+ )}
390
+ {feedback && <FeedbackSlot strings={strings.feedback} />}
391
+ <PaginationSlot next={next} prev={prev} strings={strings.page} />
392
+ </>
373
393
  )
374
394
  }
375
- {feedback && <FeedbackSlot strings={strings.feedback} />}
376
- <PaginationSlot next={next} prev={prev} strings={strings.page} />
377
395
  </main>
378
- <aside
379
- aria-label={strings.toc.title}
380
- class="sticky top-16 hidden h-[calc(100dvh-4rem)] overflow-y-auto px-4 pt-6 pb-10 text-sm xl:block"
381
- >
382
- <TableOfContentsSlot
383
- headings={tocHeadings}
384
- title={strings.toc.title}
385
- variant="desktop"
386
- />
387
- <PageActions
388
- askEnabled={askEnabled}
389
- editUrl={editUrl}
390
- exportEpub={exportEpub}
391
- exportPdf={exportPdf}
392
- mcpName={mcp?.name}
393
- mcpUrl={mcpUrl}
394
- route={page.route}
395
- strings={strings.actions}
396
- />
397
- </aside>
396
+ {
397
+ !isApiOperation && (
398
+ <aside
399
+ aria-label={strings.toc.title}
400
+ class="sticky top-16 hidden h-[calc(100dvh-4rem)] overflow-y-auto px-4 pt-6 pb-10 text-sm xl:block"
401
+ >
402
+ <TableOfContentsSlot
403
+ headings={tocHeadings}
404
+ title={strings.toc.title}
405
+ variant="desktop"
406
+ />
407
+ <PageActions
408
+ askEnabled={askEnabled}
409
+ editUrl={editUrl}
410
+ exportEpub={exportEpub}
411
+ exportPdf={exportPdf}
412
+ mcpName={mcp?.name}
413
+ mcpUrl={mcpUrl}
414
+ route={page.route}
415
+ strings={strings.actions}
416
+ />
417
+ </aside>
418
+ )
419
+ }
398
420
  </div>
399
421
  <FooterSlot navigation={navigation} site={site} ui={strings} />
400
422
  {
@@ -414,7 +436,7 @@ const bannerScript = banner?.dismissible
414
436
  type="button"
415
437
  ></button>
416
438
  <script>
417
- import { icons } from "../../theme/icons.ts";
439
+ import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
418
440
  // Registers the <blume-mermaid> custom element (emitted by ```mermaid
419
441
  // fences). Mermaid itself is lazy-loaded only on pages that use a diagram.
420
442
  import "../content/mermaid-element.ts";
@@ -138,7 +138,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
138
138
  />
139
139
 
140
140
  <script>
141
- import { icons } from "../../theme/icons.ts";
141
+ import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
142
142
  import {
143
143
  escapeHtml,
144
144
  highlight,
@@ -0,0 +1,84 @@
1
+ ---
2
+ import specs from "blume:openapi";
3
+ import MethodBadge from "./MethodBadge.astro";
4
+
5
+ interface Props {
6
+ source: string;
7
+ }
8
+
9
+ const { source } = Astro.props;
10
+ const spec = specs[source];
11
+ const operations = Object.values(spec?.operations ?? {});
12
+ const servers =
13
+ ((spec?.document ?? {}) as { servers?: { url?: string }[] }).servers ?? [];
14
+
15
+ // Tags in declared order, then any operation tag not listed under `tags`.
16
+ const declaredSlugs = new Set(spec?.tags.map((tag) => tag.slug));
17
+ const extraSlugs = new Set<string>();
18
+ const extraTags: { slug: string; name: string; description: string }[] = [];
19
+ for (const operation of operations) {
20
+ if (!(declaredSlugs.has(operation.tagSlug) || extraSlugs.has(operation.tagSlug))) {
21
+ extraSlugs.add(operation.tagSlug);
22
+ extraTags.push({ description: "", name: operation.tag, slug: operation.tagSlug });
23
+ }
24
+ }
25
+ const sections = [...(spec?.tags ?? []), ...extraTags];
26
+ ---
27
+
28
+ {
29
+ spec && (
30
+ <div>
31
+ {spec.version && (
32
+ <div class="not-prose mb-4 text-muted-foreground text-sm">
33
+ Version {spec.version}
34
+ </div>
35
+ )}
36
+ {servers.length > 0 && (
37
+ <div class="not-prose mb-8 flex flex-wrap items-center gap-2">
38
+ <span class="text-muted-foreground text-xs">Base URL</span>
39
+ {servers.map((server) => (
40
+ <code class="rounded bg-muted px-2 py-0.5 text-foreground text-xs">
41
+ {server.url}
42
+ </code>
43
+ ))}
44
+ </div>
45
+ )}
46
+ {sections.map((tag) => {
47
+ const ops = operations.filter(
48
+ (operation) => operation.tagSlug === tag.slug
49
+ );
50
+ if (ops.length === 0) {
51
+ return null;
52
+ }
53
+ return (
54
+ <section class="mb-8">
55
+ <h2 class="mb-1 font-semibold text-foreground text-xl">
56
+ {tag.name}
57
+ </h2>
58
+ {tag.description && (
59
+ <div class="mb-3 text-muted-foreground text-sm" set:text={tag.description} />
60
+ )}
61
+ <ul class="not-prose flex list-none flex-col gap-2 p-0">
62
+ {ops.map((operation) => (
63
+ <li>
64
+ <a
65
+ class="flex items-center gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
66
+ href={operation.route}
67
+ >
68
+ <MethodBadge method={operation.method} />
69
+ <span class="font-medium text-foreground text-sm">
70
+ {operation.summary || operation.path}
71
+ </span>
72
+ <code class="ml-auto hidden text-muted-foreground text-xs sm:inline">
73
+ {operation.path}
74
+ </code>
75
+ </a>
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ </section>
80
+ );
81
+ })}
82
+ </div>
83
+ )
84
+ }
@@ -0,0 +1,28 @@
1
+ ---
2
+ interface Props {
3
+ method: string;
4
+ class?: string;
5
+ }
6
+
7
+ const { method, class: className } = Astro.props;
8
+ const upper = method.toUpperCase();
9
+
10
+ const COLORS: Record<string, string> = {
11
+ DELETE: "bg-red-500/15 text-red-700 dark:text-red-300",
12
+ GET: "bg-green-500/15 text-green-700 dark:text-green-300",
13
+ HEAD: "bg-muted text-muted-foreground",
14
+ OPTIONS: "bg-muted text-muted-foreground",
15
+ PATCH: "bg-yellow-500/20 text-yellow-800 dark:text-yellow-300",
16
+ POST: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
17
+ PUT: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
18
+ };
19
+ const color = COLORS[upper] ?? "bg-muted text-muted-foreground";
20
+ ---
21
+
22
+ <span
23
+ class:list={[
24
+ "not-prose inline-flex items-center rounded-md px-2 py-0.5 font-mono font-semibold text-xs uppercase tracking-wide",
25
+ color,
26
+ className,
27
+ ]}>{upper}</span
28
+ >
@@ -0,0 +1,140 @@
1
+ ---
2
+ import specs from "blume:openapi";
3
+ import type { SchemaLike } from "./helpers.ts";
4
+ import { buildRequestSample, sampleLanguages } from "./snippets.ts";
5
+ import MethodBadge from "./MethodBadge.astro";
6
+ import ParametersTable from "./ParametersTable.astro";
7
+ import RequestBody from "./RequestBody.astro";
8
+ import RequestPanel from "./RequestPanel.astro";
9
+ import Responses from "./Responses.astro";
10
+
11
+ interface Props {
12
+ source: string;
13
+ id: string;
14
+ }
15
+
16
+ interface ParamLike {
17
+ $ref?: string;
18
+ name?: string;
19
+ in?: string;
20
+ description?: string;
21
+ required?: boolean;
22
+ deprecated?: boolean;
23
+ schema?: SchemaLike;
24
+ example?: unknown;
25
+ }
26
+
27
+ interface MediaTypeLike {
28
+ schema?: SchemaLike;
29
+ example?: unknown;
30
+ }
31
+
32
+ interface FullOperation {
33
+ summary?: string;
34
+ description?: string;
35
+ deprecated?: boolean;
36
+ parameters?: ParamLike[];
37
+ requestBody?: {
38
+ description?: string;
39
+ required?: boolean;
40
+ content?: Record<string, MediaTypeLike>;
41
+ };
42
+ responses?: Record<
43
+ string,
44
+ { description?: string; content?: Record<string, MediaTypeLike> }
45
+ >;
46
+ }
47
+
48
+ const { source, id } = Astro.props;
49
+ const spec = specs[source];
50
+ const ref = spec?.operations[id];
51
+
52
+ const doc = (spec?.document ?? {}) as {
53
+ paths?: Record<string, Record<string, unknown> & { parameters?: ParamLike[] }>;
54
+ components?: {
55
+ schemas?: Record<string, SchemaLike>;
56
+ parameters?: Record<string, ParamLike>;
57
+ };
58
+ servers?: { url?: string }[];
59
+ };
60
+ const pathItem = ref ? doc.paths?.[ref.path] : undefined;
61
+ const operation = (
62
+ pathItem && ref ? pathItem[ref.method] : undefined
63
+ ) as FullOperation | undefined;
64
+
65
+ const schemas = doc.components?.schemas ?? {};
66
+ const componentParams = doc.components?.parameters ?? {};
67
+ const PARAM_REF = /#\/components\/parameters\/(?<name>[^/]+)$/u;
68
+ const resolveParam = (param: ParamLike): ParamLike => {
69
+ if (typeof param.$ref === "string") {
70
+ const name = PARAM_REF.exec(param.$ref)?.groups?.name;
71
+ return (name && componentParams[name]) || param;
72
+ }
73
+ return param;
74
+ };
75
+ const params = [
76
+ ...(pathItem?.parameters ?? []),
77
+ ...(operation?.parameters ?? []),
78
+ ].map(resolveParam);
79
+
80
+ const sample =
81
+ ref && operation
82
+ ? buildRequestSample(
83
+ { parameters: params, requestBody: operation.requestBody },
84
+ ref.method,
85
+ ref.path,
86
+ doc.servers ?? [],
87
+ schemas
88
+ )
89
+ : null;
90
+ const languages = sampleLanguages(spec?.codeSamples ?? []);
91
+ ---
92
+
93
+ {
94
+ !(spec && ref && operation) ? (
95
+ <div class="text-muted-foreground">This API operation could not be found.</div>
96
+ ) : (
97
+ <div class="not-prose">
98
+ <div class="mb-6 flex flex-wrap items-center gap-3">
99
+ <MethodBadge method={ref.method} />
100
+ <code class="break-all font-mono text-foreground text-sm">
101
+ {ref.path}
102
+ </code>
103
+ {operation.deprecated && (
104
+ <span class="font-medium text-[0.625rem] text-orange-600 uppercase tracking-wide dark:text-orange-400">
105
+ deprecated
106
+ </span>
107
+ )}
108
+ </div>
109
+ <div class="grid grid-cols-1 items-start gap-x-10 gap-y-8 xl:grid-cols-[minmax(0,1fr)_minmax(0,28rem)]">
110
+ <div>
111
+ <ParametersTable parameters={params} schemas={schemas} />
112
+ {operation.requestBody && (
113
+ <RequestBody
114
+ expandAll={spec.expandSchemas}
115
+ requestBody={operation.requestBody}
116
+ schemas={schemas}
117
+ />
118
+ )}
119
+ {operation.responses && (
120
+ <Responses
121
+ expandAll={spec.expandSchemas}
122
+ responses={operation.responses}
123
+ schemas={schemas}
124
+ />
125
+ )}
126
+ </div>
127
+ {sample && (
128
+ <div class="xl:sticky xl:top-24 xl:self-start">
129
+ <RequestPanel
130
+ languages={languages}
131
+ responses={operation.responses ?? {}}
132
+ sample={sample}
133
+ schemas={schemas}
134
+ />
135
+ </div>
136
+ )}
137
+ </div>
138
+ </div>
139
+ )
140
+ }