blume 1.5.3 → 1.6.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 (209) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +3949 -1403
  3. package/dist/cli/index.js.map +111 -96
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +33 -2
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +10 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +122 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +29 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +26 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/07-faq.mdx +9 -9
  21. package/docs/_snippets/include-demo.mdx +7 -0
  22. package/docs/advanced/api-reference.mdx +13 -4
  23. package/docs/advanced/custom-pages.mdx +4 -2
  24. package/docs/advanced/graphql.mdx +84 -0
  25. package/docs/advanced/meta.ts +8 -1
  26. package/docs/configuration/ai.mdx +25 -3
  27. package/docs/configuration/index.mdx +24 -0
  28. package/docs/configuration/search.mdx +13 -1
  29. package/docs/configuration/seo.mdx +30 -3
  30. package/docs/configuration/theming.mdx +23 -0
  31. package/docs/content/components.mdx +15 -1
  32. package/docs/content/includes.mdx +68 -0
  33. package/docs/content/meta.ts +1 -0
  34. package/docs/content/navigation.mdx +25 -0
  35. package/docs/content/sources.mdx +42 -1
  36. package/docs/content/syntax.mdx +69 -1
  37. package/docs/content/versioning.mdx +15 -9
  38. package/docs/reference/cli.mdx +2 -1
  39. package/package.json +66 -57
  40. package/skills/blume-migrate/SKILL.md +16 -7
  41. package/skills/blume-migrate/references/docusaurus.md +5 -3
  42. package/skills/blume-migrate/references/fumadocs.md +10 -2
  43. package/skills/blume-migrate/references/mintlify.md +3 -2
  44. package/skills/blume-migrate/references/nextra.md +2 -2
  45. package/skills/blume-migrate/references/starlight.md +1 -1
  46. package/src/ai/agent-readability.ts +2 -1
  47. package/src/ai/ask-data.ts +2 -1
  48. package/src/ai/component-markdown.ts +199 -36
  49. package/src/ai/llms.ts +93 -6
  50. package/src/ai/markdown.ts +2 -2
  51. package/src/ai/mcp/discovery.ts +10 -2
  52. package/src/ai/mcp/server.ts +74 -2
  53. package/src/astro/examples.ts +29 -2
  54. package/src/astro/generate.ts +282 -177
  55. package/src/astro/include-hmr.ts +81 -0
  56. package/src/astro/include-refresh.ts +0 -0
  57. package/src/astro/index.ts +10 -5
  58. package/src/astro/markdown-negotiation.ts +1 -1
  59. package/src/astro/runtime-modules.ts +196 -0
  60. package/src/astro/templates.ts +365 -113
  61. package/src/cli/commands/build.ts +91 -16
  62. package/src/cli/commands/dev.ts +6 -3
  63. package/src/cli/host-args.ts +18 -0
  64. package/src/cli/index.ts +2 -1
  65. package/src/cli/init/questions.ts +1 -0
  66. package/src/cli/init/scaffold.ts +27 -4
  67. package/src/components/colors.ts +142 -0
  68. package/src/components/content/Badge.astro +5 -12
  69. package/src/components/content/Callout.astro +19 -36
  70. package/src/components/content/Card.astro +15 -21
  71. package/src/components/content/Component.astro +10 -1
  72. package/src/components/content/GithubInfo.astro +28 -9
  73. package/src/components/content/Tabs.astro +27 -5
  74. package/src/components/content/github-info.ts +20 -5
  75. package/src/components/copy-feedback.ts +93 -9
  76. package/src/components/dropdown-dismiss.ts +122 -0
  77. package/src/components/islands/ask-ai.tsx +4 -1
  78. package/src/components/islands/hooks.ts +3 -1
  79. package/src/components/layout/Fonts.astro +15 -8
  80. package/src/components/layout/Header.astro +44 -0
  81. package/src/components/layout/LanguageSwitcher.astro +9 -1
  82. package/src/components/layout/NavSelector.astro +12 -3
  83. package/src/components/layout/NavTree.astro +6 -18
  84. package/src/components/layout/PageActions.astro +54 -22
  85. package/src/components/layout/PageLayout.astro +2 -0
  86. package/src/components/layout/ReferenceLayout.astro +6 -1
  87. package/src/components/layout/RootLayout.astro +42 -15
  88. package/src/components/layout/Search.astro +36 -4
  89. package/src/components/layout/TableOfContents.astro +8 -2
  90. package/src/components/layout/head-scripts.ts +30 -1
  91. package/src/components/openapi/ApiOverview.astro +13 -3
  92. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  93. package/src/components/openapi/GraphqlChip.astro +33 -0
  94. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  95. package/src/components/openapi/GraphqlOperation.astro +186 -0
  96. package/src/components/openapi/GraphqlType.astro +154 -0
  97. package/src/components/openapi/MethodBadge.astro +3 -14
  98. package/src/components/openapi/Operation.astro +12 -5
  99. package/src/components/openapi/OperationPanel.astro +43 -0
  100. package/src/components/openapi/RequestPanel.astro +5 -10
  101. package/src/components/openapi/Responses.astro +1 -16
  102. package/src/components/openapi/graphql-helpers.ts +466 -0
  103. package/src/components/openapi/playground-client.ts +15 -0
  104. package/src/components/openapi/sample-panels.ts +45 -0
  105. package/src/components/openapi/snippets.ts +13 -35
  106. package/src/core/base-path.ts +11 -0
  107. package/src/core/config-input.ts +209 -2
  108. package/src/core/config.ts +6 -4
  109. package/src/core/content-assets.ts +15 -4
  110. package/src/core/data.ts +28 -3
  111. package/src/core/define-components.ts +2 -0
  112. package/src/core/diagnostics.ts +8 -0
  113. package/src/core/frontmatter.ts +20 -8
  114. package/src/core/github.ts +71 -0
  115. package/src/core/graph.ts +22 -8
  116. package/src/core/heading-markers.ts +96 -0
  117. package/src/core/i18n-ui.ts +12 -0
  118. package/src/core/includes.ts +633 -0
  119. package/src/core/last-modified.ts +36 -11
  120. package/src/core/links.ts +79 -13
  121. package/src/core/manifest.ts +10 -0
  122. package/src/core/meta.ts +2 -1
  123. package/src/core/nav-diagnostics.ts +11 -2
  124. package/src/core/navigation.ts +27 -6
  125. package/src/core/project-graph.ts +61 -9
  126. package/src/core/schema.ts +235 -36
  127. package/src/core/server-features.ts +5 -9
  128. package/src/core/sources/github-releases.ts +2 -2
  129. package/src/core/sources/normalize.ts +502 -115
  130. package/src/core/sources/notion.ts +43 -8
  131. package/src/core/sources/obsidian.ts +1038 -0
  132. package/src/core/sources/read.ts +36 -1
  133. package/src/core/sources/resolve.ts +34 -1
  134. package/src/core/sources/types.ts +28 -6
  135. package/src/core/sources/watch.ts +12 -8
  136. package/src/core/tsconfig-aliases.ts +48 -35
  137. package/src/core/types.ts +31 -2
  138. package/src/core/ui-packs/ar.ts +2 -0
  139. package/src/core/ui-packs/bg.ts +3 -0
  140. package/src/core/ui-packs/bn.ts +2 -0
  141. package/src/core/ui-packs/ca.ts +3 -0
  142. package/src/core/ui-packs/cs.ts +2 -0
  143. package/src/core/ui-packs/da.ts +2 -0
  144. package/src/core/ui-packs/de.ts +3 -0
  145. package/src/core/ui-packs/el.ts +3 -0
  146. package/src/core/ui-packs/es.ts +3 -0
  147. package/src/core/ui-packs/fa.ts +2 -0
  148. package/src/core/ui-packs/fi.ts +2 -0
  149. package/src/core/ui-packs/fr.ts +3 -0
  150. package/src/core/ui-packs/he.ts +2 -0
  151. package/src/core/ui-packs/hi.ts +2 -0
  152. package/src/core/ui-packs/hr.ts +3 -0
  153. package/src/core/ui-packs/hu.ts +3 -0
  154. package/src/core/ui-packs/id.ts +3 -0
  155. package/src/core/ui-packs/it.ts +2 -0
  156. package/src/core/ui-packs/ja.ts +3 -0
  157. package/src/core/ui-packs/ko.ts +3 -0
  158. package/src/core/ui-packs/nl.ts +3 -0
  159. package/src/core/ui-packs/no.ts +3 -0
  160. package/src/core/ui-packs/pl.ts +3 -0
  161. package/src/core/ui-packs/pt-br.ts +3 -0
  162. package/src/core/ui-packs/pt.ts +3 -0
  163. package/src/core/ui-packs/ro.ts +3 -0
  164. package/src/core/ui-packs/ru.ts +3 -0
  165. package/src/core/ui-packs/sk.ts +2 -0
  166. package/src/core/ui-packs/sr.ts +2 -0
  167. package/src/core/ui-packs/sv.ts +3 -0
  168. package/src/core/ui-packs/th.ts +2 -0
  169. package/src/core/ui-packs/tr.ts +3 -0
  170. package/src/core/ui-packs/uk.ts +3 -0
  171. package/src/core/ui-packs/vi.ts +2 -0
  172. package/src/core/ui-packs/zh-tw.ts +2 -0
  173. package/src/core/ui-packs/zh.ts +2 -0
  174. package/src/core/version-cut.ts +26 -6
  175. package/src/core/yaml.ts +26 -0
  176. package/src/deploy/function-bundle.ts +251 -0
  177. package/src/deploy/vercel-negotiation.ts +49 -6
  178. package/src/eval/schema.ts +3 -1
  179. package/src/markdown/code-title.ts +22 -16
  180. package/src/markdown/features.ts +21 -0
  181. package/src/markdown/fence-meta.ts +50 -0
  182. package/src/markdown/heading-anchors.ts +198 -37
  183. package/src/markdown/include.ts +247 -0
  184. package/src/markdown/index.ts +43 -34
  185. package/src/markdown/language-icon.ts +2 -2
  186. package/src/markdown/mdast.ts +7 -3
  187. package/src/markdown/ts2js.ts +264 -0
  188. package/src/og/card.ts +1 -1
  189. package/src/openapi/asyncapi.ts +4 -1
  190. package/src/openapi/graphql-build.ts +293 -0
  191. package/src/openapi/graphql.ts +212 -0
  192. package/src/openapi/model.ts +38 -5
  193. package/src/openapi/parse.ts +34 -0
  194. package/src/openapi/proxy.ts +30 -5
  195. package/src/openapi/references.ts +97 -13
  196. package/src/openapi/render-mdx.ts +66 -12
  197. package/src/openapi/scalar.ts +5 -16
  198. package/src/openapi/source.ts +91 -23
  199. package/src/registry/eject.ts +47 -17
  200. package/src/search/documents.ts +229 -37
  201. package/src/search/orama-index.ts +9 -5
  202. package/src/seo/jsonld.ts +293 -51
  203. package/src/theme/code-block-padding.ts +16 -0
  204. package/src/theme/entry.ts +67 -13
  205. package/src/theme/fonts.ts +189 -16
  206. package/src/theme/sources.ts +49 -0
  207. package/src/translate/prompts.ts +2 -0
  208. package/src/translate/run.ts +7 -0
  209. package/src/translate/work-list.ts +0 -0
@@ -14,7 +14,7 @@ export { normalizeRoute } from "../core/base-path.ts";
14
14
  * imports so `core` can depend on it without a cycle.
15
15
  */
16
16
 
17
- export type ReferenceKind = "openapi" | "asyncapi";
17
+ export type ReferenceKind = "openapi" | "asyncapi" | "graphql";
18
18
 
19
19
  /** Who renders a reference: Blume's own UI, or the embedded Scalar SPA. */
20
20
  export type ReferenceRenderer = "blume" | "scalar";
@@ -54,8 +54,18 @@ export interface ReferenceSource {
54
54
  includeInSearch: boolean;
55
55
  /** Whether generated pages emit noindex metadata and stay out of the sitemap. */
56
56
  noindex: boolean;
57
+ /**
58
+ * Whether operation meta descriptions end with the generated English
59
+ * "Reference for …" sentence, or carry the spec's own prose alone.
60
+ */
61
+ seoDescriptionSuffix: boolean;
57
62
  /** Local path or `http(s)` URL, verbatim from config. */
58
63
  spec: string;
64
+ /**
65
+ * URL of the live GraphQL endpoint the playground and code samples target
66
+ * (GraphQL only — a schema, unlike an OpenAPI document, names no server).
67
+ */
68
+ endpoint?: string;
59
69
  /** Per-block Scalar theme name override, if any (Scalar renderer only). */
60
70
  theme?: string;
61
71
  /**
@@ -109,25 +119,40 @@ export const slugify = (text: string): string =>
109
119
  const routeSlug = (route: string): string =>
110
120
  slugify(trimChar(route, "/")) || "reference";
111
121
 
112
- type Block = ResolvedConfig["openapi"] | ResolvedConfig["asyncapi"];
122
+ /**
123
+ * The structural shape all three reference blocks (`openapi`, `asyncapi`,
124
+ * `graphql`) share. `endpoint` exists only on the GraphQL block and its
125
+ * sources; `scalar`/`theme` only on the Scalar-capable kinds — optional here
126
+ * so one resolver serves every block.
127
+ */
128
+ interface Block {
129
+ enabled: boolean;
130
+ endpoint?: string;
131
+ route: string;
132
+ scalar?: ResolvedConfig["openapi"]["scalar"];
133
+ sources: {
134
+ endpoint?: string;
135
+ includeInLlms: boolean;
136
+ includeInSearch: boolean;
137
+ label?: string;
138
+ noindex: boolean;
139
+ route?: string;
140
+ seoDescriptionSuffix: boolean;
141
+ spec: string;
142
+ }[];
143
+ spec?: string;
144
+ theme?: string;
145
+ }
113
146
 
114
147
  /** A spec is a single source (`spec` shorthand prepended to any `sources`). */
115
- const sourcesOf = (
116
- block: Block
117
- ): {
118
- includeInLlms: boolean;
119
- includeInSearch: boolean;
120
- label?: string;
121
- noindex: boolean;
122
- route?: string;
123
- spec: string;
124
- }[] => {
148
+ const sourcesOf = (block: Block): Block["sources"] => {
125
149
  const sources = [...block.sources];
126
150
  if (block.spec) {
127
151
  sources.unshift({
128
152
  includeInLlms: true,
129
153
  includeInSearch: true,
130
154
  noindex: false,
155
+ seoDescriptionSuffix: true,
131
156
  spec: block.spec,
132
157
  });
133
158
  }
@@ -163,7 +188,7 @@ const referencesFor = (
163
188
  route = normalizeRoute(`${base}/${suffix || index + 1}`);
164
189
  }
165
190
 
166
- return {
191
+ const reference: ReferenceSource = {
167
192
  basePath,
168
193
  display,
169
194
  includeInLlms: source.includeInLlms,
@@ -174,10 +199,18 @@ const referencesFor = (
174
199
  renderer,
175
200
  route,
176
201
  scalar: block.scalar,
202
+ seoDescriptionSuffix: source.seoDescriptionSuffix,
177
203
  slug: routeSlug(route),
178
204
  spec: source.spec,
179
205
  theme: block.theme,
180
206
  };
207
+ // Per-source endpoint wins; the block-level one is the shared default
208
+ // (the common single-schema case pairs it with the `spec` shorthand).
209
+ const endpoint = source.endpoint ?? block.endpoint;
210
+ if (endpoint !== undefined) {
211
+ reference.endpoint = endpoint;
212
+ }
213
+ return reference;
181
214
  });
182
215
  };
183
216
 
@@ -212,6 +245,21 @@ export const resolveReferences = (
212
245
  },
213
246
  config.basePath
214
247
  ),
248
+ // GraphQL is always Blume-rendered — Scalar's embedded SPA reads OpenAPI
249
+ // documents only, so the block declares no `renderer` opt-out (nor the
250
+ // schema-row `expandSchemas` toggle; GraphQL field tables have no nesting).
251
+ ...referencesFor(
252
+ "graphql",
253
+ config.graphql,
254
+ "GraphQL",
255
+ "blume",
256
+ {
257
+ codeSamples: config.graphql.codeSamples,
258
+ expandSchemas: false,
259
+ playground: config.graphql.playground,
260
+ },
261
+ config.basePath
262
+ ),
215
263
  ];
216
264
 
217
265
  /**
@@ -287,3 +335,39 @@ export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
287
335
  /** Whether any reference is Scalar-rendered (gates the `@scalar/astro` dep + pages). */
288
336
  export const hasScalarReferences = (config: ResolvedConfig): boolean =>
289
337
  resolveReferences(config).some((ref) => ref.renderer === "scalar");
338
+
339
+ /**
340
+ * The reference kinds whose enabled, Blume-rendered playground opted into the
341
+ * built-in CORS proxy with `proxy: true`. A proxy URL string points at an
342
+ * external service, and `false` sends requests directly — neither routes
343
+ * through the endpoint. The generator's per-spec allowlist diagnostics key on
344
+ * this, so it shares one definition with {@link needsPlaygroundProxy}.
345
+ */
346
+ export const builtinProxyKinds = (config: ResolvedConfig): ReferenceKind[] => {
347
+ const kinds: ReferenceKind[] = [];
348
+ if (
349
+ config.openapi.enabled &&
350
+ config.openapi.renderer === "blume" &&
351
+ config.openapi.playground.enabled &&
352
+ config.openapi.playground.proxy === true
353
+ ) {
354
+ kinds.push("openapi");
355
+ }
356
+ if (
357
+ config.graphql.enabled &&
358
+ config.graphql.playground.enabled &&
359
+ config.graphql.playground.proxy === true
360
+ ) {
361
+ kinds.push("graphql");
362
+ }
363
+ return kinds;
364
+ };
365
+
366
+ /**
367
+ * Whether the built-in playground CORS proxy endpoint (`/_api-proxy`) must be
368
+ * generated: some enabled Blume-rendered block's playground opted into it with
369
+ * `proxy: true`. Shared by the server feature gate and the generator so the
370
+ * two can never disagree.
371
+ */
372
+ export const needsPlaygroundProxy = (config: ResolvedConfig): boolean =>
373
+ builtinProxyKinds(config).length > 0;
@@ -4,6 +4,8 @@ import { toString as mdastToString } from "mdast-util-to-string";
4
4
  import stringWidth from "string-width";
5
5
 
6
6
  import { columnsPrefix } from "../core/text-width.ts";
7
+ import type { GraphqlMember } from "./graphql.ts";
8
+ import { isGraphqlOperationKind } from "./graphql.ts";
7
9
  import type { ApiOperationRef, ApiSpecData } from "./model.ts";
8
10
  import type { ReferenceSource } from "./references.ts";
9
11
 
@@ -156,20 +158,51 @@ const clip = (text: string, max: number): string => {
156
158
 
157
159
  const apiName = (spec: ApiSpecData): string => spec.title || spec.label;
158
160
 
161
+ /** Human phrase for each GraphQL page kind, for meta descriptions. */
162
+ const GRAPHQL_MEMBER_PHRASES = {
163
+ enum: "enum type",
164
+ input: "input object type",
165
+ interface: "interface type",
166
+ mutation: "mutation",
167
+ object: "object type",
168
+ query: "query",
169
+ scalar: "scalar type",
170
+ subscription: "subscription",
171
+ union: "union type",
172
+ } satisfies Record<GraphqlMember, string>;
173
+
159
174
  /**
160
175
  * The spec's own prose for the operation, followed by the endpoint it documents
161
176
  * — so every operation page carries a distinct, self-describing meta
162
- * description even when the spec's summaries are terse.
177
+ * description even when the spec's summaries are terse. With the suffix
178
+ * switched off (`seoDescriptionSuffix: false`, for sites whose prose isn't
179
+ * English) the description is the prose alone, or the page `title` — a
180
+ * language-neutral `GET /pets`, channel, or field name — when the operation
181
+ * has no prose at all, so no page ships an empty description.
163
182
  */
164
183
  const operationDescription = (
165
184
  spec: ApiSpecData,
166
- operation: ApiOperationRef
185
+ operation: ApiOperationRef,
186
+ options: { suffix: boolean; title: string }
167
187
  ): string => {
168
- // AsyncAPI operations act on a channel, not an HTTP endpoint.
169
- const suffix =
170
- spec.kind === "asyncapi"
171
- ? `Reference for the ${operation.method} operation on ${operation.path} in the ${apiName(spec)} API.`
172
- : `Reference for the ${operation.method.toUpperCase()} ${operation.path} endpoint in the ${apiName(spec)} API.`;
188
+ if (!options.suffix) {
189
+ return clip(
190
+ plainProse(operation.description || operation.summary) || options.title,
191
+ META_DESCRIPTION_MAX
192
+ );
193
+ }
194
+ // AsyncAPI operations act on a channel, not an HTTP endpoint; GraphQL pages
195
+ // document a root field or a named type.
196
+ let suffix: string;
197
+ if (spec.kind === "asyncapi") {
198
+ suffix = `Reference for the ${operation.method} operation on ${operation.path} in the ${apiName(spec)} API.`;
199
+ } else if (spec.kind === "graphql") {
200
+ // SAFETY: the GraphQL extractor only ever assigns member kinds as the
201
+ // method (see `extractGraphqlOperations`).
202
+ suffix = `Reference for the ${operation.path} ${GRAPHQL_MEMBER_PHRASES[operation.method as GraphqlMember]} in the ${apiName(spec)} API.`;
203
+ } else {
204
+ suffix = `Reference for the ${operation.method.toUpperCase()} ${operation.path} endpoint in the ${apiName(spec)} API.`;
205
+ }
173
206
  const prose = clip(
174
207
  plainProse(operation.description || operation.summary),
175
208
  META_DESCRIPTION_MAX - stringWidth(suffix) - 1
@@ -188,11 +221,17 @@ export const operationMdx = (
188
221
  operation: ApiOperationRef,
189
222
  reference?: Pick<
190
223
  ReferenceSource,
191
- "includeInLlms" | "includeInSearch" | "noindex"
224
+ "includeInLlms" | "includeInSearch" | "noindex" | "seoDescriptionSuffix"
192
225
  >
193
226
  ): RenderedPage => {
194
227
  const method = operation.method.toUpperCase();
195
- const title = operation.summary || `${method} ${operation.path}`;
228
+ const graphql = spec.kind === "graphql";
229
+ // A GraphQL page IS its field/type — `QUERY pets` would double the badge the
230
+ // page already renders; the other kinds title an endpoint or channel action.
231
+ const fallbackTitle = graphql
232
+ ? operation.path
233
+ : `${method} ${operation.path}`;
234
+ const title = operation.summary || fallbackTitle;
196
235
  // Skip the body description when it only repeats the summary (the `<h1>`) —
197
236
  // common in specs that set summary and description to the same string.
198
237
  const description =
@@ -214,11 +253,26 @@ export const operationMdx = (
214
253
  searchFlags.exclude = true;
215
254
  }
216
255
  const seo: RenderedPageData["seo"] = {
217
- description: operationDescription(spec, operation),
256
+ description: operationDescription(spec, operation, {
257
+ suffix: reference?.seoDescriptionSuffix !== false,
258
+ title,
259
+ }),
218
260
  };
219
261
  if (reference?.noindex) {
220
262
  seo.noindex = true;
221
263
  }
264
+ const sidebar: RenderedPageData["sidebar"] = {
265
+ label: operation.summary || operation.path,
266
+ };
267
+ // GraphQL operation kinds badge like HTTP methods, but a type page's kind
268
+ // already heads its sidebar group ("Objects", "Enums", …) — an `OBJECT`
269
+ // badge on every row would only repeat it, so type pages get none. The
270
+ // uppercased method is likewise an internal token on GraphQL pages, so
271
+ // their search tags carry only the group name.
272
+ if (!graphql || isGraphqlOperationKind(operation.method)) {
273
+ sidebar.badge = method;
274
+ }
275
+ const tags = graphql ? [operation.tag] : [operation.tag, method];
222
276
  return {
223
277
  body: withDescription(
224
278
  description,
@@ -226,9 +280,9 @@ export const operationMdx = (
226
280
  ),
227
281
  data: {
228
282
  ...flags,
229
- search: { ...searchFlags, tags: [operation.tag, method] },
283
+ search: { ...searchFlags, tags },
230
284
  seo,
231
- sidebar: { badge: method, label: operation.summary || operation.path },
285
+ sidebar,
232
286
  title,
233
287
  // Signals the two-column API layout (request panel instead of the TOC).
234
288
  type: "openapi-operation",
@@ -32,22 +32,16 @@ const referencePagePath = (route: string): string => {
32
32
  return `${segments === "" ? "index" : segments}.astro`;
33
33
  };
34
34
 
35
- const darkModeConfig = (mode: ResolvedConfig["theme"]["mode"]) => {
36
- if (mode === "dark") {
37
- return { darkMode: true };
38
- }
39
- if (mode === "light") {
40
- return { darkMode: false };
41
- }
42
- // "system": leave Scalar to follow the OS preference.
43
- return {};
44
- };
45
-
46
35
  /**
47
36
  * Map Blume's theme onto Scalar's. An explicit `theme` name wins; otherwise we
48
37
  * keep Scalar's default theme and layer Blume's accent/radius on top via
49
38
  * `customCss`. Scalar re-injects `customCss` after its bundled theme, so these
50
39
  * variables reliably override the defaults. Best-effort, not pixel-exact.
40
+ *
41
+ * Light/dark is not decided here: the page's `data-theme` (stored preference,
42
+ * else the configured mode, else the OS) is only known in the browser, so
43
+ * `ReferenceLayout` pins Scalar's color mode to it at mount and keeps the two
44
+ * in step afterwards (`SCALAR_THEME_INIT_SCRIPT`).
51
45
  */
52
46
  const themeConfiguration = (config: ResolvedConfig, override?: string) => {
53
47
  if (override) {
@@ -57,7 +51,6 @@ const themeConfiguration = (config: ResolvedConfig, override?: string) => {
57
51
  const radius = resolveRadius(config.theme);
58
52
  return {
59
53
  customCss: `:root,.light-mode,.dark-mode{--scalar-color-accent:${accent.light};--scalar-radius:${radius};}.dark-mode{--scalar-color-accent:${accent.dark};}`,
60
- ...darkModeConfig(config.theme.mode),
61
54
  };
62
55
  };
63
56
 
@@ -151,9 +144,6 @@ export const buildReferenceFiles = async (options: {
151
144
  warnings.push(spec.warning);
152
145
  }
153
146
  const pagePath = referencePagePath(ref.route);
154
- // Relative path from the page back to src/generated/data.json: a page one
155
- // directory deep (api/events.astro) needs an extra "../".
156
- const depth = pagePath.split("/").length - 1;
157
147
  files.push({
158
148
  content: scalarReferenceTemplate({
159
149
  configuration: {
@@ -164,7 +154,6 @@ export const buildReferenceFiles = async (options: {
164
154
  // hideTestRequestButton, orderSchemaPropertiesBy, and the rest).
165
155
  ...ref.scalar,
166
156
  },
167
- dataImport: `${"../".repeat(depth + 1)}generated/data.json`,
168
157
  noindex: ref.noindex,
169
158
  route: ref.route,
170
159
  title: ref.label,
@@ -11,6 +11,8 @@ import type {
11
11
  import type { Diagnostic } from "../core/types.ts";
12
12
  import { extractAsyncApiOperations } from "./asyncapi.ts";
13
13
  import type { AsyncApiDocument } from "./asyncapi.ts";
14
+ import { extractGraphqlOperations } from "./graphql.ts";
15
+ import type { GraphqlDocument } from "./graphql.ts";
14
16
  import { extractOperations } from "./model.ts";
15
17
  import type {
16
18
  ApiDocument,
@@ -19,7 +21,12 @@ import type {
19
21
  ApiTagRef,
20
22
  OpenApiData,
21
23
  } from "./model.ts";
22
- import { InvalidSpecError, parseAsyncApiSpec, parseSpec } from "./parse.ts";
24
+ import {
25
+ InvalidSpecError,
26
+ parseAsyncApiSpec,
27
+ parseGraphqlSpec,
28
+ parseSpec,
29
+ } from "./parse.ts";
23
30
  import type { ReferenceSource } from "./references.ts";
24
31
  import { operationMdx, overviewMdx } from "./render-mdx.ts";
25
32
  import type { RenderedPage } from "./render-mdx.ts";
@@ -51,6 +58,62 @@ export const isOpenApiSource = (
51
58
  /** Route (`/reference/pet/add-pet`) to a staged content ref, without extension. */
52
59
  const routeToRef = (route: string): string => route.replace(/^\/+/u, "");
53
60
 
61
+ /** Human label per spec kind, so one kind's failure never reads as another's. */
62
+ const KIND_LABELS = {
63
+ asyncapi: "AsyncAPI",
64
+ graphql: "GraphQL",
65
+ openapi: "OpenAPI",
66
+ } satisfies Record<ReferenceSource["kind"], string>;
67
+
68
+ /** Diagnostic-code prefix per spec kind. */
69
+ const CODE_PREFIXES = {
70
+ asyncapi: "BLUME_ASYNCAPI",
71
+ graphql: "BLUME_GRAPHQL",
72
+ openapi: "BLUME_OPENAPI",
73
+ } satisfies Record<ReferenceSource["kind"], string>;
74
+
75
+ /**
76
+ * Parse-level warning code per kind: an offline cache fallback for any kind,
77
+ * plus lossy 2.x→3.0 conversion notes for AsyncAPI — hence the broader codes
78
+ * there and for GraphQL (OpenAPI keeps its historical one).
79
+ */
80
+ const SPEC_WARNING_CODES = {
81
+ asyncapi: "BLUME_ASYNCAPI_SPEC_WARNING",
82
+ graphql: "BLUME_GRAPHQL_SPEC_WARNING",
83
+ openapi: "BLUME_OPENAPI_STALE",
84
+ } satisfies Record<ReferenceSource["kind"], string>;
85
+
86
+ /**
87
+ * Extract-level skip code per kind. OpenAPI keeps its historical code (the
88
+ * only extract warning it emits is the unresolved $ref path item); the
89
+ * GraphQL extractor currently skips nothing, so its code is reserved.
90
+ */
91
+ const SKIPPED_CODES = {
92
+ asyncapi: "BLUME_ASYNCAPI_SKIPPED_OPERATION",
93
+ graphql: "BLUME_GRAPHQL_SKIPPED",
94
+ openapi: "BLUME_OPENAPI_REF_PATH_ITEM",
95
+ } satisfies Record<ReferenceSource["kind"], string>;
96
+
97
+ /** `_EMPTY` diagnostic suggestion per kind. */
98
+ const EMPTY_SUGGESTIONS = {
99
+ asyncapi:
100
+ "Check the spec points at an AsyncAPI document with `channels` and `operations`.",
101
+ graphql:
102
+ "Check the spec points at a GraphQL schema (SDL or introspection JSON) whose root types declare fields.",
103
+ openapi:
104
+ "Check the spec points at an OpenAPI document with operations under `paths`.",
105
+ } satisfies Record<ReferenceSource["kind"], string>;
106
+
107
+ /** `_UNAVAILABLE` suggestion for a readable-but-invalid spec, per kind. */
108
+ const INVALID_SUGGESTIONS = {
109
+ asyncapi:
110
+ "Point the spec at an AsyncAPI document (a YAML or JSON file with an object at the top level).",
111
+ graphql:
112
+ "Point the spec at a GraphQL schema — SDL text or an introspection JSON result.",
113
+ openapi:
114
+ "Point the spec at an OpenAPI document (a YAML or JSON file with an object at the top level).",
115
+ } satisfies Record<ReferenceSource["kind"], string>;
116
+
54
117
  const toEntry = (rendered: RenderedPage, ref: string): SourceEntry => {
55
118
  const raw = matter.stringify(`${rendered.body}\n`, rendered.data);
56
119
  return {
@@ -122,7 +185,7 @@ interface LoadedSpec {
122
185
 
123
186
  /** One parsed spec, whichever front-end read it — the kind dispatch seam. */
124
187
  interface ParsedReference {
125
- document: ApiDocument | AsyncApiDocument;
188
+ document: ApiDocument | AsyncApiDocument | GraphqlDocument;
126
189
  warnings: string[];
127
190
  operations: ApiOperationRef[];
128
191
  tags: ApiTagRef[];
@@ -149,6 +212,21 @@ const parseReference = async (
149
212
  warnings,
150
213
  };
151
214
  }
215
+ if (reference.kind === "graphql") {
216
+ const { document, warnings } = await parseGraphqlSpec(
217
+ reference.spec,
218
+ ctx.projectRoot,
219
+ options
220
+ );
221
+ const extracted = extractGraphqlOperations(document, reference.route);
222
+ return {
223
+ document,
224
+ extractWarnings: extracted.warnings,
225
+ operations: extracted.operations,
226
+ tags: extracted.tags,
227
+ warnings,
228
+ };
229
+ }
152
230
  const { document, warnings } = await parseSpec(
153
231
  reference.spec,
154
232
  ctx.projectRoot,
@@ -175,9 +253,8 @@ export const openApiSource = (
175
253
  ): Promise<LoadedSpec | Diagnostic> => {
176
254
  // Human label and diagnostic-code prefix for the spec's kind, so an
177
255
  // AsyncAPI failure never reads as an OpenAPI one.
178
- const kindLabel = reference.kind === "asyncapi" ? "AsyncAPI" : "OpenAPI";
179
- const codePrefix =
180
- reference.kind === "asyncapi" ? "BLUME_ASYNCAPI" : "BLUME_OPENAPI";
256
+ const kindLabel = KIND_LABELS[reference.kind];
257
+ const codePrefix = CODE_PREFIXES[reference.kind];
181
258
  try {
182
259
  const { document, warnings, operations, tags, extractWarnings } =
183
260
  await parseReference(reference, ctx);
@@ -221,26 +298,20 @@ export const openApiSource = (
221
298
  title: info.title ?? reference.label,
222
299
  version: info.version ?? "",
223
300
  };
301
+ // Only GraphQL references carry a live endpoint (a schema names no
302
+ // server); assigned separately so the key stays absent otherwise.
303
+ if (reference.endpoint !== undefined) {
304
+ spec.endpoint = reference.endpoint;
305
+ }
224
306
  return {
225
307
  diagnostics: [
226
308
  ...warnings.map((message) => ({
227
- // Parse-level notes: an offline cache fallback for either kind,
228
- // plus lossy 2.x→3.0 conversion notes for AsyncAPI — hence the
229
- // broader code on that side (OpenAPI keeps its historical one).
230
- code:
231
- reference.kind === "asyncapi"
232
- ? "BLUME_ASYNCAPI_SPEC_WARNING"
233
- : "BLUME_OPENAPI_STALE",
309
+ code: SPEC_WARNING_CODES[reference.kind],
234
310
  message,
235
311
  severity: "warning" as const,
236
312
  })),
237
313
  ...extractWarnings.map((message) => ({
238
- // OpenAPI keeps its historical code (the only extract warning it
239
- // emits is the unresolved $ref path item).
240
- code:
241
- reference.kind === "asyncapi"
242
- ? "BLUME_ASYNCAPI_SKIPPED_OPERATION"
243
- : "BLUME_OPENAPI_REF_PATH_ITEM",
314
+ code: SKIPPED_CODES[reference.kind],
244
315
  message: `In ${kindLabel} spec "${reference.spec}": ${message}`,
245
316
  severity: "warning" as const,
246
317
  })),
@@ -253,10 +324,7 @@ export const openApiSource = (
253
324
  code: `${codePrefix}_EMPTY`,
254
325
  message: `${kindLabel} spec "${reference.spec}" for ${reference.route} declares no operations; its API reference is empty.`,
255
326
  severity: "warning" as const,
256
- suggestion:
257
- reference.kind === "asyncapi"
258
- ? "Check the spec points at an AsyncAPI document with `channels` and `operations`."
259
- : "Check the spec points at an OpenAPI document with operations under `paths`.",
327
+ suggestion: EMPTY_SUGGESTIONS[reference.kind],
260
328
  },
261
329
  ]
262
330
  : []),
@@ -280,7 +348,7 @@ export const openApiSource = (
280
348
  // only point at reachability for actual fetch/read failures.
281
349
  suggestion:
282
350
  error instanceof InvalidSpecError
283
- ? `Point the spec at ${reference.kind === "asyncapi" ? "an AsyncAPI" : "an OpenAPI"} document (a YAML or JSON file with an object at the top level).`
351
+ ? INVALID_SUGGESTIONS[reference.kind]
284
352
  : "Check the spec URL/path is reachable from the build environment; behind a proxy, set HTTP(S)_PROXY.",
285
353
  };
286
354
  }
@@ -9,7 +9,12 @@ import { buildRawMarkdown } from "../ai/markdown.ts";
9
9
  import { buildMcpData } from "../ai/mcp/data.ts";
10
10
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
11
11
  import { planComponentSlots } from "../astro/component-slots.ts";
12
- import { discoverExamples, exampleMarkdownLookup } from "../astro/examples.ts";
12
+ import {
13
+ EXAMPLE_SCAN_GLOB,
14
+ discoverExamples,
15
+ exampleMarkdownLookup,
16
+ exampleScanRoots,
17
+ } from "../astro/examples.ts";
13
18
  import {
14
19
  buildRuntimeData,
15
20
  collectStaged,
@@ -44,6 +49,7 @@ import {
44
49
  searchEndpointTemplate,
45
50
  staticJsonEndpointTemplate,
46
51
  } from "../astro/templates.ts";
52
+ import { buildIncludeGraph } from "../core/includes.ts";
47
53
  import { packageRoot } from "../core/package-root.ts";
48
54
  import { scanProject } from "../core/project-graph.ts";
49
55
  import type { BlumeProject } from "../core/project-graph.ts";
@@ -60,6 +66,7 @@ import {
60
66
  tailwindEntryTemplate,
61
67
  } from "../theme/entry.ts";
62
68
  import { buildThemeCss } from "../theme/palette.ts";
69
+ import { rebaseSourceDirectives } from "../theme/sources.ts";
63
70
  import { twoslashCss } from "../theme/twoslash.ts";
64
71
 
65
72
  const toPosix = (path: string): string => path.split("\\").join("/");
@@ -202,7 +209,7 @@ const mcpFiles = async (
202
209
  path: join(genDir, "mcp-data.json"),
203
210
  },
204
211
  {
205
- content: mcpEndpointTemplate(route),
212
+ content: mcpEndpointTemplate(),
206
213
  path: join(srcDir, "pages", mcpPageFile(route)),
207
214
  },
208
215
  {
@@ -250,13 +257,21 @@ const changelogFiles = (
250
257
  };
251
258
 
252
259
  /** Contents of the configured `examples.css`, or `""` when unset/absent. */
253
- const readExamplesCss = (
254
- root: string,
255
- css: string | undefined
256
- ): Promise<string> =>
257
- css && existsSync(join(root, css))
258
- ? readFile(join(root, css), "utf-8")
259
- : Promise.resolve("");
260
+ /**
261
+ * Read a user stylesheet that eject inlines into a generated entry under
262
+ * `genDir`, re-rooting its relative `@source` paths from the user's file.
263
+ * Resolves to an empty string when the file is unset or absent.
264
+ */
265
+ const readUserCss = async (
266
+ file: string | null,
267
+ genDir: string
268
+ ): Promise<string> => {
269
+ if (!(file && existsSync(file))) {
270
+ return "";
271
+ }
272
+ const css = await readFile(file, "utf-8");
273
+ return rebaseSourceDirectives(css, { from: file, to: genDir });
274
+ };
260
275
 
261
276
  /**
262
277
  * The per-example preview route `<Component />` iframes embed, nested under
@@ -330,10 +345,11 @@ export const eject = async (
330
345
  context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
331
346
  detectNeedsReact(root),
332
347
  detectUsesMath(root),
333
- context.themeFile
334
- ? readFile(context.themeFile, "utf-8")
335
- : Promise.resolve(""),
336
- readExamplesCss(root, config.examples.css),
348
+ readUserCss(context.themeFile, genDir),
349
+ readUserCss(
350
+ config.examples.css ? join(root, config.examples.css) : null,
351
+ genDir
352
+ ),
337
353
  buildRawMarkdown(project),
338
354
  discoverIslands(root),
339
355
  ]);
@@ -383,11 +399,14 @@ export const eject = async (
383
399
  content: astroConfigTemplate({
384
400
  askPath: "./src/generated/Ask.astro",
385
401
  config,
402
+ contentRoot: relContext.contentRoot,
386
403
  contentRoutes: project.manifest.routes.map((route) => route.path),
387
404
  context: relContext,
388
- dataPath: "./src/generated/data.json",
389
405
  examplesPath: "./src/generated/examples.ts",
390
406
  examplesThemePath: "./src/generated/examples.css",
407
+ // No CLI publishes the runtime data modules in memory after eject, so
408
+ // the config aliases each to the JSON snapshot written below.
409
+ generatedModulesDir: "./src/generated",
391
410
  integrationBridge: ejectIntegrationBridge(
392
411
  config,
393
412
  root,
@@ -396,7 +415,6 @@ export const eject = async (
396
415
  needsReact,
397
416
  needsSvelte,
398
417
  needsVue,
399
- openapiPath: "./src/generated/openapi.json",
400
418
  pages: relPages,
401
419
  searchClientPath: "./src/generated/search-client.ts",
402
420
  themePath: "./src/generated/app.css",
@@ -450,7 +468,9 @@ export const eject = async (
450
468
  // Relative sources keep the ejected app portable.
451
469
  content: examplesEntryTemplate({
452
470
  configTokens: buildThemeCss(config.theme),
453
- sources: ["../../**/*.{astro,jsx,svelte,ts,tsx,vue}"],
471
+ sources: exampleScanRoots(root, examples.dir).map(
472
+ (dir) => `${relative(genDir, dir)}/${EXAMPLE_SCAN_GLOB}`
473
+ ),
454
474
  userCss: userExamplesCss,
455
475
  }),
456
476
  path: join(genDir, "examples.css"),
@@ -485,6 +505,15 @@ export const eject = async (
485
505
  content: `${JSON.stringify(rawMarkdown)}\n`,
486
506
  path: join(genDir, "raw-markdown.json"),
487
507
  },
508
+ {
509
+ // The partial → including-pages map behind `includeHmrPlugin`, which the
510
+ // ejected astro.config wires at this exact path — without the file every
511
+ // hot update's read would silently no-op and partial edits would serve
512
+ // stale pages. A snapshot like the rest of `src/generated`: the ejected
513
+ // app owns (and may regenerate or prune) it.
514
+ content: `${JSON.stringify(buildIncludeGraph(project.graph.pages))}\n`,
515
+ path: join(genDir, "includes.json"),
516
+ },
488
517
  {
489
518
  content: rawMarkdownEndpointTemplate("md"),
490
519
  path: join(srcDir, "pages", "[...slug].md.ts"),
@@ -502,7 +531,8 @@ export const eject = async (
502
531
  if (config.seo.og.enabled) {
503
532
  files.push({
504
533
  content: ogEndpointTemplate(
505
- customOgRoutes(pages, config.title, config.seo.og.titles)
534
+ customOgRoutes(pages, config.title, config.seo.og.titles),
535
+ { pageDescriptions: config.seo.og.description !== false }
506
536
  ),
507
537
  path: join(srcDir, "pages", "og", "[...slug].png.ts"),
508
538
  });