blume 1.0.4 → 1.1.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 (117) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/cli/index.js +13404 -10228
  3. package/dist/cli/index.js.map +94 -63
  4. package/dist/types/ai/component-markdown.d.ts +12 -1
  5. package/dist/types/core/config-input.d.ts +73 -4
  6. package/dist/types/core/data.d.ts +9 -0
  7. package/dist/types/core/deployment-env.d.ts +6 -0
  8. package/dist/types/core/diagnostics.d.ts +23 -0
  9. package/dist/types/core/i18n-ui.d.ts +8 -8
  10. package/dist/types/core/schema.d.ts +144 -22
  11. package/dist/types/core/sources/types.d.ts +3 -1
  12. package/dist/types/core/standard-schema.d.ts +41 -0
  13. package/dist/types/core/types.d.ts +20 -0
  14. package/dist/types/og/card.d.ts +63 -0
  15. package/dist/types/og/dimensions.d.ts +12 -0
  16. package/docs/01-quickstart.mdx +1 -1
  17. package/docs/02-deployment.mdx +9 -1
  18. package/docs/advanced/api-reference.mdx +11 -0
  19. package/docs/advanced/changelog.mdx +2 -2
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +3 -3
  22. package/docs/configuration/customization.mdx +1 -1
  23. package/docs/configuration/export.mdx +1 -1
  24. package/docs/configuration/index.mdx +21 -1
  25. package/docs/configuration/search.mdx +28 -1
  26. package/docs/configuration/seo.mdx +37 -2
  27. package/docs/configuration/theming.mdx +1 -1
  28. package/docs/content/components.mdx +14 -0
  29. package/docs/content/index.mdx +1 -1
  30. package/docs/content/meta.mdx +1 -1
  31. package/docs/content/navigation.mdx +5 -1
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/reference/cli.mdx +80 -2
  34. package/docs/reference/frontmatter.mdx +31 -1
  35. package/package.json +4 -3
  36. package/skills/blume-migrate/SKILL.md +1 -1
  37. package/skills/blume-migrate/references/mintlify.md +3 -2
  38. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
  39. package/src/ai/component-markdown.ts +39 -11
  40. package/src/ai/llms.ts +19 -2
  41. package/src/ai/markdown.ts +5 -1
  42. package/src/astro/adapter-root.ts +70 -0
  43. package/src/astro/generate.ts +124 -50
  44. package/src/astro/index.ts +1 -0
  45. package/src/astro/pages.ts +39 -8
  46. package/src/astro/templates.ts +93 -28
  47. package/src/audit/agent.ts +114 -0
  48. package/src/audit/catalog.ts +826 -0
  49. package/src/audit/checks/assets.ts +177 -0
  50. package/src/audit/checks/content.ts +231 -0
  51. package/src/audit/checks/duplicates.ts +131 -0
  52. package/src/audit/checks/i18n.ts +246 -0
  53. package/src/audit/checks/indexability.ts +213 -0
  54. package/src/audit/checks/links.ts +223 -0
  55. package/src/audit/checks/llms.ts +138 -0
  56. package/src/audit/checks/network.ts +272 -0
  57. package/src/audit/checks/og-image.ts +113 -0
  58. package/src/audit/checks/redirects.ts +87 -0
  59. package/src/audit/checks/robots.ts +114 -0
  60. package/src/audit/checks/sitemap.ts +229 -0
  61. package/src/audit/checks/social.ts +238 -0
  62. package/src/audit/crawl.ts +259 -0
  63. package/src/audit/graph.ts +74 -0
  64. package/src/audit/html.ts +54 -0
  65. package/src/audit/image-size.ts +63 -0
  66. package/src/audit/locate.ts +33 -0
  67. package/src/audit/redirects.ts +74 -0
  68. package/src/audit/report.ts +278 -0
  69. package/src/audit/run.ts +198 -0
  70. package/src/audit/snapshot.ts +189 -0
  71. package/src/audit/types.ts +214 -0
  72. package/src/audit/url.ts +103 -0
  73. package/src/cli/commands/audit.ts +205 -0
  74. package/src/cli/commands/build.ts +64 -13
  75. package/src/cli/index.ts +2 -0
  76. package/src/cli/prepare.ts +10 -2
  77. package/src/components/content/Tabs.astro +98 -15
  78. package/src/components/layout/Breadcrumbs.astro +1 -1
  79. package/src/components/layout/Header.astro +1 -0
  80. package/src/components/layout/PageFeedback.astro +1 -1
  81. package/src/components/layout/PageLayout.astro +5 -1
  82. package/src/components/layout/Pagination.astro +1 -1
  83. package/src/components/layout/RootLayout.astro +5 -3
  84. package/src/components/layout/Search.astro +35 -6
  85. package/src/components/layout/TableOfContents.astro +1 -1
  86. package/src/components/openapi/Authorization.astro +80 -0
  87. package/src/components/openapi/Operation.astro +19 -1
  88. package/src/components/openapi/ParametersTable.astro +1 -1
  89. package/src/components/openapi/security.ts +201 -0
  90. package/src/components/openapi/snippets.ts +42 -13
  91. package/src/core/config-input.ts +78 -4
  92. package/src/core/data.ts +9 -1
  93. package/src/core/deployment-env.ts +9 -0
  94. package/src/core/diagnostics.ts +61 -12
  95. package/src/core/graph.ts +23 -4
  96. package/src/core/links.ts +2 -91
  97. package/src/core/nav-diagnostics.ts +48 -4
  98. package/src/core/navigation.ts +169 -14
  99. package/src/core/probe.ts +136 -0
  100. package/src/core/project-graph.ts +54 -20
  101. package/src/core/schema.ts +93 -3
  102. package/src/core/sources/github-releases.ts +65 -2
  103. package/src/core/sources/normalize.ts +198 -25
  104. package/src/core/sources/types.ts +3 -1
  105. package/src/core/standard-schema.ts +54 -0
  106. package/src/core/types.ts +20 -0
  107. package/src/deploy/adapter-output.ts +27 -15
  108. package/src/deploy/headers.ts +66 -0
  109. package/src/deploy/redirects.ts +49 -9
  110. package/src/markdown/index.ts +1 -0
  111. package/src/markdown/twoslash.ts +60 -0
  112. package/src/og/card.ts +98 -33
  113. package/src/og/index.ts +1 -1
  114. package/src/registry/eject.ts +3 -1
  115. package/src/search/popular.ts +33 -0
  116. package/src/theme/entry.ts +6 -1
  117. /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
@@ -4,6 +4,20 @@ interface Props {
4
4
  defaultTabIndex?: number;
5
5
  dropdown?: boolean;
6
6
  hash?: boolean | "false" | "true";
7
+ /**
8
+ * Render borderless — a tab strip on a full-width rule with the content
9
+ * flowing beneath as prose — instead of the default bordered box. Reads as
10
+ * page-level navigation rather than an isolated widget; pair with `param` for
11
+ * shareable, multiple-per-page tabs.
12
+ */
13
+ inline?: boolean;
14
+ /**
15
+ * Sync the active tab to this URL query-param key instead of the hash. Each
16
+ * group owns its own param, so several `Tabs` can share a page and every
17
+ * selection is deep-linkable (`?install=windows`). Give each group a unique
18
+ * `param`; it takes precedence over `hash`.
19
+ */
20
+ param?: string;
7
21
  sync?: boolean;
8
22
  }
9
23
 
@@ -12,34 +26,53 @@ const {
12
26
  defaultTabIndex = 0,
13
27
  dropdown = false,
14
28
  hash = true,
29
+ inline = false,
30
+ param,
15
31
  sync = true,
16
32
  } = Astro.props;
17
33
 
18
34
  // MDX string attributes (`hash="false"` from generated markup) must read as
19
35
  // their boolean meaning — the string "false" is truthy.
20
36
  const hashEnabled = String(hash) !== "false";
37
+ // The dropdown (select) affordance belongs to the boxed layout; inline always
38
+ // renders a tab strip.
39
+ const useDropdown = dropdown && !inline;
21
40
  ---
22
41
 
23
42
  <blume-tabs
24
- class="not-prose my-6 block overflow-hidden rounded-blume border border-border bg-background"
43
+ class:list={[
44
+ "my-6 block",
45
+ inline
46
+ ? ""
47
+ : "not-prose overflow-hidden rounded-blume border border-border bg-background",
48
+ ]}
25
49
  data-border-bottom={borderBottom ? "true" : "false"}
26
50
  data-default-tab-index={defaultTabIndex}
27
- data-dropdown={dropdown ? "true" : "false"}
51
+ data-dropdown={useDropdown ? "true" : "false"}
28
52
  data-hash={hashEnabled ? "true" : "false"}
53
+ data-param={param}
29
54
  data-sync={sync ? "true" : "false"}
30
55
  >
31
- <div class:list={[dropdown ? "" : "overflow-x-auto"]}>
56
+ <div
57
+ class:list={[
58
+ inline ? "not-prose" : "",
59
+ useDropdown ? "" : "overflow-x-auto",
60
+ ]}
61
+ >
32
62
  <div
33
63
  class:list={[
34
- dropdown || borderBottom ? "border-border border-b" : "",
35
- dropdown ? "p-3" : "flex gap-4 px-3",
64
+ useDropdown || borderBottom ? "border-border border-b" : "",
65
+ useDropdown ? "p-3" : inline ? "flex gap-4" : "flex gap-4 px-3",
36
66
  ]}
37
67
  data-blume-tablist
38
- role={dropdown ? undefined : "tablist"}
68
+ role={useDropdown ? undefined : "tablist"}
39
69
  >
40
70
  </div>
41
71
  </div>
42
- <div data-blume-tab-content>
72
+ <div
73
+ class:list={[inline ? "pt-4 [&>[data-blume-tab-panel]]:p-0!" : ""]}
74
+ data-blume-tab-content
75
+ >
43
76
  <slot />
44
77
  </div>
45
78
  </blume-tabs>
@@ -114,7 +147,7 @@ const hashEnabled = String(hash) !== "false";
114
147
  if (this.#syncEnabled()) {
115
148
  document.addEventListener(SYNC_EVENT, this.#sync);
116
149
  }
117
- window.addEventListener("hashchange", this.#hashChange);
150
+ this.#addUrlListener();
118
151
  return;
119
152
  }
120
153
  this.dataset.blumeTabsReady = "true";
@@ -173,12 +206,13 @@ const hashEnabled = String(hash) !== "false";
173
206
  if (this.#syncEnabled()) {
174
207
  document.addEventListener(SYNC_EVENT, this.#sync);
175
208
  }
176
- window.addEventListener("hashchange", this.#hashChange);
209
+ this.#addUrlListener();
177
210
  }
178
211
 
179
212
  disconnectedCallback() {
180
213
  document.removeEventListener(SYNC_EVENT, this.#sync);
181
- window.removeEventListener("hashchange", this.#hashChange);
214
+ window.removeEventListener("hashchange", this.#urlChange);
215
+ window.removeEventListener("popstate", this.#urlChange);
182
216
  }
183
217
 
184
218
  activate(index: number, sync: boolean, updateHash: boolean) {
@@ -201,8 +235,18 @@ const hashEnabled = String(hash) !== "false";
201
235
  this.#select.value = String(index);
202
236
  }
203
237
  const id = panelId(activePanel);
204
- if (updateHash && id && this.#hashEnabled()) {
205
- history.replaceState(null, "", `#${encodeURIComponent(id)}`);
238
+ if (updateHash && id) {
239
+ const param = this.#paramName();
240
+ if (param) {
241
+ // Query-param mode: write only this group's key, so several groups on
242
+ // one page each deep-link independently. replaceState (not push) keeps
243
+ // the shareable URL current without stacking a history entry per click.
244
+ const url = new URL(window.location.href);
245
+ url.searchParams.set(param, id);
246
+ history.replaceState(history.state, "", url);
247
+ } else if (this.#hashEnabled()) {
248
+ history.replaceState(null, "", `#${encodeURIComponent(id)}`);
249
+ }
206
250
  }
207
251
  if (sync && this.#syncEnabled()) {
208
252
  document.dispatchEvent(
@@ -239,8 +283,29 @@ const hashEnabled = String(hash) !== "false";
239
283
  return true;
240
284
  }
241
285
 
286
+ #activateByParam() {
287
+ const param = this.#paramName();
288
+ if (!param) {
289
+ return false;
290
+ }
291
+ const value = new URL(window.location.href).searchParams.get(param);
292
+ if (!value) {
293
+ return false;
294
+ }
295
+ const index = this.#panels.findIndex((panel) => panelId(panel) === value);
296
+ if (index === -1) {
297
+ return false;
298
+ }
299
+
300
+ this.activate(index, false, false);
301
+ return true;
302
+ }
303
+
242
304
  #initialIndex() {
243
- if (this.#activateByHash()) {
305
+ const activated = this.#paramName()
306
+ ? this.#activateByParam()
307
+ : this.#activateByHash();
308
+ if (activated) {
244
309
  return this.#panels.findIndex((panel) => !panel.classList.contains("hidden"));
245
310
  }
246
311
  return boundedIndex(
@@ -278,6 +343,20 @@ const hashEnabled = String(hash) !== "false";
278
343
  return this.dataset.hash !== "false";
279
344
  }
280
345
 
346
+ #paramName() {
347
+ return this.dataset.param || null;
348
+ }
349
+
350
+ // Query-param changes don't fire `hashchange`; they surface through history
351
+ // navigation (`popstate`). Listen to whichever channel this group syncs on.
352
+ #addUrlListener() {
353
+ if (this.#paramName()) {
354
+ window.addEventListener("popstate", this.#urlChange);
355
+ } else {
356
+ window.addEventListener("hashchange", this.#urlChange);
357
+ }
358
+ }
359
+
281
360
  #sync = (event: Event) => {
282
361
  const title = (event as CustomEvent<{ title?: string }>).detail?.title;
283
362
  if (title) {
@@ -285,8 +364,12 @@ const hashEnabled = String(hash) !== "false";
285
364
  }
286
365
  };
287
366
 
288
- #hashChange = () => {
289
- this.#activateByHash();
367
+ #urlChange = () => {
368
+ if (this.#paramName()) {
369
+ this.#activateByParam();
370
+ } else {
371
+ this.#activateByHash();
372
+ }
290
373
  };
291
374
  }
292
375
 
@@ -31,7 +31,7 @@ const eyebrowCrumb = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null;
31
31
  aria-label={n.breadcrumb}
32
32
  class:list={[
33
33
  "mb-2 text-muted-foreground text-sm",
34
- wide ? "max-w-none" : "mx-auto max-w-[42rem]",
34
+ wide ? "max-w-none" : "mx-auto max-w-content",
35
35
  ]}
36
36
  >
37
37
  {eyebrowCrumb.route ? (
@@ -182,6 +182,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
182
182
  askEnabled={askEnabled}
183
183
  locale={searchLocale}
184
184
  navigation={navigation}
185
+ popularPages={data.config.search.popular}
185
186
  strings={searchStrings}
186
187
  />
187
188
  )
@@ -20,7 +20,7 @@ const buttonClass =
20
20
 
21
21
  <section
22
22
  aria-label={f.question}
23
- class="mx-auto mt-12 flex max-w-[42rem] items-center justify-between gap-4 border-border border-t pt-6"
23
+ class="mx-auto mt-12 flex max-w-content items-center justify-between gap-4 border-border border-t pt-6"
24
24
  data-blume-page-feedback
25
25
  >
26
26
  <p class="text-muted-foreground text-sm">{f.question}</p>
@@ -186,7 +186,11 @@ const bannerKey = banner?.dismissible ? banner.key : null;
186
186
  <title>{pageTitle}</title>
187
187
  <Favicon appleIcon={appleIcon} favicon={favicon} />
188
188
  <Fonts cssVars={fontCssVars ?? []} />
189
- {resolvedCanonical && <link href={resolvedCanonical} rel="canonical" />}
189
+ {/* Google warns against pairing a canonical with noindex — the canonical
190
+ says "index that URL", the robots meta says "distrust this page". */}
191
+ {resolvedCanonical && !noindex && (
192
+ <link href={resolvedCanonical} rel="canonical" />
193
+ )}
190
194
  {noindex && <meta content="noindex" name="robots" />}
191
195
  {description && <meta content={description} name="description" />}
192
196
  <meta content="website" property="og:type" />
@@ -26,7 +26,7 @@ const s = { ...EN_UI.page, ...strings };
26
26
  (prev || next) && (
27
27
  <nav
28
28
  aria-label={s.pagination}
29
- class="mx-auto mt-12 flex max-w-[42rem] justify-between gap-4 border-border border-t pt-6 max-md:flex-col"
29
+ class="mx-auto mt-12 flex max-w-content justify-between gap-4 border-border border-t pt-6 max-md:flex-col"
30
30
  >
31
31
  {prev ? (
32
32
  <a
@@ -261,7 +261,7 @@ if (isBare) {
261
261
  } else if (isApiOperation) {
262
262
  gridClass = "lg:grid-cols-[17.5rem_minmax(0,1fr)]";
263
263
  }
264
- let articleClass = "prose mx-auto max-w-[42rem]";
264
+ let articleClass = "prose mx-auto max-w-content";
265
265
  if (isBare) {
266
266
  articleClass = "prose mx-auto max-w-[54rem]";
267
267
  } else if (isApiOperation) {
@@ -345,7 +345,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
345
345
  <title>{pageTitle}</title>
346
346
  <Favicon favicon={favicon} appleIcon={appleIcon} />
347
347
  <Fonts cssVars={fontCssVars ?? []} />
348
- {canonical && <link rel="canonical" href={canonical} />}
348
+ {/* Google warns against pairing a canonical with noindex — the canonical
349
+ says "index that URL", the robots meta says "distrust this page". */}
350
+ {canonical && !noindex && <link rel="canonical" href={canonical} />}
349
351
  {
350
352
  localeAlternates?.map((alt) => (
351
353
  <link href={alt.href} hreflang={alt.hreflang} rel="alternate" />
@@ -592,7 +594,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
592
594
  route={page.route}
593
595
  />
594
596
  {formattedLastModified && (
595
- <p class="mx-auto mt-10 max-w-[42rem] text-muted-foreground text-sm">
597
+ <p class="mx-auto mt-10 max-w-content text-muted-foreground text-sm">
596
598
  {strings.page.lastUpdated} {formattedLastModified}
597
599
  </p>
598
600
  )}
@@ -2,6 +2,7 @@
2
2
  import { EN_UI } from "../../core/i18n-ui.ts";
3
3
  import type { UIStrings } from "../../core/i18n-ui.ts";
4
4
  import type { Navigation } from "../../core/types.ts";
5
+ import { resolveIcon } from "../../theme/icons.ts";
5
6
  import Icon from "../Icon.astro";
6
7
  import { flattenPages } from "./nav-utils.ts";
7
8
 
@@ -13,19 +14,43 @@ import { flattenPages } from "./nav-utils.ts";
13
14
  interface Props {
14
15
  askEnabled?: boolean;
15
16
  navigation?: Navigation;
17
+ /** Curated empty-state pages; falls back to the first sidebar pages when empty or omitted. */
18
+ popularPages?: { icon?: string; label: string; route: string }[];
16
19
  strings?: UIStrings["search"];
17
20
  /** Active locale to filter results to; omitted disables locale filtering. */
18
21
  locale?: string;
19
22
  }
20
23
 
21
- const { askEnabled = false, navigation, strings, locale } = Astro.props;
24
+ const { askEnabled = false, navigation, popularPages, strings, locale } =
25
+ Astro.props;
22
26
  // Merge over the English baseline per key (rather than `strings ?? …`) so a
23
27
  // partial — or empty `{}` — strings object still resolves every label to a
24
28
  // default, matching the pattern PageActions uses for its own dictionary.
25
29
  const s = { ...EN_UI.search, ...strings };
26
30
 
27
- // Pages shown in the empty state, before the user has typed anything.
28
- const popular = navigation ? flattenPages(navigation.sidebar).slice(0, 6) : [];
31
+ // Icons resolve to inline SVG here (`theme/icons.ts` is a server-only module —
32
+ // far too large to ship to the browser), so the island gets ready-to-render
33
+ // markup rather than a name it can't resolve. Same approach as AskAI.astro.
34
+ // A name outside the set falls through to the island's own file glyph.
35
+ const iconSvg = (name: string | undefined): string | undefined => {
36
+ const resolved = name ? resolveIcon(name) : null;
37
+ return resolved
38
+ ? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${resolved.viewBox}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${resolved.body}</svg>`
39
+ : undefined;
40
+ };
41
+
42
+ // Pages shown in the empty state, before the user has typed anything. Only
43
+ // curated entries carry an icon; sidebar-derived rows keep the file glyph.
44
+ const popular =
45
+ popularPages && popularPages.length > 0
46
+ ? popularPages.map((page) => ({
47
+ icon: iconSvg(page.icon),
48
+ label: page.label,
49
+ route: page.route,
50
+ }))
51
+ : navigation
52
+ ? flattenPages(navigation.sidebar).slice(0, 6)
53
+ : [];
29
54
 
30
55
  const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
31
56
  ---
@@ -166,6 +191,8 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
166
191
  }
167
192
 
168
193
  interface PopularPage {
194
+ /** Pre-resolved inline SVG from the server; absent falls back to `file`. */
195
+ icon?: string;
169
196
  label: string;
170
197
  route: string;
171
198
  }
@@ -501,7 +528,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
501
528
  if (this.popular.length > 0) {
502
529
  const group = this.addGroup(this.popularMsg);
503
530
  for (const page of this.popular) {
504
- const item = this.createLinkRow(page.route, page.label);
531
+ const item = this.createLinkRow(page.route, page.label, page.icon);
505
532
  group.appendChild(item.el);
506
533
  this.selectables.push(item);
507
534
  }
@@ -590,13 +617,15 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
590
617
  return item;
591
618
  }
592
619
 
593
- createLinkRow(url: string, label: string): Selectable {
620
+ createLinkRow(url: string, label: string, icon?: string): Selectable {
594
621
  const el = document.createElement("a");
595
622
  const href = prefixBase(import.meta.env.BASE_URL, url);
596
623
  el.href = href;
597
624
  el.className = ROW_CLASS;
625
+ // `icon` is server-resolved markup from the bundled icon set, not
626
+ // author HTML — the label still goes through `escapeHtml`.
598
627
  el.innerHTML = `
599
- <span class="mt-0.5 shrink-0 text-muted-foreground">${svg("file")}</span>
628
+ <span class="mt-0.5 shrink-0 text-muted-foreground">${icon ?? svg("file")}</span>
600
629
  <span class="flex-1">
601
630
  <span class="block truncate font-normal text-foreground text-sm">${escapeHtml(label)}</span>
602
631
  </span>`;
@@ -19,7 +19,7 @@ const { headings, title, variant } = Astro.props;
19
19
 
20
20
  {
21
21
  headings.length > 0 && variant === "mobile" && (
22
- <details class="group mx-auto mb-6 max-w-[42rem] rounded-blume border border-border xl:hidden">
22
+ <details class="group mx-auto mb-6 max-w-content rounded-blume border border-border xl:hidden">
23
23
  <summary class="flex cursor-pointer list-none items-center justify-between gap-2 px-4 py-3 font-medium text-foreground text-sm [&::-webkit-details-marker]:hidden">
24
24
  <span>{title}</span>
25
25
  <Icon
@@ -0,0 +1,80 @@
1
+ ---
2
+ import {
3
+ type OperationSecurity,
4
+ schemeCarrier,
5
+ schemeLabel,
6
+ } from "./security.ts";
7
+
8
+ interface Props {
9
+ security: OperationSecurity;
10
+ }
11
+
12
+ const { security } = Astro.props;
13
+ ---
14
+
15
+ {
16
+ security.alternatives.length > 0 && (
17
+ <section class="mt-6">
18
+ <div
19
+ aria-level="2"
20
+ class="mb-2 font-semibold text-foreground text-sm"
21
+ role="heading"
22
+ >
23
+ Authorization
24
+ </div>
25
+ {security.optional && (
26
+ <p class="mb-2 text-muted-foreground text-sm">
27
+ Optional — this operation also accepts unauthenticated requests.
28
+ </p>
29
+ )}
30
+ {security.alternatives.map((alternative, index) => (
31
+ <>
32
+ {index > 0 && (
33
+ <div class="my-2 font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide">
34
+ or
35
+ </div>
36
+ )}
37
+ <div class="not-prose rounded-blume border border-border px-4">
38
+ {alternative.map((resolved) => {
39
+ const carrier = schemeCarrier(resolved);
40
+ return (
41
+ <div class="border-border border-t py-3 first:border-t-0">
42
+ <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
43
+ <code class="font-mono text-foreground text-sm">
44
+ {carrier?.name ?? resolved.key}
45
+ </code>
46
+ <span class="text-muted-foreground text-xs">
47
+ {schemeLabel(resolved)}
48
+ {carrier ? ` · ${carrier.in}` : ""}
49
+ </span>
50
+ {!security.optional && (
51
+ <span class="font-medium text-[0.625rem] text-red-600 uppercase tracking-wide dark:text-red-400">
52
+ required
53
+ </span>
54
+ )}
55
+ </div>
56
+ {resolved.scheme?.description && (
57
+ <div
58
+ class="mt-1 text-muted-foreground text-sm"
59
+ set:text={resolved.scheme.description}
60
+ />
61
+ )}
62
+ {resolved.scopes.length > 0 && (
63
+ <div class="mt-1 flex flex-wrap items-center gap-1 text-xs">
64
+ <span class="text-muted-foreground">Scopes:</span>
65
+ {resolved.scopes.map((scope) => (
66
+ <code class="rounded bg-muted px-1 py-0.5 text-foreground">
67
+ {scope}
68
+ </code>
69
+ ))}
70
+ </div>
71
+ )}
72
+ </div>
73
+ );
74
+ })}
75
+ </div>
76
+ </>
77
+ ))}
78
+ </section>
79
+ )
80
+ }
@@ -6,7 +6,15 @@ import {
6
6
  resolveComponentRef,
7
7
  type SchemaLike,
8
8
  } from "./helpers.ts";
9
+ import {
10
+ effectiveSecurity,
11
+ resolveSecurity,
12
+ sampleAuth,
13
+ type SecurityRequirementLike,
14
+ type SecuritySchemeLike,
15
+ } from "./security.ts";
9
16
  import { buildRequestSample, sampleLanguages } from "./snippets.ts";
17
+ import Authorization from "./Authorization.astro";
10
18
  import MethodBadge from "./MethodBadge.astro";
11
19
  import ParametersTable from "./ParametersTable.astro";
12
20
  import RequestBody from "./RequestBody.astro";
@@ -43,6 +51,7 @@ interface FullOperation {
43
51
  parameters?: ParameterLike[];
44
52
  requestBody?: RequestBodyLike;
45
53
  responses?: Record<string, ResponseLike>;
54
+ security?: SecurityRequirementLike[];
46
55
  }
47
56
 
48
57
  const { source, id } = Astro.props;
@@ -59,7 +68,9 @@ const doc = (spec?.document ?? {}) as {
59
68
  parameters?: Record<string, ParameterLike>;
60
69
  requestBodies?: Record<string, RequestBodyLike>;
61
70
  responses?: Record<string, ResponseLike>;
71
+ securitySchemes?: Record<string, SecuritySchemeLike>;
62
72
  };
73
+ security?: SecurityRequirementLike[];
63
74
  servers?: { url?: string }[];
64
75
  };
65
76
  const pathItem = ref ? doc.paths?.[ref.path] : undefined;
@@ -84,6 +95,11 @@ const responses = Object.fromEntries(
84
95
  ])
85
96
  );
86
97
 
98
+ const security = resolveSecurity(
99
+ effectiveSecurity(operation?.security, doc.security),
100
+ doc.components?.securitySchemes
101
+ );
102
+
87
103
  const sample =
88
104
  ref && operation
89
105
  ? buildRequestSample(
@@ -91,7 +107,8 @@ const sample =
91
107
  ref.method,
92
108
  ref.path,
93
109
  doc.servers ?? [],
94
- schemas
110
+ schemas,
111
+ sampleAuth(security)
95
112
  )
96
113
  : null;
97
114
  const languages = sampleLanguages(spec?.codeSamples ?? []);
@@ -115,6 +132,7 @@ const languages = sampleLanguages(spec?.codeSamples ?? []);
115
132
  </div>
116
133
  <div class="grid grid-cols-1 items-start gap-x-10 gap-y-8 xl:grid-cols-[minmax(0,1fr)_minmax(0,28rem)]">
117
134
  <div>
135
+ <Authorization security={security} />
118
136
  <ParametersTable parameters={params} schemas={schemas} />
119
137
  {requestBody && (
120
138
  <RequestBody
@@ -39,7 +39,7 @@ const groups = SECTIONS.map((section) => ({
39
39
  groups.map((group) => (
40
40
  <section class="mt-6">
41
41
  <div
42
- aria-level="3"
42
+ aria-level="2"
43
43
  class="mb-2 font-semibold text-foreground text-sm"
44
44
  role="heading"
45
45
  >