blume 1.4.2 → 1.5.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 (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -1,10 +1,15 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
3
 
4
+ import type { AsyncAPIDocument as ConverterDocument } from "@asyncapi/converter";
5
+ import { convert } from "@asyncapi/converter";
4
6
  import { normalize, upgrade } from "@scalar/openapi-parser";
7
+ import pRetry, { AbortError } from "p-retry";
5
8
  import { isAbsolute, join } from "pathe";
6
9
 
7
10
  import { hashText } from "../core/sources/cache.ts";
11
+ import type { AsyncApiDocument } from "./asyncapi.ts";
12
+ import { normalizeAsyncApiDocument } from "./asyncapi.ts";
8
13
  import type { ApiDocument } from "./model.ts";
9
14
 
10
15
  /**
@@ -95,13 +100,23 @@ const ensureProxyDispatcher = async (): Promise<void> => {
95
100
  }
96
101
  };
97
102
 
98
- /** `Retry-After` in ms when the server sent a sane one, else undefined. */
103
+ /**
104
+ * `Retry-After` in ms when the server sent a sane one, else undefined. RFC
105
+ * 9110 allows both forms: delta-seconds (`120`) and an HTTP-date (`Wed, 21
106
+ * Oct 2015 07:28:00 GMT`); the date form arrives from CDN rate limiters and
107
+ * was previously ignored.
108
+ */
99
109
  const retryAfterMs = (response: Response): number | undefined => {
100
110
  const header = response.headers.get("retry-after");
101
- const seconds = header ? Number(header) : Number.NaN;
102
- return Number.isFinite(seconds) && seconds > 0
103
- ? seconds * SECOND_MS
104
- : undefined;
111
+ if (!header) {
112
+ return undefined;
113
+ }
114
+ const seconds = Number(header);
115
+ if (Number.isFinite(seconds)) {
116
+ return seconds > 0 ? seconds * SECOND_MS : undefined;
117
+ }
118
+ const delta = Date.parse(header) - Date.now();
119
+ return Number.isFinite(delta) && delta > 0 ? delta : undefined;
105
120
  };
106
121
 
107
122
  /** One fetch attempt, normalized: the body text, or a (maybe-retryable) error. */
@@ -137,33 +152,65 @@ const attemptFetch = async (spec: string): Promise<Attempt> => {
137
152
  }
138
153
  };
139
154
 
155
+ /**
156
+ * A retryable failure, wrapped in a plain Error p-retry never special-cases:
157
+ * it refuses to retry a non-network `TypeError`, and the underlying error's
158
+ * type is the server's choice, not ours. The message is the underlying
159
+ * error's, so the exhaustion throw still reads `spec -> 503 Service
160
+ * Unavailable`.
161
+ */
162
+ type RetryableFetchError = Error & { retryAfter?: number };
163
+
164
+ const retryableFetchError = (
165
+ error: Error,
166
+ retryAfter?: number
167
+ ): RetryableFetchError => {
168
+ const wrapper: RetryableFetchError = new Error(error.message, {
169
+ cause: error,
170
+ });
171
+ wrapper.name = "RetryableFetchError";
172
+ wrapper.retryAfter = retryAfter;
173
+ return wrapper;
174
+ };
175
+
140
176
  /** Fetch a remote spec's text, retrying transient failures with backoff. */
141
177
  const fetchSpecText = async (spec: string): Promise<string> => {
142
178
  await ensureProxyDispatcher();
143
- let last: Attempt = {
144
- error: new Error(`Could not fetch ${spec}`),
145
- retryable: false,
146
- };
147
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
148
- // oxlint-disable-next-line no-await-in-loop -- sequential retry attempts
149
- last = await attemptFetch(spec);
150
- if ("text" in last) {
151
- return last.text;
152
- }
153
- if (!last.retryable) {
154
- break;
155
- }
156
- if (attempt < MAX_ATTEMPTS - 1) {
157
- // oxlint-disable-next-line no-await-in-loop -- back off before retrying
158
- await sleep(
159
- Math.min(
160
- last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt,
161
- MAX_RETRY_WAIT_MS
162
- )
163
- );
179
+ return await pRetry(
180
+ async () => {
181
+ const attempt = await attemptFetch(spec);
182
+ if ("text" in attempt) {
183
+ return attempt.text;
184
+ }
185
+ if (!attempt.retryable) {
186
+ // AbortError stops retrying and rethrows the original untouched.
187
+ throw new AbortError(attempt.error);
188
+ }
189
+ throw retryableFetchError(attempt.error, attempt.retryAfter);
190
+ },
191
+ {
192
+ factor: 2,
193
+ maxTimeout: MAX_RETRY_WAIT_MS,
194
+ minTimeout: BASE_BACKOFF_MS,
195
+ // A sane `Retry-After` replaces the exponential backoff rather than
196
+ // stacking on it: p-retry's own (capped) delay still runs after this
197
+ // hook, so only the difference is slept here.
198
+ onFailedAttempt: async (context) => {
199
+ // SAFETY: every retryable throw above is a RetryableFetchError; any
200
+ // other error reaching this hook reads an absent retryAfter.
201
+ const { retryAfter } = context.error as RetryableFetchError;
202
+ if (retryAfter !== undefined && context.retriesLeft > 0) {
203
+ await sleep(
204
+ Math.max(
205
+ 0,
206
+ Math.min(retryAfter, MAX_RETRY_WAIT_MS) - context.retryDelay
207
+ )
208
+ );
209
+ }
210
+ },
211
+ retries: MAX_ATTEMPTS - 1,
164
212
  }
165
- }
166
- throw last.error;
213
+ );
167
214
  };
168
215
 
169
216
  const cacheFileFor = (cacheDir: string, spec: string): string =>
@@ -224,6 +271,8 @@ const readSpecText = async (
224
271
  if (cacheFile) {
225
272
  const cached = await readCache(cacheFile);
226
273
  if (cached !== undefined) {
274
+ // SAFETY: fetchSpecText throws only Error instances — attemptFetch
275
+ // wraps every non-Error throw in an Error.
227
276
  return {
228
277
  text: cached,
229
278
  warnings: [
@@ -242,6 +291,14 @@ const readSpecText = async (
242
291
  * diagnostic (an error in build, a warning in dev) rather than a hard failure so
243
292
  * a broken spec doesn't take down the whole build.
244
293
  */
294
+ /**
295
+ * A parsed mapping is the only shape the renderer can treat as a document:
296
+ * `normalize` yields undefined for anything that isn't a YAML/JSON mapping
297
+ * (empty file, scalar, list) and `upgrade(undefined)` a null specification.
298
+ */
299
+ const isApiDocument = <Value>(value: Value): value is Value & ApiDocument =>
300
+ typeof value === "object" && value !== null;
301
+
245
302
  export const parseSpec = async (
246
303
  spec: string,
247
304
  root: string,
@@ -250,13 +307,89 @@ export const parseSpec = async (
250
307
  const { text, warnings } = await readSpecText(spec, root, options);
251
308
  const normalized = normalize(text);
252
309
  const { specification } = upgrade(normalized);
253
- // `normalize` yields undefined for anything that isn't a YAML/JSON mapping
254
- // (empty file, scalar, list) and `upgrade(undefined)` yields a null
255
- // specification — reject it here so the renderer never sees a non-document.
256
- if (specification === null || typeof specification !== "object") {
310
+ // Reject a non-mapping here so the renderer never sees a non-document.
311
+ if (!isApiDocument(specification)) {
257
312
  throw new InvalidSpecError(
258
313
  `${spec} is not a valid OpenAPI document (expected a YAML or JSON object).`
259
314
  );
260
315
  }
261
- return { document: specification as ApiDocument, warnings };
316
+ return { document: specification, warnings };
317
+ };
318
+
319
+ export interface ParsedAsyncApiSpec {
320
+ document: AsyncApiDocument;
321
+ warnings: string[];
322
+ }
323
+
324
+ /**
325
+ * Read and normalize a spec to an AsyncAPI 3.x document — the AsyncAPI mirror
326
+ * of {@link parseSpec}. 1.x/2.x documents are lifted to 3.0 with the official
327
+ * `@asyncapi/converter` (channels + operations with `send`/`receive` actions),
328
+ * so the extractor and components only ever handle one shape; `$ref`s stay
329
+ * intact, matching the OpenAPI path. Error semantics match `parseSpec`: an
330
+ * unreadable spec throws, a readable non-AsyncAPI document throws
331
+ * {@link InvalidSpecError}, and callers lower both into source diagnostics.
332
+ */
333
+ /**
334
+ * An object carrying a non-empty `asyncapi` version string — the only input
335
+ * the converter and extractor can key on. `normalize` yields undefined for
336
+ * non-mapping input, which fails the object check here.
337
+ */
338
+ const isAsyncApiDocument = <Value>(
339
+ value: Value
340
+ ): value is Value & AsyncApiDocument & { asyncapi: string } =>
341
+ typeof value === "object" &&
342
+ value !== null &&
343
+ "asyncapi" in value &&
344
+ typeof value.asyncapi === "string" &&
345
+ value.asyncapi !== "";
346
+
347
+ export const parseAsyncApiSpec = async (
348
+ spec: string,
349
+ root: string,
350
+ options: SpecFetchOptions = {}
351
+ ): Promise<ParsedAsyncApiSpec> => {
352
+ const { text, warnings } = await readSpecText(spec, root, options);
353
+ const normalized = normalize(text);
354
+ if (!isAsyncApiDocument(normalized)) {
355
+ throw new InvalidSpecError(
356
+ `${spec} is not a valid AsyncAPI document (expected a YAML or JSON object with an \`asyncapi\` version field).`
357
+ );
358
+ }
359
+ const version = normalized.asyncapi;
360
+ let document: AsyncApiDocument = normalized;
361
+ if (!version.startsWith("3.")) {
362
+ // The converter reports lossy conversions (e.g. a 2.x parameter schema
363
+ // that 3.0 can't express) through console.warn — capture those as spec
364
+ // warnings instead of letting them leak into CLI output.
365
+ const captured: string[] = [];
366
+ const originalWarn = console.warn;
367
+ console.warn = (...args: unknown[]) => {
368
+ captured.push(args.map(String).join(" "));
369
+ };
370
+ try {
371
+ // SAFETY: the converter accepts any pre-3.0 AsyncAPI object and returns
372
+ // the 3.0 shape the extractor consumes; the two packages just declare
373
+ // the document type differently.
374
+ document = convert(
375
+ document as ConverterDocument,
376
+ "3.0.0"
377
+ ) as AsyncApiDocument;
378
+ } catch (error) {
379
+ // An unconvertible document (say, an unknown `asyncapi` version) is a
380
+ // content problem, not a network one — same class as a non-document.
381
+ // SAFETY: @asyncapi/converter throws Error instances for bad input.
382
+ throw new InvalidSpecError(
383
+ `${spec} could not be converted to AsyncAPI 3.0 (${(error as Error).message}).`
384
+ );
385
+ } finally {
386
+ console.warn = originalWarn;
387
+ }
388
+ warnings.push(
389
+ ...captured.map(
390
+ (message) => `Converting ${spec} to AsyncAPI 3.0: ${message}`
391
+ )
392
+ );
393
+ }
394
+ return { document: normalizeAsyncApiDocument(document), warnings };
262
395
  };
@@ -1,6 +1,10 @@
1
- import { withBasePath } from "../core/base-path.ts";
1
+ import { normalizeRoute, withBasePath } from "../core/base-path.ts";
2
2
  import type { ResolvedConfig } from "../core/schema.ts";
3
- import { trimChar, trimEnd } from "../core/trim.ts";
3
+ import { trimChar } from "../core/trim.ts";
4
+
5
+ // Re-exported from its home next to the other path helpers; `core/schema.ts`
6
+ // and downstream consumers historically imported it from here.
7
+ export { normalizeRoute } from "../core/base-path.ts";
4
8
 
5
9
  /**
6
10
  * Pure resolution of the configured API reference blocks into concrete routes,
@@ -50,9 +54,10 @@ export interface ReferenceSource {
50
54
  theme?: string;
51
55
  /**
52
56
  * Arbitrary Scalar config forwarded to `<ScalarComponent>` (Scalar renderer
53
- * only). Takes precedence over Blume's derived spec/theme config.
57
+ * only). Takes precedence over Blume's derived spec/theme config. Typed off
58
+ * the config schema so the two can never drift.
54
59
  */
55
- scalar?: Record<string, unknown>;
60
+ scalar?: ResolvedConfig["openapi"]["scalar"];
56
61
  /** Display options carried through to the Blume renderer. */
57
62
  display: ReferenceDisplay;
58
63
  /**
@@ -62,18 +67,37 @@ export interface ReferenceSource {
62
67
  collisions?: string[];
63
68
  }
64
69
 
65
- const NON_SLUG = /[^a-z0-9]+/gu;
70
+ // Keep Unicode letters/marks/numbers so diacritics stay in the slug (ASCII-only
71
+ // stripping turned `Größe` into `gr-e`, which the nav humanizer rendered as
72
+ // `Gr E`); `\p{M}` keeps combining marks attached to their base letter, which
73
+ // NFC cannot always compose away (Devanagari vowel signs, Turkish `İ`'s
74
+ // lowercased combining dot).
75
+ const NON_SLUG = /[^\p{L}\p{M}\p{N}]+/gu;
76
+ // Format characters (ZWNJ, ZWJ, bidi controls) separate no words — hyphenating
77
+ // them would split Persian/Indic compounds the way ASCII stripping split
78
+ // `Größe` — so they are dropped, not replaced.
79
+ const FORMAT_CHARS = /\p{Cf}/gu;
80
+ // A combining mark at the start of the slug has no base letter to attach to
81
+ // (it would glue onto the preceding `/` in a URL), and a marks-only slug must
82
+ // come out empty so callers' fallbacks (`operations`, `reference`) fire.
83
+ const LEADING_MARKS = /^\p{M}+/u;
66
84
 
85
+ /**
86
+ * Lowercase, hyphen-separated slug: `Add a Pet!` -> `add-a-pet`. Unicode
87
+ * letters are kept (`Größe` -> `größe`), NFC-normalized so canonically
88
+ * equivalent spellings (NFD input from macOS tooling) land on one slug.
89
+ * Non-ASCII slugs rely on the emitter percent-encoding the URL where a raw
90
+ * URI is required (sitemap, canonical).
91
+ */
67
92
  export const slugify = (text: string): string =>
68
- trimChar(text.toLowerCase().replace(NON_SLUG, "-"), "-");
69
-
70
- /** Normalize a configured route to a single leading slash, no trailing slash. */
71
- export const normalizeRoute = (route: string): string => {
72
- const trimmed = route.trim();
73
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
74
- const noTrailing = trimEnd(withSlash, "/");
75
- return noTrailing === "" ? "/" : noTrailing;
76
- };
93
+ trimChar(
94
+ text
95
+ .normalize("NFC")
96
+ .toLowerCase()
97
+ .replace(FORMAT_CHARS, "")
98
+ .replace(NON_SLUG, "-"),
99
+ "-"
100
+ ).replace(LEADING_MARKS, "");
77
101
 
78
102
  /** A stable per-reference token from its route: `/api/events` -> `api-events`. */
79
103
  const routeSlug = (route: string): string =>
@@ -151,11 +175,9 @@ const referencesFor = (
151
175
  });
152
176
  };
153
177
 
154
- const NO_DISPLAY: ReferenceDisplay = { codeSamples: [], expandSchemas: false };
155
-
156
178
  /**
157
- * Resolve every enabled reference. OpenAPI honors its `renderer` (Blume's own UI
158
- * by default); AsyncAPI is always rendered by Scalar for now.
179
+ * Resolve every enabled reference. Both blocks honor their `renderer`
180
+ * Blume's own UI by default, with the embedded Scalar SPA as the opt-out.
159
181
  */
160
182
  export const resolveReferences = (
161
183
  config: ResolvedConfig
@@ -175,8 +197,11 @@ export const resolveReferences = (
175
197
  "asyncapi",
176
198
  config.asyncapi,
177
199
  "Events",
178
- "scalar",
179
- NO_DISPLAY,
200
+ config.asyncapi.renderer,
201
+ {
202
+ codeSamples: config.asyncapi.codeSamples,
203
+ expandSchemas: config.asyncapi.expandSchemas,
204
+ },
180
205
  config.basePath
181
206
  ),
182
207
  ];
@@ -209,7 +234,7 @@ const blumeReferenceOf = (
209
234
  seen: Map<string, ReferenceSource>,
210
235
  usedSlugs: Set<string>
211
236
  ): ReferenceSource | null => {
212
- if (ref.kind !== "openapi" || ref.renderer !== "blume") {
237
+ if (ref.renderer !== "blume") {
213
238
  return null;
214
239
  }
215
240
  const kept = seen.get(ref.route);
@@ -237,7 +262,7 @@ const blumeReferenceOf = (
237
262
  return accepted;
238
263
  };
239
264
 
240
- /** Blume-rendered OpenAPI references, deduped by route (first wins). */
265
+ /** Blume-rendered references (both kinds), deduped by route (first wins). */
241
266
  export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
242
267
  const seen = new Map<string, ReferenceSource>();
243
268
  const usedSlugs = new Set<string>();
@@ -1,3 +1,9 @@
1
+ import type { Nodes } from "mdast";
2
+ import { fromMarkdown } from "mdast-util-from-markdown";
3
+ import { toString as mdastToString } from "mdast-util-to-string";
4
+ import stringWidth from "string-width";
5
+
6
+ import { columnsPrefix } from "../core/text-width.ts";
1
7
  import type { ApiOperationRef, ApiSpecData } from "./model.ts";
2
8
  import type { ReferenceSource } from "./references.ts";
3
9
 
@@ -18,49 +24,95 @@ import type { ReferenceSource } from "./references.ts";
18
24
  // deliberately not escaped: it isn't MDX-special on its own, and escaping it
19
25
  // turns a `> Note:` blockquote into literal "&gt; Note:" text.
20
26
  const MDX_UNSAFE = /[<{}]/gu;
21
- const ENTITIES: Record<string, string> = {
22
- "<": "&lt;",
23
- "{": "&#123;",
24
- "}": "&#125;",
25
- };
27
+ const ENTITIES = new Map([
28
+ ["<", "&lt;"],
29
+ ["{", "&#123;"],
30
+ ["}", "&#125;"],
31
+ ]);
26
32
  // MDX also parses lines starting with `import`/`export` as ESM ("import the
27
33
  // SDK…" is common spec prose). Entity-escape the keyword's first letter so the
28
34
  // construct can't match; it still renders as the literal word.
29
35
  const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
30
- // Backtick code — inline spans and fences alike — is already literal in MDX,
31
- // and entities are NOT decoded inside it, so escaping there would render the
32
- // entity text verbatim (`/pets/&#123;petId&#125;`). Matching any balanced
33
- // backtick run covers `code`, ``code``, and ```fences``` in one shot. Both
34
- // runs are pinned by the backtick lookarounds: CommonMark pairs a span only
35
- // with an *equal-length* run, so without them a lone backtick would "close" on
36
- // the first backtick of a longer fence run — leaving `{` in the real prose
37
- // unescaped (a compile error) and escaping entities into the fence body.
38
- const BACKTICK_CODE = /(?<!`)(?<bt>`+)(?!`)[\s\S]*?(?<!`)\k<bt>(?!`)/gu;
39
-
40
36
  const escapeProse = (text: string): string =>
41
37
  text
42
- .replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char)
38
+ .replace(MDX_UNSAFE, (char) => ENTITIES.get(char) ?? char)
43
39
  .replace(
44
40
  MDX_ESM_KEYWORD,
45
41
  (keyword) => `&#${keyword.codePointAt(0)};${keyword.slice(1)}`
46
42
  );
47
43
 
48
- /** Escape MDX-special syntax in prose while leaving backtick code verbatim. */
44
+ /**
45
+ * The source offset ranges of code constructs — inline spans and fences —
46
+ * that MDX treats as literal (entities are NOT decoded inside them, so
47
+ * escaping there would render `/pets/&#123;petId&#125;` verbatim). The ranges
48
+ * come from a CommonMark parse rather than fence-emulating regexes: the
49
+ * parser is the authority on equal-length backtick pairing, longer tilde
50
+ * closers, unclosed fences running to EOF, and fences nested in blockquotes —
51
+ * each of which the replaced regexes had to re-derive (two with a recorded
52
+ * bug history in this file).
53
+ *
54
+ * One CommonMark construct is deliberately *not* masked: indented code. MDX
55
+ * disables indented code blocks, so a 4-space-indented sample is a paragraph
56
+ * whose braces genuinely need escaping; fence-or-backtick is told apart from
57
+ * indentation by the construct's first character.
58
+ */
59
+ const codeSpans = (text: string): [number, number][] => {
60
+ const spans: [number, number][] = [];
61
+ const collect = (node: Nodes): void => {
62
+ if (node.type === "inlineCode" || node.type === "code") {
63
+ // fromMarkdown always stamps positions; -1 is an unreachable guard.
64
+ const start = node.position?.start.offset ?? -1;
65
+ const end = node.position?.end.offset ?? -1;
66
+ const head = text.slice(Math.max(start, 0), Math.max(end, 0)).trimStart();
67
+ if (
68
+ start >= 0 &&
69
+ (node.type === "inlineCode" ||
70
+ head.startsWith("`") ||
71
+ head.startsWith("~"))
72
+ ) {
73
+ spans.push([start, end]);
74
+ }
75
+ return;
76
+ }
77
+ if ("children" in node) {
78
+ for (const child of node.children) {
79
+ collect(child);
80
+ }
81
+ }
82
+ };
83
+ collect(fromMarkdown(text));
84
+ return spans;
85
+ };
86
+
87
+ /** Escape MDX-special syntax in prose while leaving code verbatim. */
49
88
  const mdxSafe = (text: string): string => {
50
89
  let out = "";
51
90
  let cursor = 0;
52
- for (const match of text.matchAll(BACKTICK_CODE)) {
53
- const start = match.index ?? 0;
91
+ for (const [start, end] of codeSpans(text)) {
54
92
  out += escapeProse(text.slice(cursor, start));
55
- out += match[0];
56
- cursor = start + match[0].length;
93
+ out += text.slice(start, end);
94
+ cursor = end;
57
95
  }
58
96
  return out + escapeProse(text.slice(cursor));
59
97
  };
60
98
 
99
+ /**
100
+ * Frontmatter emitted for one operation or overview page. Boolean flags are
101
+ * assigned only when set, so absent keys stay absent in the staged MDX.
102
+ */
103
+ export interface RenderedPageData {
104
+ ai?: { exclude: boolean };
105
+ deprecated?: boolean;
106
+ search?: { exclude?: boolean; tags?: string[] };
107
+ seo: { description: string; noindex?: boolean };
108
+ sidebar: { badge?: string; label: string };
109
+ title: string;
110
+ type?: string;
111
+ }
112
+
61
113
  /** Frontmatter + body for one operation or overview page. */
62
114
  export interface RenderedPage {
63
- data: Record<string, unknown>;
115
+ data: RenderedPageData;
64
116
  body: string;
65
117
  }
66
118
 
@@ -70,33 +122,36 @@ export interface RenderedPage {
70
122
  // `description`: the prose already renders in the body, and a `description`
71
123
  // frontmatter field would print it a second time as the page subtitle.
72
124
  const META_DESCRIPTION_MAX = 160;
73
- const PARAGRAPH_BREAK = /\n\s*\n/u;
74
- const MARKDOWN_LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
75
- const MARKDOWN_MARKS = /[*_`#>]/gu;
76
125
  const WHITESPACE = /\s+/gu;
77
126
  const TRAILING_WORD = /\s+\S*$/u;
78
127
 
79
- /** Flatten markdown prose to its first paragraph as single-line plain text. */
80
- const plainProse = (markdown: string): string =>
81
- (markdown.trim().split(PARAGRAPH_BREAK).at(0) ?? "")
82
- .replace(MARKDOWN_LINK, "$<text>")
83
- .replace(MARKDOWN_MARKS, "")
84
- .replace(WHITESPACE, " ")
85
- .trim();
128
+ /**
129
+ * Flatten markdown prose to its first paragraph as single-line plain text,
130
+ * via a real parse (`mdast-util-to-string`). The regex strip this replaces
131
+ * was lossy on literal prose — `snake_case` → `snakecase`, `C#` → `C` — and
132
+ * these strings ship as `seo.description` meta tags. A description with no
133
+ * paragraph (say, only a heading or list) falls back to its first block.
134
+ */
135
+ const plainProse = (markdown: string): string => {
136
+ const tree = fromMarkdown(markdown);
137
+ const first =
138
+ tree.children.find((node) => node.type === "paragraph") ?? tree.children[0];
139
+ return first ? mdastToString(first).replace(WHITESPACE, " ").trim() : "";
140
+ };
86
141
 
87
- /** Cap `text` at `max` characters, cutting on a word boundary. */
142
+ /** Cap `text` at `max` display columns, cutting on a word boundary. */
88
143
  const clip = (text: string, max: number): string => {
89
144
  if (max <= 0) {
90
145
  return "";
91
146
  }
92
- if (text.length <= max) {
147
+ if (stringWidth(text) <= max) {
93
148
  return text;
94
149
  }
95
- const head = text.slice(0, max - 1);
150
+ const head = columnsPrefix(text, max - 1);
96
151
  const onWordBoundary = head.replace(TRAILING_WORD, "");
97
152
  // One very long token — an endpoint path has no spaces — would be dropped
98
153
  // whole, leaving a stub. Hard-cut it instead of losing it.
99
- return `${onWordBoundary.length >= max / 2 ? onWordBoundary : head}…`;
154
+ return `${stringWidth(onWordBoundary) >= max / 2 ? onWordBoundary : head}…`;
100
155
  };
101
156
 
102
157
  const apiName = (spec: ApiSpecData): string => spec.title || spec.label;
@@ -110,11 +165,14 @@ const operationDescription = (
110
165
  spec: ApiSpecData,
111
166
  operation: ApiOperationRef
112
167
  ): string => {
113
- const endpoint = `${operation.method.toUpperCase()} ${operation.path}`;
114
- const suffix = `Reference for the ${endpoint} endpoint in the ${apiName(spec)} API.`;
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.`;
115
173
  const prose = clip(
116
174
  plainProse(operation.description || operation.summary),
117
- META_DESCRIPTION_MAX - suffix.length - 1
175
+ META_DESCRIPTION_MAX - stringWidth(suffix) - 1
118
176
  );
119
177
  return clip([prose, suffix].filter(Boolean).join(" "), META_DESCRIPTION_MAX);
120
178
  };
@@ -141,22 +199,35 @@ export const operationMdx = (
141
199
  operation.description.trim() === operation.summary.trim()
142
200
  ? ""
143
201
  : operation.description;
202
+ const flags: Pick<RenderedPageData, "ai" | "deprecated"> = {};
203
+ if (reference?.includeInLlms === false) {
204
+ flags.ai = { exclude: true };
205
+ }
206
+ if (operation.deprecated) {
207
+ flags.deprecated = true;
208
+ }
209
+ const searchFlags: Pick<
210
+ NonNullable<RenderedPageData["search"]>,
211
+ "exclude"
212
+ > = {};
213
+ if (reference?.includeInSearch === false) {
214
+ searchFlags.exclude = true;
215
+ }
216
+ const seo: RenderedPageData["seo"] = {
217
+ description: operationDescription(spec, operation),
218
+ };
219
+ if (reference?.noindex) {
220
+ seo.noindex = true;
221
+ }
144
222
  return {
145
223
  body: withDescription(
146
224
  description,
147
225
  `<Operation source="${spec.slug}" id="${operation.key}" />`
148
226
  ),
149
227
  data: {
150
- ...(reference?.includeInLlms === false ? { ai: { exclude: true } } : {}),
151
- ...(operation.deprecated ? { deprecated: true } : {}),
152
- search: {
153
- ...(reference?.includeInSearch === false ? { exclude: true } : {}),
154
- tags: [operation.tag, method],
155
- },
156
- seo: {
157
- description: operationDescription(spec, operation),
158
- ...(reference?.noindex ? { noindex: true } : {}),
159
- },
228
+ ...flags,
229
+ search: { ...searchFlags, tags: [operation.tag, method] },
230
+ seo,
160
231
  sidebar: { badge: method, label: operation.summary || operation.path },
161
232
  title,
162
233
  // Signals the two-column API layout (request panel instead of the TOC).
@@ -214,6 +285,21 @@ export const overviewMdx = (
214
285
  ].join("\n\n")
215
286
  );
216
287
  }
288
+ const flags: Pick<RenderedPageData, "ai" | "search"> = {};
289
+ if (reference?.includeInLlms === false) {
290
+ flags.ai = { exclude: true };
291
+ }
292
+ if (reference?.includeInSearch === false) {
293
+ flags.search = { exclude: true };
294
+ }
295
+ const seo: RenderedPageData["seo"] = {
296
+ description:
297
+ clip(plainProse(spec.description), META_DESCRIPTION_MAX) ||
298
+ `${apiName(spec)} API reference.`,
299
+ };
300
+ if (reference?.noindex) {
301
+ seo.noindex = true;
302
+ }
217
303
  return {
218
304
  body: [
219
305
  withDescription(
@@ -223,16 +309,8 @@ export const overviewMdx = (
223
309
  ...tagSections,
224
310
  ].join("\n\n"),
225
311
  data: {
226
- ...(reference?.includeInLlms === false ? { ai: { exclude: true } } : {}),
227
- ...(reference?.includeInSearch === false
228
- ? { search: { exclude: true } }
229
- : {}),
230
- seo: {
231
- description:
232
- clip(plainProse(spec.description), META_DESCRIPTION_MAX) ||
233
- `${apiName(spec)} API reference.`,
234
- ...(reference?.noindex ? { noindex: true } : {}),
235
- },
312
+ ...flags,
313
+ seo,
236
314
  sidebar: { label: "Overview" },
237
315
  title: apiName(spec),
238
316
  },