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
@@ -3,6 +3,7 @@ import data from "blume:data";
3
3
 
4
4
  import { highlightCode } from "../../markdown/index.ts";
5
5
  import { exampleValue, type SchemaLike, toJson } from "./helpers.ts";
6
+ import { languageSamplePanels } from "./sample-panels.ts";
6
7
  import PanelTabs from "./PanelTabs.astro";
7
8
  import type { RequestSample, SampleLanguage } from "./snippets.ts";
8
9
 
@@ -25,16 +26,10 @@ interface Props {
25
26
 
26
27
  const { sample, languages, responses, schemas } = Astro.props;
27
28
 
28
- const requestPanels = await Promise.all(
29
- languages.map(async (language) => ({
30
- html: await highlightCode(language.build(sample), language.lang, {
31
- icons: false,
32
- themes: data.config.codeThemes,
33
- }),
34
- key: language.id,
35
- label: language.label,
36
- lang: language.id,
37
- }))
29
+ const requestPanels = await languageSamplePanels(
30
+ languages,
31
+ sample,
32
+ data.config.codeThemes
38
33
  );
39
34
 
40
35
  const responsePanels = await Promise.all(
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { statusColor } from "../colors.ts";
2
3
  import type { SchemaLike } from "./helpers.ts";
3
4
  import SchemaTable from "./SchemaTable.astro";
4
5
 
@@ -19,22 +20,6 @@ interface Props {
19
20
 
20
21
  const { responses, schemas, expandAll = false } = Astro.props;
21
22
 
22
- const statusColor = (status: string): string => {
23
- if (status.startsWith("2")) {
24
- return "bg-green-500/15 text-green-700 dark:text-green-300";
25
- }
26
- if (status.startsWith("3")) {
27
- return "bg-blue-500/15 text-blue-700 dark:text-blue-300";
28
- }
29
- if (status.startsWith("4")) {
30
- return "bg-orange-500/15 text-orange-700 dark:text-orange-300";
31
- }
32
- if (status.startsWith("5")) {
33
- return "bg-red-500/15 text-red-700 dark:text-red-300";
34
- }
35
- return "bg-muted text-muted-foreground";
36
- };
37
-
38
23
  const items = Object.entries(responses);
39
24
  ---
40
25
 
@@ -0,0 +1,466 @@
1
+ import type {
2
+ GraphqlDocument,
3
+ GraphqlField,
4
+ GraphqlInputValue,
5
+ GraphqlOperationKind,
6
+ GraphqlTypeRef,
7
+ } from "../../openapi/graphql.ts";
8
+ import {
9
+ GRAPHQL_OPERATION_KINDS,
10
+ isGraphqlOperationKind,
11
+ } from "../../openapi/graphql.ts";
12
+ import type { ApiSpecData } from "../../openapi/model.ts";
13
+ import type { SpecValue } from "./helpers.ts";
14
+ import type { PlaygroundModel } from "./request.ts";
15
+
16
+ /**
17
+ * Build-time logic behind the GraphQL reference pages: the generated example
18
+ * operation (a valid, bounded-depth query with a variable per argument), its
19
+ * example variables and response, the shared playground/request model, and the
20
+ * usage backlinks. Everything here works off the serializable
21
+ * `GraphqlDocument` — no `graphql`-js — so none of it can drag the parser
22
+ * into the runtime.
23
+ */
24
+
25
+ /**
26
+ * Stands in for the live endpoint when the config sets none, so the samples
27
+ * and playground render a complete request the reader edits instead of a
28
+ * URL-less fragment.
29
+ */
30
+ export const GRAPHQL_ENDPOINT_PLACEHOLDER =
31
+ "https://your-api.example.com/graphql";
32
+
33
+ /** How deep a generated selection set descends into nested object fields. */
34
+ const SELECTION_DEPTH = 2;
35
+
36
+ /** How deep example variables descend into nested input objects. */
37
+ const VARIABLE_DEPTH = 3;
38
+
39
+ /** Example values for the spec-defined scalars. */
40
+ const SCALAR_SAMPLES = new Map<string, SpecValue>([
41
+ ["Boolean", true],
42
+ ["Float", 0],
43
+ ["ID", "id"],
44
+ ["Int", 0],
45
+ ["String", "string"],
46
+ ]);
47
+
48
+ /** A generated example object (variables, response data). */
49
+ export interface GraphqlSampleObject {
50
+ [key: string]: SpecValue;
51
+ }
52
+
53
+ /** One field of a generated selection set. */
54
+ export interface GraphqlSelection {
55
+ name: string;
56
+ /** The field's declared type (absent on the `__typename` meta field). */
57
+ type?: GraphqlTypeRef;
58
+ /** Sub-selection for composite fields; absent on leaves. */
59
+ children?: GraphqlSelection[];
60
+ }
61
+
62
+ /** Whether selecting this named type requires a sub-selection. */
63
+ const isComposite = (document: GraphqlDocument, name: string): boolean => {
64
+ const kind = document.types[name]?.kind;
65
+ return kind === "object" || kind === "interface" || kind === "union";
66
+ };
67
+
68
+ /**
69
+ * A valid selection set for a composite type, or undefined for leaves: every
70
+ * scalar/enum field, plus nested composites while `depth` allows. A composite
71
+ * with nothing selectable at this depth falls back to `__typename`, which is
72
+ * legal on any composite — the set must never come out empty.
73
+ */
74
+ export const selectionSet = (
75
+ document: GraphqlDocument,
76
+ typeName: string,
77
+ depth: number = SELECTION_DEPTH
78
+ ): GraphqlSelection[] | undefined => {
79
+ const type = document.types[typeName];
80
+ if (!(type && isComposite(document, typeName))) {
81
+ return undefined;
82
+ }
83
+ if (type.kind === "union") {
84
+ return [{ name: "__typename" }];
85
+ }
86
+ const selections: GraphqlSelection[] = [];
87
+ for (const field of type.fields ?? []) {
88
+ // A field with an argument that must be supplied — non-null and no
89
+ // default — can't ride an example selection; there is nothing valid to
90
+ // fill it with inline. A defaulted non-null argument is omittable.
91
+ if (
92
+ field.args.some(
93
+ (arg) => arg.type.display.endsWith("!") && arg.default === undefined
94
+ )
95
+ ) {
96
+ continue;
97
+ }
98
+ if (isComposite(document, field.type.name)) {
99
+ if (depth > 1) {
100
+ selections.push({
101
+ children: selectionSet(document, field.type.name, depth - 1),
102
+ name: field.name,
103
+ type: field.type,
104
+ });
105
+ }
106
+ continue;
107
+ }
108
+ selections.push({ name: field.name, type: field.type });
109
+ }
110
+ return selections.length > 0 ? selections : [{ name: "__typename" }];
111
+ };
112
+
113
+ const INDENT = " ";
114
+
115
+ const printSelections = (
116
+ selections: GraphqlSelection[],
117
+ level: number
118
+ ): string =>
119
+ selections
120
+ .map((selection) => {
121
+ const pad = INDENT.repeat(level);
122
+ return selection.children
123
+ ? `${pad}${selection.name} {\n${printSelections(selection.children, level + 1)}\n${pad}}`
124
+ : `${pad}${selection.name}`;
125
+ })
126
+ .join("\n");
127
+
128
+ /**
129
+ * A complete, valid example operation for one root field: one variable per
130
+ * argument (typed off the schema), and a bounded-depth selection set over the
131
+ * return type.
132
+ */
133
+ export const exampleQuery = (
134
+ document: GraphqlDocument,
135
+ field: GraphqlField,
136
+ kind: GraphqlOperationKind
137
+ ): string => {
138
+ const name = field.name.charAt(0).toUpperCase() + field.name.slice(1);
139
+ const variables = field.args
140
+ .map((arg) => `$${arg.name}: ${arg.type.display}`)
141
+ .join(", ");
142
+ const args = field.args.map((arg) => `${arg.name}: $${arg.name}`).join(", ");
143
+ const head = `${kind} ${name}${variables ? `(${variables})` : ""}`;
144
+ const call = `${field.name}${args ? `(${args})` : ""}`;
145
+ const selections = selectionSet(document, field.type.name);
146
+ const body = selections
147
+ ? `${INDENT}${call} {\n${printSelections(selections, 2)}\n${INDENT}}`
148
+ : `${INDENT}${call}`;
149
+ return `${head} {\n${body}\n}`;
150
+ };
151
+
152
+ /**
153
+ * Wrap a sample in one array per list layer of the type expression, so a
154
+ * nested-list field (`[[Cell]]`) samples with the same nesting a real server
155
+ * returns. `[` can only come from list wrappers — it never appears in a type
156
+ * name — so counting occurrences is exact.
157
+ */
158
+ const wrapLists = (display: string, value: SpecValue): SpecValue => {
159
+ let wrapped = value;
160
+ for (let lists = display.split("[").length - 1; lists > 0; lists -= 1) {
161
+ wrapped = [wrapped];
162
+ }
163
+ return wrapped;
164
+ };
165
+
166
+ /**
167
+ * Whether the named type sits in a non-null position of the expression —
168
+ * `Date!` or the element of `[Date!]`, but not `[Date]!`, whose outer
169
+ * non-null wraps the list, not the name.
170
+ */
171
+ const namedNonNull = (ref: GraphqlTypeRef): boolean =>
172
+ ref.display.includes(`${ref.name}!`);
173
+
174
+ /**
175
+ * An example value for a type expression: scalar/enum samples at the leaves,
176
+ * nested objects for input types while `depth` allows, and one array wrapper
177
+ * per list layer. A custom scalar or a depth-exhausted input samples as null
178
+ * only where null is legal — in a non-null position they fall back to a
179
+ * placeholder (a string / an empty object) so the example variables stay
180
+ * valid against the schema.
181
+ */
182
+ const sampleForRef = (
183
+ document: GraphqlDocument,
184
+ ref: GraphqlTypeRef,
185
+ depth: number
186
+ ): SpecValue => {
187
+ let named: SpecValue = SCALAR_SAMPLES.get(ref.name) ?? null;
188
+ const type = document.types[ref.name];
189
+ if (type?.kind === "enum") {
190
+ named = type.enumValues?.[0]?.name ?? null;
191
+ } else if (type?.kind === "input" && depth > 0) {
192
+ named = Object.fromEntries(
193
+ (type.inputFields ?? []).map((field) => [
194
+ field.name,
195
+ sampleForRef(document, field.type, depth - 1),
196
+ ])
197
+ );
198
+ } else if (type?.kind === "input") {
199
+ named = namedNonNull(ref) ? {} : null;
200
+ } else if (named === null && namedNonNull(ref)) {
201
+ named = "string";
202
+ }
203
+ return wrapLists(ref.display, named);
204
+ };
205
+
206
+ /** Example `variables` for a root field's arguments; undefined when it has none. */
207
+ export const exampleVariables = (
208
+ document: GraphqlDocument,
209
+ field: GraphqlField
210
+ ): GraphqlSampleObject | undefined =>
211
+ field.args.length > 0
212
+ ? Object.fromEntries(
213
+ field.args.map((arg) => [
214
+ arg.name,
215
+ sampleForRef(document, arg.type, VARIABLE_DEPTH),
216
+ ])
217
+ )
218
+ : undefined;
219
+
220
+ /** The example value one selection produces, mirroring the generated query. */
221
+ const responseForSelections = (
222
+ document: GraphqlDocument,
223
+ typeName: string,
224
+ selections: GraphqlSelection[]
225
+ ): GraphqlSampleObject => {
226
+ const out: GraphqlSampleObject = {};
227
+ for (const selection of selections) {
228
+ if (selection.name === "__typename") {
229
+ // A union's example names its first member; other composites name
230
+ // themselves.
231
+ out.__typename = document.types[typeName]?.possibleTypes?.[0] ?? typeName;
232
+ continue;
233
+ }
234
+ // SAFETY: every non-`__typename` selection is built with its field type.
235
+ const ref = selection.type as GraphqlTypeRef;
236
+ if (selection.children) {
237
+ const nested = responseForSelections(
238
+ document,
239
+ ref.name,
240
+ selection.children
241
+ );
242
+ out[selection.name] = wrapLists(ref.display, nested);
243
+ } else {
244
+ out[selection.name] = sampleForRef(document, ref, 0);
245
+ }
246
+ }
247
+ return out;
248
+ };
249
+
250
+ /** The example response envelope: `data` keyed by the root field. */
251
+ export interface GraphqlExampleResponse {
252
+ data: GraphqlSampleObject;
253
+ }
254
+
255
+ /**
256
+ * An example response envelope for the generated query — the same selection
257
+ * set, so the response shows exactly the fields the query asks for.
258
+ */
259
+ export const exampleResponse = (
260
+ document: GraphqlDocument,
261
+ field: GraphqlField
262
+ ): GraphqlExampleResponse => {
263
+ const selections = selectionSet(document, field.type.name);
264
+ const data: GraphqlSampleObject = {};
265
+ if (selections) {
266
+ const value = responseForSelections(document, field.type.name, selections);
267
+ data[field.name] = wrapLists(field.type.display, value);
268
+ } else {
269
+ data[field.name] = sampleForRef(document, field.type, 0);
270
+ }
271
+ return { data };
272
+ };
273
+
274
+ /**
275
+ * The playground/request model for one GraphQL operation: a plain POST whose
276
+ * JSON body carries the query and variables. Reusing `PlaygroundModel` means
277
+ * the OpenAPI playground UI, its client module, and `buildRequest` all work
278
+ * unchanged — the samples show byte-for-byte what the Send button transmits.
279
+ */
280
+ export const graphqlPlaygroundModel = (
281
+ spec: ApiSpecData,
282
+ query: string,
283
+ variables?: GraphqlSampleObject
284
+ ): PlaygroundModel => ({
285
+ auth: [],
286
+ authOptional: true,
287
+ body: {
288
+ contentType: "application/json",
289
+ example: JSON.stringify(
290
+ variables === undefined ? { query } : { query, variables },
291
+ null,
292
+ 2
293
+ ),
294
+ schema: {
295
+ properties: { query: { type: "string" }, variables: { type: "object" } },
296
+ required: ["query"],
297
+ type: "object",
298
+ },
299
+ },
300
+ method: "POST",
301
+ params: [],
302
+ path: "",
303
+ // `||`, not `??`: an empty `endpoint: ""` would otherwise survive into the
304
+ // playground and every code sample as a request to an empty URL.
305
+ servers: [spec.endpoint || GRAPHQL_ENDPOINT_PLACEHOLDER],
306
+ });
307
+
308
+ /**
309
+ * Route lookups derived from a spec's full operation list. Each page render
310
+ * would otherwise rebuild them from scratch — O(pages × operations) across a
311
+ * build — so they memoize on the spec object, which the `blume:openapi` data
312
+ * module instantiates once per process.
313
+ */
314
+ interface GraphqlRouteMaps {
315
+ /** Operation-page routes keyed `kind:fieldName`. */
316
+ operations: Map<string, string>;
317
+ /** Type-page routes keyed by schema name. */
318
+ types: Map<string, string>;
319
+ }
320
+
321
+ const routeMapsCache = new WeakMap<ApiSpecData, GraphqlRouteMaps>();
322
+
323
+ const routeMaps = (spec: ApiSpecData): GraphqlRouteMaps => {
324
+ let maps = routeMapsCache.get(spec);
325
+ if (!maps) {
326
+ maps = { operations: new Map(), types: new Map() };
327
+ for (const ref of Object.values(spec.operations)) {
328
+ if (ref.operationId === undefined) {
329
+ continue;
330
+ }
331
+ if (isGraphqlOperationKind(ref.method)) {
332
+ maps.operations.set(`${ref.method}:${ref.operationId}`, ref.route);
333
+ } else {
334
+ // Type pages keyed by bare name: a root field named like a type must
335
+ // not shadow it.
336
+ maps.types.set(ref.operationId, ref.route);
337
+ }
338
+ }
339
+ routeMapsCache.set(spec, maps);
340
+ }
341
+ return maps;
342
+ };
343
+
344
+ /**
345
+ * Routes for every type page of a spec, keyed by its schema name — the link
346
+ * table type references resolve against (a name with no page, like a built-in
347
+ * scalar, renders as plain text).
348
+ */
349
+ export const graphqlRoutes = (spec: ApiSpecData): Map<string, string> =>
350
+ routeMaps(spec).types;
351
+
352
+ /**
353
+ * Routes for the operation pages, keyed `kind:fieldName` — the link table the
354
+ * "Used by" backlinks resolve against.
355
+ */
356
+ export const graphqlOperationRoutes = (
357
+ spec: ApiSpecData
358
+ ): Map<string, string> => routeMaps(spec).operations;
359
+
360
+ /** Where one named type is used across the schema. */
361
+ export interface GraphqlUsage {
362
+ /** Root fields whose return type or arguments mention the type. */
363
+ operations: { kind: GraphqlOperationKind; name: string }[];
364
+ /** Named types whose fields, arguments, or members mention the type. */
365
+ types: string[];
366
+ }
367
+
368
+ /** The names a field mentions: its own type plus each argument's type. */
369
+ const fieldMentionNames = (field: GraphqlField): Set<string> => {
370
+ const names = new Set([field.type.name]);
371
+ for (const arg of field.args) {
372
+ names.add(arg.type.name);
373
+ }
374
+ return names;
375
+ };
376
+
377
+ const usageFor = (
378
+ index: Map<string, GraphqlUsage>,
379
+ name: string
380
+ ): GraphqlUsage => {
381
+ let usage = index.get(name);
382
+ if (!usage) {
383
+ usage = { operations: [], types: [] };
384
+ index.set(name, usage);
385
+ }
386
+ return usage;
387
+ };
388
+
389
+ // Computing one type's backlinks means walking every type's every field and
390
+ // argument, so per-page computation would be quadratic across the build; the
391
+ // whole reverse index costs the same single walk and memoizes on the document
392
+ // object the `blume:openapi` data module instantiates once per process.
393
+ const usageCache = new WeakMap<GraphqlDocument, Map<string, GraphqlUsage>>();
394
+
395
+ const usageIndex = (document: GraphqlDocument): Map<string, GraphqlUsage> => {
396
+ const cached = usageCache.get(document);
397
+ if (cached) {
398
+ return cached;
399
+ }
400
+ const index = new Map<string, GraphqlUsage>();
401
+ const rootNames = new Map<string, GraphqlOperationKind>();
402
+ for (const kind of GRAPHQL_OPERATION_KINDS) {
403
+ const name = document.roots[kind];
404
+ if (name !== undefined) {
405
+ rootNames.set(name, kind);
406
+ }
407
+ }
408
+ for (const type of Object.values(document.types)) {
409
+ const rootKind = rootNames.get(type.name);
410
+ if (rootKind !== undefined) {
411
+ for (const field of type.fields ?? []) {
412
+ for (const name of fieldMentionNames(field)) {
413
+ // Self-references never backlink (`name !== type.name`, here and
414
+ // below) — a type page must not list itself as its own user.
415
+ if (name !== type.name) {
416
+ usageFor(index, name).operations.push({
417
+ kind: rootKind,
418
+ name: field.name,
419
+ });
420
+ }
421
+ }
422
+ }
423
+ continue;
424
+ }
425
+ const mentioned = new Set<string>();
426
+ for (const field of type.fields ?? []) {
427
+ for (const name of fieldMentionNames(field)) {
428
+ mentioned.add(name);
429
+ }
430
+ }
431
+ for (const input of type.inputFields ?? []) {
432
+ mentioned.add(input.type.name);
433
+ }
434
+ if (type.kind === "union") {
435
+ for (const member of type.possibleTypes ?? []) {
436
+ mentioned.add(member);
437
+ }
438
+ }
439
+ for (const name of mentioned) {
440
+ if (name !== type.name) {
441
+ usageFor(index, name).types.push(type.name);
442
+ }
443
+ }
444
+ }
445
+ usageCache.set(document, index);
446
+ return index;
447
+ };
448
+
449
+ /**
450
+ * Usage backlinks for one type page: the operations that return or accept it,
451
+ * and the other types that reference it (fields, input fields, union
452
+ * membership). Root types are folded into `operations` — their fields are the
453
+ * operation pages.
454
+ */
455
+ export const graphqlUsage = (
456
+ document: GraphqlDocument,
457
+ target: string
458
+ ): GraphqlUsage =>
459
+ usageIndex(document).get(target) ?? { operations: [], types: [] };
460
+
461
+ /** The row shapes the fields table renders: output fields or input values. */
462
+ export type GraphqlFieldRow = GraphqlField | GraphqlInputValue;
463
+
464
+ /** Narrow a table row to an output field (with arguments). */
465
+ export const isOutputField = (row: GraphqlFieldRow): row is GraphqlField =>
466
+ "args" in row;
@@ -337,6 +337,21 @@ export const initPlayground = (root: HTMLElement): void => {
337
337
  code.textContent = prettyBody(text);
338
338
  pre.append(code);
339
339
  region.append(pre);
340
+ // At xl the panel is its own scroll region capped to the viewport, so a
341
+ // response appended under a tall form can land below the panel's fold
342
+ // where nothing brings it into view. Scroll the panel alone — "nearest",
343
+ // by hand — and never the document: below xl the panel does not scroll,
344
+ // and a document scroll would carry the form (Send, the body editor, its
345
+ // errors) off the top on a phone.
346
+ const panel = region.closest<HTMLElement>("[data-operation-panel]");
347
+ if (panel && panel.scrollHeight > panel.clientHeight) {
348
+ const box = panel.getBoundingClientRect();
349
+ const target = region.getBoundingClientRect();
350
+ const delta = Math.min(target.bottom - box.bottom, target.top - box.top);
351
+ if (delta > 0) {
352
+ panel.scrollBy({ top: delta });
353
+ }
354
+ }
340
355
  };
341
356
 
342
357
  /**
@@ -0,0 +1,45 @@
1
+ import type { CodeThemes } from "../../markdown/index.ts";
2
+ import { highlightCode } from "../../markdown/index.ts";
3
+
4
+ /**
5
+ * The Request-tab rendering shared by the operation renderers (OpenAPI's
6
+ * `RequestPanel`, `AsyncApiOperation`, `GraphqlOperation`): one highlighted
7
+ * panel per code-sample language, so the panel shape and highlight options
8
+ * can't drift between the three. Kept apart from `snippets.ts`, which must
9
+ * stay dependency-free for the browser playground client — this module pulls
10
+ * in Shiki.
11
+ */
12
+
13
+ /** One rendered tab: highlighted HTML plus the `PanelTabs` metadata. */
14
+ export interface SamplePanel {
15
+ html: string;
16
+ key: string;
17
+ label: string;
18
+ lang: string;
19
+ }
20
+
21
+ /** The shape `SampleLanguage` and `AsyncSampleLanguage` share. */
22
+ interface PanelLanguage<Sample> {
23
+ id: string;
24
+ label: string;
25
+ lang: string;
26
+ build: (sample: Sample) => string;
27
+ }
28
+
29
+ /** Render one highlighted panel per language for a built request sample. */
30
+ export const languageSamplePanels = <Sample>(
31
+ languages: PanelLanguage<Sample>[],
32
+ sample: Sample,
33
+ themes: CodeThemes
34
+ ): Promise<SamplePanel[]> =>
35
+ Promise.all(
36
+ languages.map(async (language) => ({
37
+ html: await highlightCode(language.build(sample), language.lang, {
38
+ icons: false,
39
+ themes,
40
+ }),
41
+ key: language.id,
42
+ label: language.label,
43
+ lang: language.id,
44
+ }))
45
+ );
@@ -46,39 +46,20 @@ const fetchSnippet = (sample: RequestSample): string => {
46
46
  options.push(` headers: {\n${headers}\n }`);
47
47
  }
48
48
  if (sample.body) {
49
- // `bodyValue` mirrors the body only when it parses as JSON. Mid-edit text
50
- // that doesn't can't be inlined as a JS expression — a string literal of
51
- // the raw text keeps the snippet syntactically valid and byte-identical
52
- // to what the live send transmits.
53
- options.push(
54
- sample.bodyValue === undefined
55
- ? ` body: ${JSON.stringify(sample.body)}`
56
- : ` body: JSON.stringify(${sample.body})`
57
- );
49
+ // Always the raw editor text as a string literal, never re-read as a JS
50
+ // expression: the sample must send byte-for-byte what the live request
51
+ // sends, and an object literal doesn't round-trip every valid JSON
52
+ // document — `{"__proto__":{"x":1}}` sets a prototype instead of a key,
53
+ // and an id past 2^53 loses digits through a JS number. The string
54
+ // literal also stays syntactically valid while the editor holds mid-edit
55
+ // text that isn't JSON yet.
56
+ options.push(` body: ${JSON.stringify(sample.body)}`);
58
57
  }
59
58
  return `const response = await fetch("${sample.url}", {\n${options.join(
60
59
  ",\n"
61
60
  )}\n});`;
62
61
  };
63
62
 
64
- // Split-with-capture: odd segments are JSON string literals, kept verbatim so
65
- // a string *value* containing the words true/false/null isn't rewritten.
66
- const JSON_STRING = /(?<literal>"(?:\\.|[^"\\])*")/gu;
67
-
68
- /** Turn a JSON literal into an equivalent Python literal (`true` -> `True`). */
69
- const toPython = (json: string): string =>
70
- json
71
- .split(JSON_STRING)
72
- .map((part, index) =>
73
- index % 2 === 1
74
- ? part
75
- : part
76
- .replaceAll(/\btrue\b/gu, "True")
77
- .replaceAll(/\bfalse\b/gu, "False")
78
- .replaceAll(/\bnull\b/gu, "None")
79
- )
80
- .join("");
81
-
82
63
  const pythonSnippet = (sample: RequestSample): string => {
83
64
  const args = [` "${sample.url}"`];
84
65
  if (Object.keys(sample.headers).length > 0) {
@@ -89,14 +70,11 @@ const pythonSnippet = (sample: RequestSample): string => {
89
70
  args.push(` headers={\n${headers}\n }`);
90
71
  }
91
72
  if (sample.body) {
92
- // Same rule as the fetch snippet: only valid JSON rewrites into a Python
93
- // literal for `json=`; anything else travels as a raw string via `data=`
94
- // (JSON string escapes are a subset of Python's, so the literal is valid).
95
- args.push(
96
- sample.bodyValue === undefined
97
- ? ` data=${JSON.stringify(sample.body)}`
98
- : ` json=${toPython(sample.body)}`
99
- );
73
+ // Same rule as the fetch snippet: the raw text travels as a string via
74
+ // `data=` (JSON string escapes are a subset of Python's, so the literal is
75
+ // valid) rather than a `json=` dict — a Python literal re-serializes
76
+ // `1e400` as `Infinity` and would otherwise diverge from the live send.
77
+ args.push(` data=${JSON.stringify(sample.body)}`);
100
78
  }
101
79
  return `import requests\n\nresponse = requests.${sample.method.toLowerCase()}(\n${args.join(
102
80
  ",\n"
@@ -62,6 +62,17 @@ export const normalizeRoute = (input: string): string => {
62
62
  export const isInternalPath = (target: string): boolean =>
63
63
  target.startsWith("/") && !target.startsWith("//");
64
64
 
65
+ /**
66
+ * Whether a rendered link should open in a new tab: an absolute http(s) URL or
67
+ * a protocol-relative one (`//host/path`). Other schemes (`mailto:`, `tel:`)
68
+ * also leave the site but hand off to another application, where a `_blank`
69
+ * target only opens an empty tab beside it. The one predicate behind every
70
+ * chrome link, so a header action and a sidebar featured link treat the same
71
+ * href alike.
72
+ */
73
+ export const isExternalUrl = (target: string): boolean =>
74
+ /^https?:\/\//iu.test(target) || target.startsWith("//");
75
+
65
76
  /**
66
77
  * Idempotently prepend `basePath` to a root-relative route. A route already
67
78
  * equal to or nested under the base is returned unchanged, so authors who write