blume 1.5.2 → 1.6.0

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 (194) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/cli/index.js +3639 -1377
  3. package/dist/cli/index.js.map +103 -91
  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 +23 -1
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +8 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +117 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +23 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +21 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/_snippets/include-demo.mdx +7 -0
  21. package/docs/advanced/api-reference.mdx +3 -3
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/graphql.mdx +84 -0
  24. package/docs/advanced/meta.ts +8 -1
  25. package/docs/configuration/ai.mdx +21 -3
  26. package/docs/configuration/index.mdx +24 -0
  27. package/docs/configuration/search.mdx +13 -1
  28. package/docs/configuration/seo.mdx +27 -0
  29. package/docs/configuration/theming.mdx +17 -0
  30. package/docs/content/components.mdx +7 -0
  31. package/docs/content/includes.mdx +68 -0
  32. package/docs/content/meta.ts +1 -0
  33. package/docs/content/navigation.mdx +25 -0
  34. package/docs/content/sources.mdx +42 -1
  35. package/docs/content/syntax.mdx +69 -1
  36. package/docs/content/versioning.mdx +15 -9
  37. package/docs/reference/cli.mdx +2 -1
  38. package/package.json +23 -14
  39. package/skills/blume-migrate/SKILL.md +16 -7
  40. package/skills/blume-migrate/references/docusaurus.md +5 -3
  41. package/skills/blume-migrate/references/fumadocs.md +10 -2
  42. package/skills/blume-migrate/references/mintlify.md +3 -2
  43. package/skills/blume-migrate/references/nextra.md +2 -2
  44. package/skills/blume-migrate/references/starlight.md +1 -1
  45. package/src/ai/agent-readability.ts +2 -1
  46. package/src/ai/ask-data.ts +2 -1
  47. package/src/ai/component-markdown.ts +199 -36
  48. package/src/ai/llms.ts +93 -6
  49. package/src/ai/markdown.ts +2 -2
  50. package/src/ai/mcp/discovery.ts +10 -2
  51. package/src/ai/mcp/server.ts +74 -2
  52. package/src/astro/generate.ts +183 -116
  53. package/src/astro/include-hmr.ts +81 -0
  54. package/src/astro/include-refresh.ts +0 -0
  55. package/src/astro/index.ts +3 -5
  56. package/src/astro/templates.ts +125 -76
  57. package/src/cli/commands/build.ts +84 -15
  58. package/src/cli/init/questions.ts +1 -0
  59. package/src/cli/init/scaffold.ts +27 -4
  60. package/src/components/colors.ts +142 -0
  61. package/src/components/content/Badge.astro +5 -12
  62. package/src/components/content/Callout.astro +19 -36
  63. package/src/components/content/Card.astro +15 -21
  64. package/src/components/content/Component.astro +10 -1
  65. package/src/components/content/GithubInfo.astro +28 -9
  66. package/src/components/content/Tabs.astro +27 -5
  67. package/src/components/content/github-info.ts +20 -5
  68. package/src/components/dropdown-dismiss.ts +122 -0
  69. package/src/components/layout/Fonts.astro +15 -8
  70. package/src/components/layout/Header.astro +44 -0
  71. package/src/components/layout/LanguageSwitcher.astro +9 -1
  72. package/src/components/layout/NavSelector.astro +12 -3
  73. package/src/components/layout/NavTree.astro +6 -18
  74. package/src/components/layout/PageActions.astro +29 -8
  75. package/src/components/layout/PageLayout.astro +10 -1
  76. package/src/components/layout/ReferenceLayout.astro +6 -1
  77. package/src/components/layout/RootLayout.astro +46 -15
  78. package/src/components/layout/Search.astro +36 -4
  79. package/src/components/layout/TableOfContents.astro +8 -2
  80. package/src/components/layout/head-scripts.ts +53 -1
  81. package/src/components/openapi/ApiOverview.astro +13 -3
  82. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  83. package/src/components/openapi/GraphqlChip.astro +33 -0
  84. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  85. package/src/components/openapi/GraphqlOperation.astro +186 -0
  86. package/src/components/openapi/GraphqlType.astro +154 -0
  87. package/src/components/openapi/MethodBadge.astro +3 -14
  88. package/src/components/openapi/Operation.astro +12 -5
  89. package/src/components/openapi/OperationPanel.astro +43 -0
  90. package/src/components/openapi/RequestPanel.astro +5 -10
  91. package/src/components/openapi/Responses.astro +1 -16
  92. package/src/components/openapi/graphql-helpers.ts +466 -0
  93. package/src/components/openapi/playground-client.ts +15 -0
  94. package/src/components/openapi/sample-panels.ts +45 -0
  95. package/src/components/openapi/snippets.ts +13 -35
  96. package/src/core/base-path.ts +11 -0
  97. package/src/core/config-input.ts +209 -2
  98. package/src/core/config.ts +6 -4
  99. package/src/core/content-assets.ts +15 -4
  100. package/src/core/data.ts +18 -2
  101. package/src/core/diagnostics.ts +8 -0
  102. package/src/core/frontmatter.ts +20 -8
  103. package/src/core/github.ts +71 -0
  104. package/src/core/graph.ts +22 -8
  105. package/src/core/heading-markers.ts +96 -0
  106. package/src/core/i18n-ui.ts +11 -0
  107. package/src/core/includes.ts +632 -0
  108. package/src/core/last-modified.ts +36 -11
  109. package/src/core/links.ts +79 -13
  110. package/src/core/meta.ts +2 -1
  111. package/src/core/nav-diagnostics.ts +11 -2
  112. package/src/core/navigation.ts +27 -6
  113. package/src/core/project-graph.ts +61 -9
  114. package/src/core/schema.ts +226 -35
  115. package/src/core/server-features.ts +5 -9
  116. package/src/core/sources/github-releases.ts +2 -2
  117. package/src/core/sources/normalize.ts +502 -115
  118. package/src/core/sources/notion.ts +43 -8
  119. package/src/core/sources/obsidian.ts +1038 -0
  120. package/src/core/sources/read.ts +36 -1
  121. package/src/core/sources/resolve.ts +34 -1
  122. package/src/core/sources/types.ts +28 -6
  123. package/src/core/sources/watch.ts +12 -8
  124. package/src/core/tsconfig-aliases.ts +48 -35
  125. package/src/core/types.ts +25 -2
  126. package/src/core/ui-packs/ar.ts +1 -0
  127. package/src/core/ui-packs/bg.ts +2 -0
  128. package/src/core/ui-packs/bn.ts +1 -0
  129. package/src/core/ui-packs/ca.ts +2 -0
  130. package/src/core/ui-packs/cs.ts +1 -0
  131. package/src/core/ui-packs/da.ts +1 -0
  132. package/src/core/ui-packs/de.ts +2 -0
  133. package/src/core/ui-packs/el.ts +2 -0
  134. package/src/core/ui-packs/es.ts +2 -0
  135. package/src/core/ui-packs/fa.ts +1 -0
  136. package/src/core/ui-packs/fi.ts +1 -0
  137. package/src/core/ui-packs/fr.ts +2 -0
  138. package/src/core/ui-packs/he.ts +1 -0
  139. package/src/core/ui-packs/hi.ts +1 -0
  140. package/src/core/ui-packs/hr.ts +2 -0
  141. package/src/core/ui-packs/hu.ts +2 -0
  142. package/src/core/ui-packs/id.ts +2 -0
  143. package/src/core/ui-packs/it.ts +1 -0
  144. package/src/core/ui-packs/ja.ts +2 -0
  145. package/src/core/ui-packs/ko.ts +2 -0
  146. package/src/core/ui-packs/nl.ts +2 -0
  147. package/src/core/ui-packs/no.ts +2 -0
  148. package/src/core/ui-packs/pl.ts +2 -0
  149. package/src/core/ui-packs/pt-br.ts +2 -0
  150. package/src/core/ui-packs/pt.ts +2 -0
  151. package/src/core/ui-packs/ro.ts +2 -0
  152. package/src/core/ui-packs/ru.ts +2 -0
  153. package/src/core/ui-packs/sk.ts +1 -0
  154. package/src/core/ui-packs/sr.ts +1 -0
  155. package/src/core/ui-packs/sv.ts +2 -0
  156. package/src/core/ui-packs/th.ts +1 -0
  157. package/src/core/ui-packs/tr.ts +2 -0
  158. package/src/core/ui-packs/uk.ts +2 -0
  159. package/src/core/ui-packs/vi.ts +1 -0
  160. package/src/core/ui-packs/zh-tw.ts +1 -0
  161. package/src/core/ui-packs/zh.ts +1 -0
  162. package/src/core/version-cut.ts +21 -3
  163. package/src/core/yaml.ts +26 -0
  164. package/src/deploy/function-bundle.ts +251 -0
  165. package/src/eval/schema.ts +3 -1
  166. package/src/markdown/code-title.ts +22 -16
  167. package/src/markdown/features.ts +21 -0
  168. package/src/markdown/fence-meta.ts +50 -0
  169. package/src/markdown/heading-anchors.ts +198 -37
  170. package/src/markdown/include.ts +247 -0
  171. package/src/markdown/index.ts +43 -34
  172. package/src/markdown/language-icon.ts +2 -2
  173. package/src/markdown/mdast.ts +7 -3
  174. package/src/markdown/ts2js.ts +264 -0
  175. package/src/openapi/asyncapi.ts +4 -1
  176. package/src/openapi/graphql-build.ts +293 -0
  177. package/src/openapi/graphql.ts +212 -0
  178. package/src/openapi/model.ts +38 -5
  179. package/src/openapi/parse.ts +34 -0
  180. package/src/openapi/proxy.ts +30 -5
  181. package/src/openapi/references.ts +89 -13
  182. package/src/openapi/render-mdx.ts +48 -8
  183. package/src/openapi/scalar.ts +5 -12
  184. package/src/openapi/source.ts +91 -23
  185. package/src/registry/eject.ts +11 -0
  186. package/src/search/documents.ts +229 -37
  187. package/src/search/orama-index.ts +9 -5
  188. package/src/seo/jsonld.ts +293 -51
  189. package/src/theme/code-block-padding.ts +16 -0
  190. package/src/theme/entry.ts +65 -11
  191. package/src/theme/fonts.ts +189 -16
  192. package/src/translate/prompts.ts +2 -0
  193. package/src/translate/run.ts +7 -0
  194. 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";
@@ -56,6 +56,11 @@ export interface ReferenceSource {
56
56
  noindex: boolean;
57
57
  /** Local path or `http(s)` URL, verbatim from config. */
58
58
  spec: string;
59
+ /**
60
+ * URL of the live GraphQL endpoint the playground and code samples target
61
+ * (GraphQL only — a schema, unlike an OpenAPI document, names no server).
62
+ */
63
+ endpoint?: string;
59
64
  /** Per-block Scalar theme name override, if any (Scalar renderer only). */
60
65
  theme?: string;
61
66
  /**
@@ -109,19 +114,32 @@ export const slugify = (text: string): string =>
109
114
  const routeSlug = (route: string): string =>
110
115
  slugify(trimChar(route, "/")) || "reference";
111
116
 
112
- type Block = ResolvedConfig["openapi"] | ResolvedConfig["asyncapi"];
117
+ /**
118
+ * The structural shape all three reference blocks (`openapi`, `asyncapi`,
119
+ * `graphql`) share. `endpoint` exists only on the GraphQL block and its
120
+ * sources; `scalar`/`theme` only on the Scalar-capable kinds — optional here
121
+ * so one resolver serves every block.
122
+ */
123
+ interface Block {
124
+ enabled: boolean;
125
+ endpoint?: string;
126
+ route: string;
127
+ scalar?: ResolvedConfig["openapi"]["scalar"];
128
+ sources: {
129
+ endpoint?: string;
130
+ includeInLlms: boolean;
131
+ includeInSearch: boolean;
132
+ label?: string;
133
+ noindex: boolean;
134
+ route?: string;
135
+ spec: string;
136
+ }[];
137
+ spec?: string;
138
+ theme?: string;
139
+ }
113
140
 
114
141
  /** 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
- }[] => {
142
+ const sourcesOf = (block: Block): Block["sources"] => {
125
143
  const sources = [...block.sources];
126
144
  if (block.spec) {
127
145
  sources.unshift({
@@ -163,7 +181,7 @@ const referencesFor = (
163
181
  route = normalizeRoute(`${base}/${suffix || index + 1}`);
164
182
  }
165
183
 
166
- return {
184
+ const reference: ReferenceSource = {
167
185
  basePath,
168
186
  display,
169
187
  includeInLlms: source.includeInLlms,
@@ -178,6 +196,13 @@ const referencesFor = (
178
196
  spec: source.spec,
179
197
  theme: block.theme,
180
198
  };
199
+ // Per-source endpoint wins; the block-level one is the shared default
200
+ // (the common single-schema case pairs it with the `spec` shorthand).
201
+ const endpoint = source.endpoint ?? block.endpoint;
202
+ if (endpoint !== undefined) {
203
+ reference.endpoint = endpoint;
204
+ }
205
+ return reference;
181
206
  });
182
207
  };
183
208
 
@@ -212,6 +237,21 @@ export const resolveReferences = (
212
237
  },
213
238
  config.basePath
214
239
  ),
240
+ // GraphQL is always Blume-rendered — Scalar's embedded SPA reads OpenAPI
241
+ // documents only, so the block declares no `renderer` opt-out (nor the
242
+ // schema-row `expandSchemas` toggle; GraphQL field tables have no nesting).
243
+ ...referencesFor(
244
+ "graphql",
245
+ config.graphql,
246
+ "GraphQL",
247
+ "blume",
248
+ {
249
+ codeSamples: config.graphql.codeSamples,
250
+ expandSchemas: false,
251
+ playground: config.graphql.playground,
252
+ },
253
+ config.basePath
254
+ ),
215
255
  ];
216
256
 
217
257
  /**
@@ -287,3 +327,39 @@ export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
287
327
  /** Whether any reference is Scalar-rendered (gates the `@scalar/astro` dep + pages). */
288
328
  export const hasScalarReferences = (config: ResolvedConfig): boolean =>
289
329
  resolveReferences(config).some((ref) => ref.renderer === "scalar");
330
+
331
+ /**
332
+ * The reference kinds whose enabled, Blume-rendered playground opted into the
333
+ * built-in CORS proxy with `proxy: true`. A proxy URL string points at an
334
+ * external service, and `false` sends requests directly — neither routes
335
+ * through the endpoint. The generator's per-spec allowlist diagnostics key on
336
+ * this, so it shares one definition with {@link needsPlaygroundProxy}.
337
+ */
338
+ export const builtinProxyKinds = (config: ResolvedConfig): ReferenceKind[] => {
339
+ const kinds: ReferenceKind[] = [];
340
+ if (
341
+ config.openapi.enabled &&
342
+ config.openapi.renderer === "blume" &&
343
+ config.openapi.playground.enabled &&
344
+ config.openapi.playground.proxy === true
345
+ ) {
346
+ kinds.push("openapi");
347
+ }
348
+ if (
349
+ config.graphql.enabled &&
350
+ config.graphql.playground.enabled &&
351
+ config.graphql.playground.proxy === true
352
+ ) {
353
+ kinds.push("graphql");
354
+ }
355
+ return kinds;
356
+ };
357
+
358
+ /**
359
+ * Whether the built-in playground CORS proxy endpoint (`/_api-proxy`) must be
360
+ * generated: some enabled Blume-rendered block's playground opted into it with
361
+ * `proxy: true`. Shared by the server feature gate and the generator so the
362
+ * two can never disagree.
363
+ */
364
+ export const needsPlaygroundProxy = (config: ResolvedConfig): boolean =>
365
+ 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,6 +158,19 @@ 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
@@ -165,11 +180,18 @@ const operationDescription = (
165
180
  spec: ApiSpecData,
166
181
  operation: ApiOperationRef
167
182
  ): 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.`;
183
+ // AsyncAPI operations act on a channel, not an HTTP endpoint; GraphQL pages
184
+ // document a root field or a named type.
185
+ let suffix: string;
186
+ if (spec.kind === "asyncapi") {
187
+ suffix = `Reference for the ${operation.method} operation on ${operation.path} in the ${apiName(spec)} API.`;
188
+ } else if (spec.kind === "graphql") {
189
+ // SAFETY: the GraphQL extractor only ever assigns member kinds as the
190
+ // method (see `extractGraphqlOperations`).
191
+ suffix = `Reference for the ${operation.path} ${GRAPHQL_MEMBER_PHRASES[operation.method as GraphqlMember]} in the ${apiName(spec)} API.`;
192
+ } else {
193
+ suffix = `Reference for the ${operation.method.toUpperCase()} ${operation.path} endpoint in the ${apiName(spec)} API.`;
194
+ }
173
195
  const prose = clip(
174
196
  plainProse(operation.description || operation.summary),
175
197
  META_DESCRIPTION_MAX - stringWidth(suffix) - 1
@@ -192,7 +214,13 @@ export const operationMdx = (
192
214
  >
193
215
  ): RenderedPage => {
194
216
  const method = operation.method.toUpperCase();
195
- const title = operation.summary || `${method} ${operation.path}`;
217
+ const graphql = spec.kind === "graphql";
218
+ // A GraphQL page IS its field/type — `QUERY pets` would double the badge the
219
+ // page already renders; the other kinds title an endpoint or channel action.
220
+ const fallbackTitle = graphql
221
+ ? operation.path
222
+ : `${method} ${operation.path}`;
223
+ const title = operation.summary || fallbackTitle;
196
224
  // Skip the body description when it only repeats the summary (the `<h1>`) —
197
225
  // common in specs that set summary and description to the same string.
198
226
  const description =
@@ -219,6 +247,18 @@ export const operationMdx = (
219
247
  if (reference?.noindex) {
220
248
  seo.noindex = true;
221
249
  }
250
+ const sidebar: RenderedPageData["sidebar"] = {
251
+ label: operation.summary || operation.path,
252
+ };
253
+ // GraphQL operation kinds badge like HTTP methods, but a type page's kind
254
+ // already heads its sidebar group ("Objects", "Enums", …) — an `OBJECT`
255
+ // badge on every row would only repeat it, so type pages get none. The
256
+ // uppercased method is likewise an internal token on GraphQL pages, so
257
+ // their search tags carry only the group name.
258
+ if (!graphql || isGraphqlOperationKind(operation.method)) {
259
+ sidebar.badge = method;
260
+ }
261
+ const tags = graphql ? [operation.tag] : [operation.tag, method];
222
262
  return {
223
263
  body: withDescription(
224
264
  description,
@@ -226,9 +266,9 @@ export const operationMdx = (
226
266
  ),
227
267
  data: {
228
268
  ...flags,
229
- search: { ...searchFlags, tags: [operation.tag, method] },
269
+ search: { ...searchFlags, tags },
230
270
  seo,
231
- sidebar: { badge: method, label: operation.summary || operation.path },
271
+ sidebar,
232
272
  title,
233
273
  // Signals the two-column API layout (request panel instead of the TOC).
234
274
  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
 
@@ -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
  }
@@ -44,6 +44,7 @@ import {
44
44
  searchEndpointTemplate,
45
45
  staticJsonEndpointTemplate,
46
46
  } from "../astro/templates.ts";
47
+ import { buildIncludeGraph } from "../core/includes.ts";
47
48
  import { packageRoot } from "../core/package-root.ts";
48
49
  import { scanProject } from "../core/project-graph.ts";
49
50
  import type { BlumeProject } from "../core/project-graph.ts";
@@ -383,6 +384,7 @@ export const eject = async (
383
384
  content: astroConfigTemplate({
384
385
  askPath: "./src/generated/Ask.astro",
385
386
  config,
387
+ contentRoot: relContext.contentRoot,
386
388
  contentRoutes: project.manifest.routes.map((route) => route.path),
387
389
  context: relContext,
388
390
  dataPath: "./src/generated/data.json",
@@ -485,6 +487,15 @@ export const eject = async (
485
487
  content: `${JSON.stringify(rawMarkdown)}\n`,
486
488
  path: join(genDir, "raw-markdown.json"),
487
489
  },
490
+ {
491
+ // The partial → including-pages map behind `includeHmrPlugin`, which the
492
+ // ejected astro.config wires at this exact path — without the file every
493
+ // hot update's read would silently no-op and partial edits would serve
494
+ // stale pages. A snapshot like the rest of `src/generated`: the ejected
495
+ // app owns (and may regenerate or prune) it.
496
+ content: `${JSON.stringify(buildIncludeGraph(project.graph.pages))}\n`,
497
+ path: join(genDir, "includes.json"),
498
+ },
488
499
  {
489
500
  content: rawMarkdownEndpointTemplate("md"),
490
501
  path: join(srcDir, "pages", "[...slug].md.ts"),