blume 1.6.4 → 1.6.6

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 (178) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/blume.mjs +3 -2
  3. package/dist/cli/chunk-0ewz4trd.js +679 -0
  4. package/dist/cli/chunk-0ewz4trd.js.map +15 -0
  5. package/dist/cli/chunk-27gtm2ym.js +69 -0
  6. package/dist/cli/chunk-27gtm2ym.js.map +11 -0
  7. package/dist/cli/chunk-2aj8ddew.js +72 -0
  8. package/dist/cli/chunk-2aj8ddew.js.map +10 -0
  9. package/dist/cli/chunk-3k0kzs6d.js +69 -0
  10. package/dist/cli/chunk-3k0kzs6d.js.map +11 -0
  11. package/dist/cli/chunk-3r94j3tc.js +221 -0
  12. package/dist/cli/chunk-3r94j3tc.js.map +10 -0
  13. package/dist/cli/chunk-4trphnvy.js +102 -0
  14. package/dist/cli/chunk-4trphnvy.js.map +11 -0
  15. package/dist/cli/chunk-4xyggvgf.js +21 -0
  16. package/dist/cli/chunk-4xyggvgf.js.map +10 -0
  17. package/dist/cli/chunk-5hs6gb7n.js +32 -0
  18. package/dist/cli/chunk-5hs6gb7n.js.map +10 -0
  19. package/dist/cli/chunk-5yvt556e.js +185 -0
  20. package/dist/cli/chunk-5yvt556e.js.map +11 -0
  21. package/dist/cli/chunk-62qsssnh.js +3808 -0
  22. package/dist/cli/chunk-62qsssnh.js.map +36 -0
  23. package/dist/cli/chunk-6kzzpsx8.js +26 -0
  24. package/dist/cli/chunk-6kzzpsx8.js.map +10 -0
  25. package/dist/cli/chunk-8gnpdsn1.js +952 -0
  26. package/dist/cli/chunk-8gnpdsn1.js.map +12 -0
  27. package/dist/cli/chunk-9sh49q0h.js +30 -0
  28. package/dist/cli/chunk-9sh49q0h.js.map +10 -0
  29. package/dist/cli/chunk-aerwpe14.js +2370 -0
  30. package/dist/cli/chunk-aerwpe14.js.map +15 -0
  31. package/dist/cli/chunk-ag1zyr5x.js +176 -0
  32. package/dist/cli/chunk-ag1zyr5x.js.map +10 -0
  33. package/dist/cli/chunk-bawgnt8x.js +277 -0
  34. package/dist/cli/chunk-bawgnt8x.js.map +11 -0
  35. package/dist/cli/chunk-bcy492zc.js +16 -0
  36. package/dist/cli/chunk-bcy492zc.js.map +10 -0
  37. package/dist/cli/chunk-btfr9yvw.js +41 -0
  38. package/dist/cli/chunk-btfr9yvw.js.map +10 -0
  39. package/dist/cli/chunk-cbjnx4s8.js +73 -0
  40. package/dist/cli/chunk-cbjnx4s8.js.map +10 -0
  41. package/dist/cli/chunk-cnvm6k3e.js +96 -0
  42. package/dist/cli/chunk-cnvm6k3e.js.map +10 -0
  43. package/dist/cli/chunk-etsqspj6.js +5170 -0
  44. package/dist/cli/chunk-etsqspj6.js.map +47 -0
  45. package/dist/cli/chunk-ev67ycx0.js +15 -0
  46. package/dist/cli/chunk-ev67ycx0.js.map +10 -0
  47. package/dist/cli/chunk-ey89bjj1.js +209 -0
  48. package/dist/cli/chunk-ey89bjj1.js.map +11 -0
  49. package/dist/cli/chunk-f75cqye8.js +76 -0
  50. package/dist/cli/chunk-f75cqye8.js.map +10 -0
  51. package/dist/cli/chunk-j00ezcg5.js +259 -0
  52. package/dist/cli/chunk-j00ezcg5.js.map +11 -0
  53. package/dist/cli/chunk-jtb45atp.js +467 -0
  54. package/dist/cli/chunk-jtb45atp.js.map +14 -0
  55. package/dist/cli/chunk-m3p3wahd.js +117 -0
  56. package/dist/cli/chunk-m3p3wahd.js.map +10 -0
  57. package/dist/cli/chunk-n0y172hf.js +387 -0
  58. package/dist/cli/chunk-n0y172hf.js.map +12 -0
  59. package/dist/cli/chunk-n4qjabmt.js +1062 -0
  60. package/dist/cli/chunk-n4qjabmt.js.map +25 -0
  61. package/dist/cli/chunk-nyqzjdhj.js +111 -0
  62. package/dist/cli/chunk-nyqzjdhj.js.map +11 -0
  63. package/dist/cli/chunk-pxj10x8y.js +35 -0
  64. package/dist/cli/chunk-pxj10x8y.js.map +10 -0
  65. package/dist/cli/chunk-s4jn7f1q.js +54 -0
  66. package/dist/cli/chunk-s4jn7f1q.js.map +10 -0
  67. package/dist/cli/chunk-s4k1pnvf.js +81 -0
  68. package/dist/cli/chunk-s4k1pnvf.js.map +10 -0
  69. package/dist/cli/chunk-s5e5jt53.js +227 -0
  70. package/dist/cli/chunk-s5e5jt53.js.map +11 -0
  71. package/dist/cli/chunk-sbdqrjbb.js +81 -0
  72. package/dist/cli/chunk-sbdqrjbb.js.map +10 -0
  73. package/dist/cli/chunk-tc89yh2r.js +136 -0
  74. package/dist/cli/chunk-tc89yh2r.js.map +10 -0
  75. package/dist/cli/chunk-vt8fgygt.js +23 -0
  76. package/dist/cli/chunk-vt8fgygt.js.map +10 -0
  77. package/dist/cli/chunk-vv237fp3.js +1002 -0
  78. package/dist/cli/chunk-vv237fp3.js.map +13 -0
  79. package/dist/cli/chunk-vv3f8mb6.js +5314 -0
  80. package/dist/cli/chunk-vv3f8mb6.js.map +58 -0
  81. package/dist/cli/chunk-vxv4x1n8.js +17 -0
  82. package/dist/cli/chunk-vxv4x1n8.js.map +10 -0
  83. package/dist/cli/chunk-wb067mv3.js +758 -0
  84. package/dist/cli/chunk-wb067mv3.js.map +13 -0
  85. package/dist/cli/chunk-wd27zjcz.js +60 -0
  86. package/dist/cli/chunk-wd27zjcz.js.map +10 -0
  87. package/dist/cli/chunk-wkq5tbtq.js +1141 -0
  88. package/dist/cli/chunk-wkq5tbtq.js.map +19 -0
  89. package/dist/cli/chunk-x1vrdjyk.js +1967 -0
  90. package/dist/cli/chunk-x1vrdjyk.js.map +34 -0
  91. package/dist/cli/chunk-x66c5yjn.js +23 -0
  92. package/dist/cli/chunk-x66c5yjn.js.map +10 -0
  93. package/dist/cli/index.js +55 -27587
  94. package/dist/cli/index.js.map +5 -243
  95. package/dist/types/ai/ask-context.d.ts +26 -0
  96. package/dist/types/core/code-fences.d.ts +11 -0
  97. package/dist/types/core/config-input.d.ts +10 -0
  98. package/dist/types/core/package-root.d.ts +1 -1
  99. package/dist/types/core/schema.d.ts +74 -1
  100. package/docs/02-deployment.mdx +1 -1
  101. package/docs/configuration/analytics.mdx +21 -2
  102. package/docs/configuration/ask-ai.mdx +1 -1
  103. package/docs/configuration/customization.mdx +2 -9
  104. package/docs/content/syntax.mdx +1 -1
  105. package/docs/reference/cli.mdx +1 -1
  106. package/package.json +16 -14
  107. package/src/ai/api/handlers.ts +4 -7
  108. package/src/ai/api/paths.ts +8 -0
  109. package/src/ai/api/spec.ts +2 -1
  110. package/src/ai/ask-context.ts +378 -22
  111. package/src/astro/generate.ts +25 -29
  112. package/src/astro/include-hmr.ts +10 -13
  113. package/src/astro/include-refresh.ts +0 -0
  114. package/src/astro/index.ts +6 -1
  115. package/src/astro/integration.ts +269 -53
  116. package/src/astro/module-types.ts +74 -0
  117. package/src/astro/templates.ts +85 -97
  118. package/src/audit/image-size.ts +10 -8
  119. package/src/cli/command-meta.ts +77 -0
  120. package/src/cli/commands/add.ts +2 -4
  121. package/src/cli/commands/audit.ts +2 -4
  122. package/src/cli/commands/build.ts +42 -346
  123. package/src/cli/commands/check.ts +2 -4
  124. package/src/cli/commands/dev.ts +31 -42
  125. package/src/cli/commands/doctor.ts +2 -4
  126. package/src/cli/commands/eject.ts +3 -41
  127. package/src/cli/commands/eval.ts +2 -5
  128. package/src/cli/commands/init.ts +2 -4
  129. package/src/cli/commands/mcp-stdio.ts +2 -5
  130. package/src/cli/commands/preview.ts +3 -5
  131. package/src/cli/commands/sync.ts +2 -4
  132. package/src/cli/commands/translate.ts +2 -5
  133. package/src/cli/commands/validate.ts +2 -4
  134. package/src/cli/commands/version.ts +2 -4
  135. package/src/cli/eject-scripts.ts +0 -45
  136. package/src/cli/host-args.ts +16 -0
  137. package/src/cli/index.ts +84 -35
  138. package/src/cli/lazy-command.ts +47 -0
  139. package/src/components/content/GithubInfo.astro +4 -1
  140. package/src/components/content/mermaid-element.ts +8 -0
  141. package/src/components/layout/Analytics.astro +20 -1
  142. package/src/components/layout/PageLayout.astro +14 -3
  143. package/src/components/layout/ReferenceLayout.astro +15 -4
  144. package/src/components/layout/RootLayout.astro +15 -4
  145. package/src/components/layout/analytics-client.ts +2 -1
  146. package/src/components/layout/page-locale.ts +29 -0
  147. package/src/components/openapi/AsyncApiOperation.astro +5 -3
  148. package/src/components/openapi/Authorization.astro +4 -6
  149. package/src/components/openapi/Bindings.astro +2 -2
  150. package/src/components/openapi/Description.astro +109 -0
  151. package/src/components/openapi/GraphqlFieldsTable.astro +5 -7
  152. package/src/components/openapi/GraphqlOperation.astro +4 -3
  153. package/src/components/openapi/GraphqlType.astro +4 -6
  154. package/src/components/openapi/ParametersTable.astro +5 -7
  155. package/src/components/openapi/RequestBody.astro +2 -4
  156. package/src/components/openapi/Responses.astro +4 -3
  157. package/src/components/openapi/SchemaProperty.astro +11 -6
  158. package/src/components/openapi/description.ts +91 -0
  159. package/src/core/api-name.ts +18 -0
  160. package/src/core/code-fences.ts +48 -0
  161. package/src/core/config-input.ts +10 -0
  162. package/src/core/content-assets.ts +3 -7
  163. package/src/core/includes.ts +3 -7
  164. package/src/core/package-root.ts +1 -1
  165. package/src/core/schema.ts +27 -0
  166. package/src/core/sources/normalize.ts +2 -37
  167. package/src/core/sources/obsidian.ts +3 -2
  168. package/src/core/svg-dimensions.ts +97 -0
  169. package/src/core/version-cut.ts +2 -2
  170. package/src/deploy/artifacts.ts +370 -0
  171. package/src/deploy/cloudflare-negotiation.ts +97 -32
  172. package/src/deploy/function-bundle.ts +66 -20
  173. package/src/deploy/sitemap.ts +6 -0
  174. package/src/deploy/vercel-negotiation.ts +8 -30
  175. package/src/og/card.ts +6 -12
  176. package/src/openapi/render-mdx.ts +9 -5
  177. package/src/registry/eject.ts +0 -2
  178. package/src/theme/entry.ts +9 -2
@@ -17,6 +17,7 @@ import {
17
17
  THEME_INIT_SCRIPT,
18
18
  } from "./head-scripts.ts";
19
19
  import Header from "./Header.astro";
20
+ import { pageDirection, pageLocale } from "./page-locale.ts";
20
21
  import { searchLocaleFor } from "./search-locale.ts";
21
22
 
22
23
  // A minimal shell for the Scalar API/AsyncAPI reference: Blume's banner + navbar
@@ -48,6 +49,7 @@ interface Props {
48
49
  key: string;
49
50
  } | null;
50
51
  analytics?: {
52
+ cloudflare?: { token: string };
51
53
  posthog?: { host?: string; key: string };
52
54
  scripts?: {
53
55
  attributes?: Record<string, string>;
@@ -65,9 +67,12 @@ interface Props {
65
67
  pageTitle: string;
66
68
  /** Keep the reference route out of crawler indexes. */
67
69
  noindex?: boolean;
68
- /** Active locale code for `<html lang>` (defaults to `en`). */
70
+ /**
71
+ * Active locale code for `<html lang>`. Defaults to the locale Astro
72
+ * resolved from the URL (`Astro.currentLocale`), then the site default.
73
+ */
69
74
  locale?: string;
70
- /** Text direction for `<html dir>` (defaults to `ltr`). */
75
+ /** Text direction for `<html dir>`; defaults to the locale's configured direction. */
71
76
  dir?: "ltr" | "rtl";
72
77
  /** Resolved UI dictionary; English baseline when omitted. */
73
78
  ui?: UIStrings;
@@ -87,11 +92,17 @@ const {
87
92
  searchEnabled,
88
93
  pageTitle,
89
94
  noindex = false,
90
- locale = "en",
91
- dir = "ltr",
95
+ locale: localeProp,
96
+ dir: dirProp,
92
97
  ui,
93
98
  } = Astro.props;
94
99
 
100
+ // The locale and direction: the page's own values when it passes them (the
101
+ // content catch-all always does), else what Astro's i18n routing resolved for
102
+ // this URL, else the site default (see `page-locale.ts`).
103
+ const locale = pageLocale(data.config.i18n, localeProp, Astro.currentLocale);
104
+ const dir = dirProp ?? pageDirection(data.config.i18n, locale);
105
+
95
106
  const strings = ui ?? EN_UI;
96
107
  // Scope search to the reference page's language on a multi-locale site.
97
108
  const searchLocale = searchLocaleFor(data.config.i18n, locale);
@@ -58,6 +58,7 @@ import {
58
58
  } from "./nav-utils.ts";
59
59
  import NavTree from "./NavTree.astro";
60
60
  import { resolveSlot } from "./overrides.ts";
61
+ import { pageDirection, pageLocale } from "./page-locale.ts";
61
62
  import { searchLocaleFor } from "./search-locale.ts";
62
63
  import PageActions from "./PageActions.astro";
63
64
  import PageFeedback from "./PageFeedback.astro";
@@ -84,6 +85,7 @@ interface Props {
84
85
  key: string;
85
86
  } | null;
86
87
  analytics?: {
88
+ cloudflare?: { token: string };
87
89
  posthog?: { host?: string; key: string };
88
90
  scripts?: {
89
91
  attributes?: Record<string, string>;
@@ -148,9 +150,12 @@ interface Props {
148
150
  lastModified?: string | null;
149
151
  noindex?: boolean;
150
152
  structuredDataEnabled?: boolean;
151
- /** Active locale code for `<html lang>` (defaults to `en`). */
153
+ /**
154
+ * Active locale code for `<html lang>`. Defaults to the locale Astro
155
+ * resolved from the URL (`Astro.currentLocale`), then the site default.
156
+ */
152
157
  locale?: string;
153
- /** Text direction for `<html dir>` (defaults to `ltr`). */
158
+ /** Text direction for `<html dir>`; defaults to the locale's configured direction. */
154
159
  dir?: "ltr" | "rtl";
155
160
  /**
156
161
  * Direction of the page content's own language — differs from `dir` on a
@@ -241,8 +246,8 @@ const {
241
246
  lastModified,
242
247
  noindex,
243
248
  structuredDataEnabled,
244
- locale = "en",
245
- dir = "ltr",
249
+ locale: localeProp,
250
+ dir: dirProp,
246
251
  contentDir,
247
252
  ui,
248
253
  localeAlternates,
@@ -258,6 +263,12 @@ const {
258
263
  contentLayout = "default",
259
264
  } = Astro.props;
260
265
 
266
+ // The locale and direction: the page's own values when it passes them (the
267
+ // content catch-all always does), else what Astro's i18n routing resolved for
268
+ // this URL, else the site default (see `page-locale.ts`).
269
+ const locale = pageLocale(data.config.i18n, localeProp, Astro.currentLocale);
270
+ const dir = dirProp ?? pageDirection(data.config.i18n, locale);
271
+
261
272
  // Serialized once for island hooks; `<` escaped so content can't break the tag.
262
273
  const clientDataJson = clientData
263
274
  ? JSON.stringify(clientData).replaceAll("<", "\\u003c")
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Send a custom analytics event to every analytics platform configured in
3
3
  * `blume.config.ts`. Mirrors the providers wired by `Analytics.astro`: Vercel
4
- * Web Analytics and PostHog are first-class; any other provider added through
4
+ * Web Analytics and PostHog are first-class (Cloudflare Web Analytics is too,
5
+ * but has no custom-event API to forward to); any other provider added through
5
6
  * `analytics.scripts` is reached via best-effort global detection or the
6
7
  * `blume:track` CustomEvent, which fires unconditionally so a project can bridge
7
8
  * the event to anything. Every call no-ops cleanly when a provider isn't present
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Locale defaults for the document shells (`RootLayout`, `PageLayout`,
3
+ * `ReferenceLayout`) when the page doesn't pass `locale`/`dir` itself.
4
+ *
5
+ * The content catch-all always passes both, resolved from the route manifest
6
+ * (which also knows about fallback pages). Custom `.astro` pages, the 404
7
+ * page, and the reference shell don't have that data, so they fall back to
8
+ * what Astro's own i18n routing resolved for the request — `Astro.currentLocale`
9
+ * — which the generated config enables with Blume's locale list. Before this,
10
+ * a custom page under `/fr/` rendered `<html lang="en">` unless it threaded
11
+ * the locale through by hand.
12
+ */
13
+
14
+ /**
15
+ * The page's locale: the caller's explicit value, else the one Astro resolved
16
+ * from the URL, else the site's default locale, else `en` (no i18n).
17
+ */
18
+ export const pageLocale = (
19
+ i18n: { defaultLocale: string } | null,
20
+ explicit?: string,
21
+ current?: string
22
+ ): string => explicit ?? current ?? i18n?.defaultLocale ?? "en";
23
+
24
+ /** The configured text direction of a locale; `ltr` when unknown. */
25
+ export const pageDirection = (
26
+ i18n: { locales: { code: string; dir: "ltr" | "rtl" }[] } | null,
27
+ locale: string
28
+ ): "ltr" | "rtl" =>
29
+ i18n?.locales.find((entry) => entry.code === locale)?.dir ?? "ltr";
@@ -23,6 +23,7 @@ import { asyncSampleLanguages } from "./async-snippets.ts";
23
23
  import { languageSamplePanels } from "./sample-panels.ts";
24
24
  import { buildMessage, defaultMessageValues } from "./message.ts";
25
25
  import { messageModel } from "./message-model.ts";
26
+ import Description from "./Description.astro";
26
27
  import MessageComposer from "./MessageComposer.astro";
27
28
  import Authorization from "./Authorization.astro";
28
29
  import Bindings from "./Bindings.astro";
@@ -156,9 +157,10 @@ const channelBindings = bindingGroups(channel?.bindings);
156
157
  : "Message"}
157
158
  </div>
158
159
  {named.message.description && (
159
- <p class="mb-2 text-muted-foreground text-sm">
160
- {named.message.description}
161
- </p>
160
+ <Description
161
+ class="mb-2 text-muted-foreground text-sm"
162
+ text={named.message.description}
163
+ />
162
164
  )}
163
165
  {named.message.contentType && (
164
166
  <div class="mb-2 text-muted-foreground text-xs">
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import Description from "./Description.astro";
2
3
  import {
3
4
  type OperationSecurity,
4
5
  schemeCarrier,
@@ -38,7 +39,7 @@ const { security } = Astro.props;
38
39
  {alternative.map((resolved) => {
39
40
  const carrier = schemeCarrier(resolved);
40
41
  return (
41
- <div class="border-border border-t py-3 first:border-t-0">
42
+ <div class="border-border border-t py-4 first:border-t-0">
42
43
  <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
43
44
  <code class="font-mono text-foreground text-sm">
44
45
  {carrier?.name ?? resolved.key}
@@ -54,13 +55,10 @@ const { security } = Astro.props;
54
55
  )}
55
56
  </div>
56
57
  {resolved.scheme?.description && (
57
- <div
58
- class="mt-1 text-muted-foreground text-sm"
59
- set:text={resolved.scheme.description}
60
- />
58
+ <Description class="mt-2 text-muted-foreground text-sm" text={resolved.scheme.description} />
61
59
  )}
62
60
  {resolved.scopes.length > 0 && (
63
- <div class="mt-1 flex flex-wrap items-center gap-1 text-xs">
61
+ <div class="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
64
62
  <span class="text-muted-foreground">Scopes:</span>
65
63
  {resolved.scopes.map((scope) => (
66
64
  <code class="rounded bg-muted px-1 py-0.5 text-foreground">
@@ -54,13 +54,13 @@ const isSchemaish = (value: unknown): value is SchemaLike => {
54
54
  </div>
55
55
  {groups.map((group) => (
56
56
  <div class="not-prose mb-3 rounded-blume border border-border px-4 last:mb-0">
57
- <div class="flex items-baseline gap-2 border-border py-3">
57
+ <div class="flex items-baseline gap-2 border-border py-4">
58
58
  <code class="font-mono font-semibold text-foreground text-sm">
59
59
  {group.protocol}
60
60
  </code>
61
61
  </div>
62
62
  {group.rows.map((row) => (
63
- <div class="border-border border-t py-3">
63
+ <div class="border-border border-t py-4">
64
64
  <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
65
65
  <code class="font-mono text-foreground text-sm">{row.name}</code>
66
66
  {!isSchemaish(row.value) && (
@@ -0,0 +1,109 @@
1
+ ---
2
+ import { descriptionHtml } from "./description.ts";
3
+
4
+ /**
5
+ * A spec description, rendered as Markdown.
6
+ *
7
+ * One component rather than the same three lines in six places, and that is not only tidiness:
8
+ * the styling below is emitted ONCE per page here, where writing it as utility classes on each
9
+ * element repeated it per description instead. On the largest reference page that is 2,400
10
+ * descriptions — the first attempt at this put 1.5 MB of identical class attributes into a single
11
+ * HTML document and pushed eight pages past Googlebot's 2 MB crawl limit, which `blume audit`
12
+ * caught. A class name and one stylesheet cost the same at one description as at two thousand.
13
+ */
14
+ interface Props {
15
+ /** Extra classes for the wrapper — position and base type come from the caller. */
16
+ class?: string;
17
+ text?: string;
18
+ }
19
+
20
+ const { class: className = "", text } = Astro.props;
21
+ const html = descriptionHtml(text);
22
+ ---
23
+
24
+ {html && <div class:list={["blume-api-description", className]} set:html={html} />}
25
+
26
+ <style is:global>
27
+ /* Plain CSS on a single class, not the typography plugin's `prose`. `prose` sets its own font
28
+ size, colour and rhythm, all of which fight the muted small type these sit in — a property
29
+ description would come out larger and darker than the property name above it. Only what
30
+ Markdown needs is styled; everything else inherits, so a description still reads as
31
+ annotation rather than as body copy. */
32
+ .blume-api-description > :first-child {
33
+ margin-top: 0;
34
+ }
35
+
36
+ .blume-api-description > :last-child {
37
+ margin-bottom: 0;
38
+ }
39
+
40
+ .blume-api-description p,
41
+ .blume-api-description ol,
42
+ .blume-api-description ul,
43
+ .blume-api-description pre {
44
+ margin-block: 0.5rem;
45
+ }
46
+
47
+ .blume-api-description ol {
48
+ list-style: decimal;
49
+ padding-inline-start: 1.25rem;
50
+ }
51
+
52
+ .blume-api-description ul {
53
+ list-style: disc;
54
+ padding-inline-start: 1.25rem;
55
+ }
56
+
57
+ .blume-api-description li {
58
+ margin-block: 0.25rem;
59
+ }
60
+
61
+ /* A heading in a description is a label for the paragraph under it, not a section of the
62
+ page: it keeps the description's own size and only gains the weight and colour of a label.
63
+ Without this it inherits the reset's unstyled heading and is indistinguishable from text. */
64
+ .blume-api-description :is(h1, h2, h3, h4, h5, h6) {
65
+ color: var(--color-foreground);
66
+ font-weight: 600;
67
+ margin-block: 0.5rem;
68
+ }
69
+
70
+ .blume-api-description strong {
71
+ color: var(--color-foreground);
72
+ font-weight: 600;
73
+ }
74
+
75
+ .blume-api-description a {
76
+ text-decoration: underline;
77
+ text-underline-offset: 2px;
78
+ }
79
+
80
+ /* Matched to the chip the reference already draws for an enum value, so an `apiKey` in prose
81
+ looks like the `apiKey` in the chips beside it. */
82
+ .blume-api-description code {
83
+ background: var(--color-muted);
84
+ border-radius: 0.25rem;
85
+ color: var(--color-foreground);
86
+ font-family: var(--font-mono);
87
+ font-size: 0.75rem;
88
+ padding: 0.125rem 0.25rem;
89
+ }
90
+
91
+ .blume-api-description pre {
92
+ background: var(--color-muted);
93
+ border-radius: 0.25rem;
94
+ overflow-x: auto;
95
+ padding: 0.5rem;
96
+ }
97
+
98
+ .blume-api-description pre code {
99
+ background: none;
100
+ padding: 0;
101
+ }
102
+
103
+ /* The scroll frame a table gets from `descriptionHtml` carries the body's own 1.5rem rhythm,
104
+ which is three times what everything else in a description sits at. Only the margin is
105
+ restated; the frame, the cell padding and the scrolling are the renderer's. */
106
+ .blume-api-description .blume-table-scroll {
107
+ margin-block: 0.5rem;
108
+ }
109
+ </style>
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import type { GraphqlFieldRow } from "./graphql-helpers.ts";
3
3
  import { isOutputField } from "./graphql-helpers.ts";
4
+ import Description from "./Description.astro";
4
5
  import GraphqlChip from "./GraphqlChip.astro";
5
6
 
6
7
  /**
@@ -46,7 +47,7 @@ const isRequired = (row: GraphqlFieldRow): boolean =>
46
47
  const route = routes.get(row.type.name);
47
48
  const args = isOutputField(row) ? row.args : [];
48
49
  return (
49
- <div class="border-border border-t py-3 first:border-t-0">
50
+ <div class="border-border border-t py-4 first:border-t-0">
50
51
  <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
51
52
  <code class="font-mono text-foreground text-sm">
52
53
  {row.name}
@@ -68,13 +69,10 @@ const isRequired = (row: GraphqlFieldRow): boolean =>
68
69
  )}
69
70
  </div>
70
71
  {row.description && (
71
- <div
72
- class="mt-1 text-muted-foreground text-sm"
73
- set:text={row.description}
74
- />
72
+ <Description class="mt-2 text-muted-foreground text-sm" text={row.description} />
75
73
  )}
76
74
  {"default" in row && row.default !== undefined && (
77
- <div class="mt-1 text-muted-foreground text-xs">
75
+ <div class="mt-2 text-muted-foreground text-xs">
78
76
  Default:{" "}
79
77
  <code class="rounded bg-muted px-1 py-0.5 text-foreground">
80
78
  {row.default}
@@ -82,7 +80,7 @@ const isRequired = (row: GraphqlFieldRow): boolean =>
82
80
  </div>
83
81
  )}
84
82
  {row.deprecationReason && (
85
- <div class="mt-1 text-muted-foreground text-xs">
83
+ <div class="mt-2 text-muted-foreground text-xs">
86
84
  Deprecated: <span set:text={row.deprecationReason} />
87
85
  </div>
88
86
  )}
@@ -23,6 +23,7 @@ import {
23
23
  import { buildRequest, defaultValues } from "./request.ts";
24
24
  import { languageSamplePanels } from "./sample-panels.ts";
25
25
  import { sampleLanguages } from "./snippets.ts";
26
+ import Description from "./Description.astro";
26
27
  import GraphqlChip from "./GraphqlChip.astro";
27
28
  import GraphqlFieldsTable from "./GraphqlFieldsTable.astro";
28
29
  import GraphqlType from "./GraphqlType.astro";
@@ -154,9 +155,9 @@ const returnRoute = field ? routes.get(field.type.name) : undefined;
154
155
  />
155
156
  </div>
156
157
  {document.types[field.type.name]?.description && (
157
- <p
158
- class="mt-1 text-muted-foreground text-sm"
159
- set:text={document.types[field.type.name]?.description}
158
+ <Description
159
+ class="mt-2 text-muted-foreground text-sm"
160
+ text={document.types[field.type.name]?.description}
160
161
  />
161
162
  )}
162
163
  </section>
@@ -7,6 +7,7 @@ import {
7
7
  graphqlRoutes,
8
8
  graphqlUsage,
9
9
  } from "./graphql-helpers.ts";
10
+ import Description from "./Description.astro";
10
11
  import GraphqlChip from "./GraphqlChip.astro";
11
12
  import GraphqlFieldsTable from "./GraphqlFieldsTable.astro";
12
13
  import MethodBadge from "./MethodBadge.astro";
@@ -86,7 +87,7 @@ const SECTION_HEADING = "mb-2 font-semibold text-foreground text-sm";
86
87
  </div>
87
88
  <div class="rounded-blume border border-border px-4">
88
89
  {(type.enumValues ?? []).map((value) => (
89
- <div class="border-border border-t py-3 first:border-t-0">
90
+ <div class="border-border border-t py-4 first:border-t-0">
90
91
  <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
91
92
  <code class="font-mono text-foreground text-sm">
92
93
  {value.name}
@@ -98,13 +99,10 @@ const SECTION_HEADING = "mb-2 font-semibold text-foreground text-sm";
98
99
  )}
99
100
  </div>
100
101
  {value.description && (
101
- <div
102
- class="mt-1 text-muted-foreground text-sm"
103
- set:text={value.description}
104
- />
102
+ <Description class="mt-2 text-muted-foreground text-sm" text={value.description} />
105
103
  )}
106
104
  {value.deprecationReason && (
107
- <div class="mt-1 text-muted-foreground text-xs">
105
+ <div class="mt-2 text-muted-foreground text-xs">
108
106
  Deprecated: <span set:text={value.deprecationReason} />
109
107
  </div>
110
108
  )}
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import Description from "./Description.astro";
2
3
  import {
3
4
  constraints,
4
5
  resolveSchema,
@@ -54,7 +55,7 @@ const groups = SECTIONS.map((section) => ({
54
55
  const limits = constraints(resolved);
55
56
  const enumValues = Array.isArray(resolved.enum) ? resolved.enum : null;
56
57
  return (
57
- <div class="border-border border-t py-3 first:border-t-0">
58
+ <div class="border-border border-t py-4 first:border-t-0">
58
59
  <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
59
60
  <code class="font-mono text-foreground text-sm">{param.name}</code>
60
61
  <span class="text-muted-foreground text-xs">{type}</span>
@@ -70,18 +71,15 @@ const groups = SECTIONS.map((section) => ({
70
71
  )}
71
72
  </div>
72
73
  {param.description && (
73
- <div
74
- class="mt-1 text-muted-foreground text-sm"
75
- set:text={param.description}
76
- />
74
+ <Description class="mt-2 text-muted-foreground text-sm" text={param.description} />
77
75
  )}
78
76
  {limits.length > 0 && (
79
- <div class="mt-1 text-muted-foreground text-xs">
77
+ <div class="mt-2 text-muted-foreground text-xs">
80
78
  {limits.join(" · ")}
81
79
  </div>
82
80
  )}
83
81
  {enumValues && (
84
- <div class="mt-1 flex flex-wrap items-center gap-1 text-xs">
82
+ <div class="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
85
83
  <span class="text-muted-foreground">Allowed:</span>
86
84
  {enumValues.map((value) => (
87
85
  <code class="rounded bg-muted px-1 py-0.5 text-foreground">
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import Description from "./Description.astro";
2
3
  import type { SchemaLike } from "./helpers.ts";
3
4
  import SchemaTable from "./SchemaTable.astro";
4
5
 
@@ -46,10 +47,7 @@ const schema = chosen?.[1]?.schema ?? {};
46
47
  </div>
47
48
  {
48
49
  requestBody.description && (
49
- <div
50
- class="text-muted-foreground text-sm"
51
- set:text={requestBody.description}
52
- />
50
+ <Description class="text-muted-foreground text-sm" text={requestBody.description} />
53
51
  )
54
52
  }
55
53
  <div class="mt-3">
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { statusColor } from "../colors.ts";
3
+ import Description from "./Description.astro";
3
4
  import type { SchemaLike } from "./helpers.ts";
4
5
  import SchemaTable from "./SchemaTable.astro";
5
6
 
@@ -52,9 +53,9 @@ const items = Object.entries(responses);
52
53
  {status}
53
54
  </span>
54
55
  {response.description && (
55
- <span
56
- class="text-muted-foreground text-sm"
57
- set:text={response.description}
56
+ <Description
57
+ class="min-w-0 text-muted-foreground text-sm"
58
+ text={response.description}
58
59
  />
59
60
  )}
60
61
  </div>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import Description from "./Description.astro";
2
3
  import {
3
4
  constraints,
4
5
  isNullable,
@@ -54,7 +55,11 @@ const expandable =
54
55
  !circular && (hasObjectShape(resolved) || Boolean(items && hasObjectShape(items)));
55
56
  ---
56
57
 
57
- <div class="border-border border-t py-3 first:border-t-0 last:pb-0">
58
+ {/* The row's vertical scale is 8 / 12 / 16px, and every other reference table copies it. A
59
+ description is a block of Markdown, not a line: at 4px it sat closer to the label above it
60
+ than its own paragraphs sat to each other, and the disclosure below it touched the next row's
61
+ divider. Each step separates a bigger unit than the one before. */}
62
+ <div class="border-border border-t py-4 first:border-t-0 last:pb-0">
58
63
  <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
59
64
  <code class="font-mono text-foreground text-sm">{name}</code>
60
65
  <span class="text-muted-foreground text-xs"
@@ -77,17 +82,17 @@ const expandable =
77
82
  </div>
78
83
  {
79
84
  description && (
80
- <div class="mt-1 text-muted-foreground text-sm" set:text={description} />
85
+ <Description class="mt-2 text-muted-foreground text-sm" text={description} />
81
86
  )
82
87
  }
83
88
  {
84
89
  limits.length > 0 && (
85
- <div class="mt-1 text-muted-foreground text-xs">{limits.join(" · ")}</div>
90
+ <div class="mt-2 text-muted-foreground text-xs">{limits.join(" · ")}</div>
86
91
  )
87
92
  }
88
93
  {
89
94
  enumValues && (
90
- <div class="mt-1 flex flex-wrap items-center gap-1 text-xs">
95
+ <div class="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
91
96
  <span class="text-muted-foreground">Allowed:</span>
92
97
  {enumValues.map((value) => (
93
98
  <code class="rounded bg-muted px-1 py-0.5 text-foreground">
@@ -99,12 +104,12 @@ const expandable =
99
104
  }
100
105
  {
101
106
  expandable && (
102
- <details class="mt-2" open={expandAll}>
107
+ <details class="mt-3" open={expandAll}>
103
108
  <summary class="cursor-pointer select-none text-accent text-xs hover:underline">
104
109
  <span class="[details[open]>summary_&]:hidden">Show properties</span>
105
110
  <span class="hidden [details[open]>summary_&]:inline">Hide properties</span>
106
111
  </summary>
107
- <div class="mt-2 border-border border-l pl-4">
112
+ <div class="mt-3 border-border border-l pl-4">
108
113
  <SchemaTable
109
114
  schema={schema}
110
115
  schemas={schemas}
@@ -0,0 +1,91 @@
1
+ import { Marked } from "marked";
2
+
3
+ /**
4
+ * Render a spec description as Markdown.
5
+ *
6
+ * An OpenAPI `description` is Markdown by specification — "CommonMark syntax MAY be used for rich
7
+ * text representation" — but the reference components printed it with `set:text`, so a schema
8
+ * property, parameter or header showed its source. On a spec generated from code docstrings that
9
+ * is most of them: `**Inline**` printed its asterisks, `` `apiKey` `` printed its backticks, and
10
+ * because HTML collapses newlines every paragraph and list ran together into one wall of text.
11
+ * The operation description does not have this problem — it is emitted into the MDX body and goes
12
+ * through the full pipeline — which is what made the difference visible page by page.
13
+ *
14
+ * `marked` rather than the site's own Markdown pipeline: the pipeline is async, plugin-laden and
15
+ * built for whole documents, while these are thousands of short strings per build — a large
16
+ * reference renders tens of thousands of them. `marked` is synchronous, already a dependency,
17
+ * and already how the Ask AI island renders model Markdown.
18
+ */
19
+ const TABLE = /<table>[\s\S]*?<\/table>/gu;
20
+
21
+ /**
22
+ * An href the author can have meant: an absolute URL, a site-root path, a fragment or a mailto.
23
+ * A bare relative href in a spec description has never yet been a link, and the lookahead keeps
24
+ * `//host` out: that is a scheme-relative URL to another origin, not a site-root path.
25
+ */
26
+ const DELIBERATE_HREF = /^(?:https?:|mailto:|#|\/(?!\/))/iu;
27
+
28
+ /** The source text of a demoted construct, made safe for `set:html`. */
29
+ const escapeHtml = (text: string): string =>
30
+ text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
31
+
32
+ const markdown = new Marked({
33
+ // `breaks` is deliberately NOT set, unlike the Ask AI island. Docstring prose is hard-wrapped at
34
+ // 72 or 79 columns, so honouring single newlines would break every sentence mid-flow at exactly
35
+ // the width the source file happened to use.
36
+ breaks: false,
37
+ gfm: true,
38
+ hooks: {
39
+ // GFM tables reach the page through `set:html`, so they miss the `blume:table-wrap` plugin
40
+ // that gives every table in the body its scroll frame — and a description sits in a column
41
+ // narrower than the body. Wrapping here reuses that frame rather than reinventing it; the
42
+ // regex is safe because a table cannot nest and this HTML is `marked`'s own output.
43
+ postprocess: (html: string) =>
44
+ html.replaceAll(
45
+ TABLE,
46
+ (table) => `<div class="blume-table-scroll" tabindex="0">${table}</div>`
47
+ ),
48
+ },
49
+ renderer: {
50
+ // Raw HTML is escaped rather than passed through. A description is data lifted out of a spec
51
+ // file, frequently generated upstream from source comments, and it is interpolated with
52
+ // `set:html`; the island that renders model output runs DOMPurify over it for the same
53
+ // reason, which needs a DOM and so is unavailable in a component that renders on the server.
54
+ html: ({ text }: { text: string }) => escapeHtml(text),
55
+ // An image is held to the same href policy as a link, for the same reason: `![x](src)` is the
56
+ // one Markdown construct that fetches a resource, and a `javascript:` or relative source in a
57
+ // description is notation rather than a picture.
58
+ image: ({ href, raw }: { href: string; raw: string }) =>
59
+ DELIBERATE_HREF.test(href) ? false : escapeHtml(raw),
60
+ // A link is emitted only when the author clearly meant one; anything else keeps its source
61
+ // text verbatim. Two failures drove this, and both are prose that was never Markdown.
62
+ //
63
+ // GFM autolinks a BARE url (`raw === href`), and a spec description is full of EXAMPLE hosts
64
+ // — `https://myorg.my.salesforce.com`, `https://yourstore.myshopify.com`. Each became an
65
+ // anchor pointing at a host that does not exist and was never meant to be visited.
66
+ //
67
+ // Worse, regex and format notation reads as link syntax. Debezium's own wording for a column
68
+ // list is `schemaName[.]tableName[.](columnName1|columnName2)`, in which `[.](columnName1|
69
+ // columnName2)` is EXACTLY `[text](href)` — 48 pages of one reference linked to a path made
70
+ // of that notation.
71
+ // Rendering the demoted case as `raw` rather than as `text` is what keeps that intact: the
72
+ // text alone is `.`, so emitting it would silently delete the rest of the notation.
73
+ //
74
+ // The test for "meant one" is the href (see `DELIBERATE_HREF`) plus the shape of the source:
75
+ // an author-written link starts with `[` (inline or reference style) or `<` (an angle
76
+ // autolink). A GFM bare autolink never does — `https://…`, `www.…` or an email — and
77
+ // comparing `raw` to `href` is not enough to catch it, because marked prefixes the missing
78
+ // `http://` or `mailto:` for the last two, so the two strings differ exactly as they would
79
+ // for a deliberate link.
80
+ link({ href, raw }: { href: string; raw: string }) {
81
+ const deliberate =
82
+ DELIBERATE_HREF.test(href) &&
83
+ (raw.startsWith("[") || raw.startsWith("<"));
84
+ return deliberate ? false : escapeHtml(raw);
85
+ },
86
+ },
87
+ });
88
+
89
+ /** `description` rendered to HTML, or an empty string when there is nothing to render. */
90
+ export const descriptionHtml = (description: string | undefined): string =>
91
+ description?.trim() ? markdown.parse(description, { async: false }) : "";