blume 1.4.2 → 1.4.3

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 (90) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli/index.js +694 -579
  3. package/dist/cli/index.js.map +56 -55
  4. package/dist/types/core/base-path.d.ts +8 -0
  5. package/dist/types/core/config-input.d.ts +8 -0
  6. package/dist/types/core/schema.d.ts +4 -0
  7. package/dist/types/core/sources/types.d.ts +9 -1
  8. package/dist/types/openapi/references.d.ts +8 -2
  9. package/docs/configuration/ai.mdx +26 -8
  10. package/docs/content/sources.mdx +1 -1
  11. package/package.json +11 -1
  12. package/src/ai/agent-readability.ts +3 -2
  13. package/src/ai/api-catalog.ts +2 -2
  14. package/src/ai/ask-context.ts +45 -12
  15. package/src/ai/llms.ts +2 -1
  16. package/src/ai/mcp/discovery.ts +25 -6
  17. package/src/ai/mcp/server.ts +108 -98
  18. package/src/ai/tar.ts +29 -70
  19. package/src/astro/examples.ts +7 -3
  20. package/src/astro/generate.ts +59 -34
  21. package/src/astro/islands.ts +7 -3
  22. package/src/astro/templates.ts +34 -9
  23. package/src/audit/agent.ts +14 -29
  24. package/src/audit/crawl.ts +41 -16
  25. package/src/audit/run.ts +10 -3
  26. package/src/audit/snapshot.ts +27 -2
  27. package/src/cli/commands/audit.ts +12 -17
  28. package/src/cli/commands/build.ts +15 -7
  29. package/src/cli/commands/dev.ts +13 -15
  30. package/src/cli/commands/eject.ts +4 -4
  31. package/src/cli/commands/eval.ts +17 -27
  32. package/src/cli/env.ts +13 -30
  33. package/src/cli/init/scaffold.ts +21 -0
  34. package/src/cli/report-format.ts +22 -0
  35. package/src/components/content/AccordionItem.astro +2 -9
  36. package/src/components/content/ColorItem.astro +5 -13
  37. package/src/components/content/Component.astro +12 -8
  38. package/src/components/content/Frame.astro +2 -12
  39. package/src/components/content/Prompt.astro +12 -31
  40. package/src/components/content/Tab.astro +2 -9
  41. package/src/components/content/Tooltip.astro +1 -9
  42. package/src/components/content/Update.astro +2 -9
  43. package/src/components/content/inline-markdown.ts +28 -0
  44. package/src/components/copy-feedback.ts +96 -0
  45. package/src/components/islands/ask-ai.tsx +78 -9
  46. package/src/components/layout/PageActions.astro +20 -32
  47. package/src/components/layout/PageLayout.astro +8 -28
  48. package/src/components/layout/RootLayout.astro +6 -48
  49. package/src/components/layout/Search.astro +56 -9
  50. package/src/components/layout/drawer-inert.ts +31 -0
  51. package/src/components/layout/search/pagefind.ts +6 -5
  52. package/src/components/layout/search/types.ts +32 -0
  53. package/src/components/openapi/panel.ts +11 -8
  54. package/src/components/raf-throttle.ts +21 -0
  55. package/src/components/slug.ts +14 -0
  56. package/src/core/base-path.ts +18 -1
  57. package/src/core/config-input.ts +8 -0
  58. package/src/core/frontmatter.ts +45 -1
  59. package/src/core/probe.ts +7 -19
  60. package/src/core/project-graph.ts +12 -1
  61. package/src/core/schema.ts +6 -0
  62. package/src/core/site-url.ts +27 -0
  63. package/src/core/sources/cache.ts +10 -8
  64. package/src/core/sources/github-releases.ts +21 -1
  65. package/src/core/sources/normalize.ts +26 -2
  66. package/src/core/sources/notion.ts +27 -5
  67. package/src/core/sources/portable-text.ts +16 -1
  68. package/src/core/sources/resolve.ts +1 -0
  69. package/src/core/sources/types.ts +13 -1
  70. package/src/deploy/cloudflare-negotiation.ts +15 -1
  71. package/src/deploy/robots.ts +2 -1
  72. package/src/deploy/rss.ts +2 -1
  73. package/src/deploy/sitemap.ts +56 -7
  74. package/src/eval/agents.ts +13 -10
  75. package/src/eval/report.ts +1 -14
  76. package/src/markdown/package-commands.ts +61 -54
  77. package/src/og/card.ts +24 -26
  78. package/src/openapi/model.ts +9 -9
  79. package/src/openapi/parse.ts +69 -28
  80. package/src/openapi/references.ts +35 -12
  81. package/src/openapi/render-mdx.ts +64 -25
  82. package/src/openapi/scalar.ts +2 -2
  83. package/src/openapi/source.ts +28 -1
  84. package/src/search/documents.ts +78 -34
  85. package/src/search/orama-index.ts +51 -12
  86. package/src/theme/palette.ts +6 -2
  87. package/src/translate/ledger.ts +4 -2
  88. package/src/translate/report.ts +1 -14
  89. package/src/translate/run.ts +20 -35
  90. package/src/cli/coalesce.ts +0 -43
@@ -1,6 +1,6 @@
1
- import { spawn } from "node:child_process";
2
1
  import { readFile, writeFile } from "node:fs/promises";
3
2
 
3
+ import spawn from "cross-spawn";
4
4
  import { join } from "pathe";
5
5
  import { z } from "zod";
6
6
 
@@ -48,7 +48,6 @@ export interface HeadlessResult {
48
48
 
49
49
  export interface HeadlessOptions {
50
50
  cwd: string;
51
- platform?: NodeJS.Platform;
52
51
  prompt: string;
53
52
  timeoutMs: number;
54
53
  }
@@ -58,6 +57,15 @@ export interface HeadlessOptions {
58
57
  * cmd.exe newline quoting alike), stdout and stderr captured, SIGTERM at the
59
58
  * deadline with a SIGKILL follow-up. Resolves with the captured result;
60
59
  * rejects only when the executable cannot be spawned at all (ENOENT).
60
+ *
61
+ * Spawned through cross-spawn, which runs npm's `.cmd` shims on Windows with
62
+ * its own cmd.exe escaping — under the old `shell: true` launch, cmd.exe was
63
+ * free to mangle the JSON carried by codex's `-c` arguments, and a missing
64
+ * executable surfaced as exit code 9009 instead of an ENOENT rejection.
65
+ * execa was evaluated and rejected here: it waits for the stdio streams to
66
+ * close, and the `exit`-beats-`close` resolution below exists precisely
67
+ * because a killed agent's own children (an MCP server, a shell) hold the
68
+ * pipes open past the SIGTERM.
61
69
  */
62
70
  export const runAgentHeadless = (
63
71
  bin: string,
@@ -66,23 +74,18 @@ export const runAgentHeadless = (
66
74
  ): Promise<HeadlessResult> =>
67
75
  // oxlint-disable-next-line promise/avoid-new -- adapt spawn's event callbacks
68
76
  new Promise((resolve, reject) => {
69
- const platform = options.platform ?? process.platform;
70
- // npm installs agent CLIs as `.cmd` shims on Windows, which Node refuses
71
- // to spawn without a shell. Arguments are plain flags and absolute paths,
72
- // so shell interpolation has nothing to mangle.
73
77
  const child = spawn(bin, args, {
74
78
  cwd: options.cwd,
75
- shell: platform === "win32",
76
79
  stdio: ["pipe", "pipe", "pipe"],
77
80
  });
78
81
 
79
82
  let stdout = "";
80
83
  let stderr = "";
81
84
  let timedOut = false;
82
- child.stdout.on("data", (chunk: Buffer) => {
85
+ child.stdout?.on("data", (chunk: Buffer) => {
83
86
  stdout += chunk.toString("utf-8");
84
87
  });
85
- child.stderr.on("data", (chunk: Buffer) => {
88
+ child.stderr?.on("data", (chunk: Buffer) => {
86
89
  stderr += chunk.toString("utf-8");
87
90
  });
88
91
 
@@ -112,7 +115,7 @@ export const runAgentHeadless = (
112
115
  }
113
116
  });
114
117
 
115
- child.stdin.end(options.prompt);
118
+ child.stdin?.end(options.prompt);
116
119
  });
117
120
 
118
121
  /** The spawn signature `runEval` accepts, injectable for tests. */
@@ -6,6 +6,7 @@ import type { ColorFunction } from "consola/utils";
6
6
  import { join, relative } from "pathe";
7
7
 
8
8
  import { AGENTS } from "../audit/agent.ts";
9
+ import { duration, money, seconds } from "../cli/report-format.ts";
9
10
  import { countBySeverity } from "../core/diagnostics.ts";
10
11
  import type { EvalResult, QuestionResult, QuestionStatus } from "./run.ts";
11
12
 
@@ -26,20 +27,6 @@ const STATUS_COLOR: Record<QuestionStatus, ColorFunction> = {
26
27
  /** Longest id gets the room; everything shorter aligns to it. */
27
28
  const ID_PAD = 28;
28
29
 
29
- const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
30
-
31
- const money = (cost: number | undefined): string =>
32
- cost === undefined ? "" : `$${cost.toFixed(2)}`;
33
-
34
- const duration = (ms: number): string => {
35
- if (ms < 60_000) {
36
- return seconds(ms);
37
- }
38
- const minutes = Math.floor(ms / 60_000);
39
- const rest = Math.round((ms % 60_000) / 1000);
40
- return `${minutes}m ${rest}s`;
41
- };
42
-
43
30
  /** One question's progress/report line: glyph, id, status, score, time, cost. */
44
31
  export const questionLine = (result: QuestionResult): string => {
45
32
  const color = STATUS_COLOR[result.status];
@@ -1,8 +1,26 @@
1
+ import type { Agent, Command } from "package-manager-detector";
2
+ import { resolveCommand } from "package-manager-detector/commands";
3
+
1
4
  /** Supported package managers, in the order tabs are displayed. */
2
5
  export const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
3
6
 
4
7
  export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
5
8
 
9
+ /**
10
+ * The agent each tab renders. The yarn tab is pinned to Berry (yarn 2+ — the
11
+ * only supported line): the pre-existing mix emitted Berry-only `yarn dlx` and
12
+ * `--immutable` next to Classic-only `yarn global add`, so no single yarn
13
+ * version could run every rendered command. Berry removed `global`
14
+ * (yarnpkg/berry#821), so global installs on the yarn tab honestly render
15
+ * npm's form, matching `ni`'s table.
16
+ */
17
+ const AGENT_FOR: Record<PackageManager, Agent> = {
18
+ bun: "bun",
19
+ npm: "npm",
20
+ pnpm: "pnpm",
21
+ yarn: "yarn@berry",
22
+ };
23
+
6
24
  /** Words that mark the input as an explicit command rather than a bare list. */
7
25
  const MANAGER_PREFIXES = new Set(["bun", "bunx", "npm", "npx", "pnpm", "yarn"]);
8
26
 
@@ -118,62 +136,51 @@ const parseIntent = (input: string): Intent => {
118
136
  return { args: normalizeFlags(verbArgs), operation };
119
137
  };
120
138
 
121
- /** Render one manager's command for the given intent. */
139
+ /** The package-manager-detector command for each non-global operation. */
140
+ const COMMAND_FOR: Record<Exclude<Operation, "create">, Command> = {
141
+ add: "add",
142
+ ci: "frozen",
143
+ exec: "execute",
144
+ install: "install",
145
+ remove: "uninstall",
146
+ run: "run",
147
+ };
148
+
149
+ /**
150
+ * Render one manager's command for the given intent, via
151
+ * package-manager-detector's maintained agent tables (the engine behind `ni`).
152
+ */
122
153
  const buildCommand = (manager: PackageManager, intent: Intent): string => {
123
- const args = intent.args.join(" ");
124
- switch (intent.operation) {
125
- case "add": {
126
- if (manager === "npm") {
127
- return `npm install ${args}`;
128
- }
129
- if (manager === "yarn" && intent.args.some((a) => GLOBAL_FLAGS.has(a))) {
130
- const pkgs = intent.args.filter((a) => !GLOBAL_FLAGS.has(a)).join(" ");
131
- return `yarn global add ${pkgs}`;
132
- }
133
- return `${manager} add ${args}`;
134
- }
135
- case "create": {
136
- return `${manager} create ${args}`;
137
- }
138
- case "exec": {
139
- if (manager === "npm") {
140
- return `npx ${args}`;
141
- }
142
- if (manager === "bun") {
143
- return `bunx ${args}`;
144
- }
145
- return `${manager} dlx ${args}`;
146
- }
147
- case "ci": {
148
- // `npm ci` maps to a frozen, lockfile-faithful install elsewhere. Yarn
149
- // Berry's flag is `--immutable` (`--frozen-lockfile` was removed in
150
- // Yarn 4), matching the Berry-only `yarn dlx` the `exec` case emits.
151
- if (manager === "npm") {
152
- return "npm ci";
153
- }
154
- if (manager === "yarn") {
155
- return "yarn install --immutable";
156
- }
157
- return `${manager} install --frozen-lockfile`;
158
- }
159
- case "remove": {
160
- if (manager === "npm") {
161
- return `npm uninstall ${args}`;
162
- }
163
- // Yarn Classic has no `remove -g`; the global form is `yarn global remove`.
164
- if (manager === "yarn" && intent.args.some((a) => GLOBAL_FLAGS.has(a))) {
165
- const pkgs = intent.args.filter((a) => !GLOBAL_FLAGS.has(a)).join(" ");
166
- return `yarn global remove ${pkgs}`;
167
- }
168
- return `${manager} remove ${args}`;
169
- }
170
- case "run": {
171
- return `${manager} run ${args}`;
172
- }
173
- default: {
174
- return `${manager} install`;
175
- }
154
+ // The tables carry no `create`; every manager spells it the same way.
155
+ if (intent.operation === "create") {
156
+ return `${manager} create ${intent.args.join(" ")}`;
157
+ }
158
+ // A `-g`/`--global` flag selects the dedicated global command (the tables
159
+ // place the flag themselves, per manager).
160
+ const global =
161
+ (intent.operation === "add" || intent.operation === "remove") &&
162
+ intent.args.some((arg) => GLOBAL_FLAGS.has(arg));
163
+ const args = global
164
+ ? intent.args.filter((arg) => !GLOBAL_FLAGS.has(arg))
165
+ : intent.args;
166
+ const command: Command = global
167
+ ? (intent.operation === "add" && "global") || "global_uninstall"
168
+ : COMMAND_FOR[intent.operation];
169
+ // Non-null: every operation above maps to a command each supported agent's
170
+ // table defines (null is only possible for gaps like npm's
171
+ // upgrade-interactive, which no Operation reaches).
172
+ const resolved = resolveCommand(AGENT_FOR[manager], command, args);
173
+ const words = resolved
174
+ ? [resolved.command, ...resolved.args]
175
+ : [manager, ...args];
176
+ // Docs favor the explicit spellings over the tables' terse aliases.
177
+ if (words[1] === "i") {
178
+ words[1] = "install";
179
+ }
180
+ if (words[0] === "bun" && words[1] === "x") {
181
+ words.splice(0, 2, "bunx");
176
182
  }
183
+ return words.join(" ");
177
184
  };
178
185
 
179
186
  /**
package/src/og/card.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ import { imageSize } from "image-size";
3
4
  import { render } from "takumi-js";
4
5
  import type { RenderOptions } from "takumi-js";
5
6
  import { container, googleFonts, image, text } from "takumi-js/helpers";
6
7
  import type { FontSubset, GoogleFontFamily, Node } from "takumi-js/helpers";
7
8
 
9
+ import { ACCENTS } from "../theme/palette.ts";
8
10
  import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "./dimensions.ts";
9
11
 
10
12
  /** A local font file registered with the OG card renderer, read at build. */
@@ -54,23 +56,15 @@ export interface OgFontFamilies {
54
56
  title?: string;
55
57
  }
56
58
 
57
- const ACCENT_HEX: Record<string, string> = {
58
- blue: "#3b82f6",
59
- green: "#22c55e",
60
- orange: "#f97316",
61
- pink: "#ec4899",
62
- purple: "#8b5cf6",
63
- red: "#ef4444",
64
- teal: "#14b8a6",
65
- };
66
-
67
- // Named presets map to Blume's palette hex (the preset "blue" is not CSS
68
- // blue); anything else is handed to Takumi as-is — it parses the full CSS
69
- // color grammar, and a genuinely malformed value fails the build with a
70
- // parse error naming it. `hasOwn` keeps a preset name like "constructor"
71
- // from resolving up the prototype chain.
59
+ // Named presets resolve from the theme's own OKLCH table — Takumi parses the
60
+ // full CSS color grammar, so the card renders exactly the accent the site
61
+ // shows (a separate hand-synced hex palette used to drift: the card's "blue"
62
+ // was Tailwind's, not Blume's). Anything else is handed to Takumi as-is, and
63
+ // a genuinely malformed value fails the build with a parse error naming it.
64
+ // `hasOwn` keeps a preset name like "constructor" from resolving up the
65
+ // prototype chain.
72
66
  const resolveAccent = (accent: string): string =>
73
- Object.hasOwn(ACCENT_HEX, accent) ? (ACCENT_HEX[accent] as string) : accent;
67
+ Object.hasOwn(ACCENTS, accent) ? (ACCENTS[accent] as string) : accent;
74
68
 
75
69
  export interface OgCardPalette {
76
70
  accent?: string;
@@ -219,17 +213,21 @@ export const truncate = (value: string, max: number): string => {
219
213
  // at full height — it stands alone as the brand (no text label beside it).
220
214
  const MARK_HEIGHT = 32;
221
215
  const MARK_MAX_WIDTH = 240;
222
- // Accept either quote style and a non-zero min-x/min-y; only width/height
223
- // matter for the aspect ratio. A miss falls back to a square mark.
224
- const VIEW_BOX =
225
- /viewBox=(?<q>["'])[\d.-]+[\s,]+[\d.-]+[\s,]+(?<w>[\d.]+)[\s,]+(?<h>[\d.]+)\k<q>/u;
226
-
227
- /** The SVG's viewBox aspect ratio (w/h), or null without a usable viewBox. */
216
+ /**
217
+ * The SVG's aspect ratio (w/h), or null when no usable dimensions exist (the
218
+ * caller falls back to a square mark). image-size (already a dependency)
219
+ * reads explicit width/height and falls back to the viewBox, tolerating the
220
+ * quote/whitespace/attribute spellings the old regex silently missed —
221
+ * `viewBox = "…"`, newline-separated values which shipped visibly-squashed
222
+ * marks instead of failing loudly.
223
+ */
228
224
  const logoAspect = (svg: string): number | null => {
229
- const box = svg.match(VIEW_BOX);
230
- const w = Number(box?.groups?.w);
231
- const h = Number(box?.groups?.h);
232
- return w && h ? w / h : null;
225
+ try {
226
+ const { height, width } = imageSize(Buffer.from(svg));
227
+ return width && height ? width / height : null;
228
+ } catch {
229
+ return null;
230
+ }
233
231
  };
234
232
 
235
233
  // Render the configured logo as the brand mark. A `currentColor` logo carries
@@ -4,6 +4,13 @@ import type {
4
4
  PathItemObject,
5
5
  } from "@scalar/openapi-types/3.1";
6
6
 
7
+ import { slugify } from "./references.ts";
8
+
9
+ // The slug rules live with the reference resolver so operation routes and
10
+ // reference-source tokens can never drift apart; re-exported for existing
11
+ // importers.
12
+ export { slugify } from "./references.ts";
13
+
7
14
  /**
8
15
  * Blume's own OpenAPI model. Specs are parsed and upgraded to 3.1 (see
9
16
  * `parse.ts`) with internal `$ref`s left intact — the document stays
@@ -16,13 +23,6 @@ import type {
16
23
  /** A normalized OpenAPI 3.1 document, internal `$ref`s intact. */
17
24
  export type ApiDocument = Document;
18
25
 
19
- const NON_SLUG = /[^a-z0-9]+/gu;
20
- const SLUG_EDGES = /^-+|-+$/gu;
21
-
22
- /** Lowercase, URL-safe slug: `Add a Pet!` -> `add-a-pet`. */
23
- export const slugify = (text: string): string =>
24
- text.toLowerCase().replace(NON_SLUG, "-").replace(SLUG_EDGES, "");
25
-
26
26
  /** The HTTP methods an OpenAPI path item may declare, in spec order. */
27
27
  export const HTTP_METHODS = [
28
28
  "get",
@@ -103,8 +103,8 @@ const isOperation = (value: unknown): value is OperationObject =>
103
103
 
104
104
  /**
105
105
  * Assign each distinct tag name a unique slug. `slugify` can collapse
106
- * different names onto one value — any two all-non-ASCII tags (`ペット`,
107
- * `注文`) both fall through to the `operations` fallback — and a shared slug
106
+ * different names onto one value — any two punctuation-only tags (`!!!`,
107
+ * `???`) both fall through to the `operations` fallback — and a shared slug
108
108
  * silently merges the tags' routes, sidebar groups, and overview sections.
109
109
  * Collisions gain `-2`, `-3`, … in first-seen order.
110
110
  */
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
3
 
4
4
  import { normalize, upgrade } from "@scalar/openapi-parser";
5
+ import pRetry, { AbortError } from "p-retry";
5
6
  import { isAbsolute, join } from "pathe";
6
7
 
7
8
  import { hashText } from "../core/sources/cache.ts";
@@ -95,13 +96,23 @@ const ensureProxyDispatcher = async (): Promise<void> => {
95
96
  }
96
97
  };
97
98
 
98
- /** `Retry-After` in ms when the server sent a sane one, else undefined. */
99
+ /**
100
+ * `Retry-After` in ms when the server sent a sane one, else undefined. RFC
101
+ * 9110 allows both forms: delta-seconds (`120`) and an HTTP-date (`Wed, 21
102
+ * Oct 2015 07:28:00 GMT`); the date form arrives from CDN rate limiters and
103
+ * was previously ignored.
104
+ */
99
105
  const retryAfterMs = (response: Response): number | undefined => {
100
106
  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;
107
+ if (!header) {
108
+ return undefined;
109
+ }
110
+ const seconds = Number(header);
111
+ if (Number.isFinite(seconds)) {
112
+ return seconds > 0 ? seconds * SECOND_MS : undefined;
113
+ }
114
+ const delta = Date.parse(header) - Date.now();
115
+ return Number.isFinite(delta) && delta > 0 ? delta : undefined;
105
116
  };
106
117
 
107
118
  /** One fetch attempt, normalized: the body text, or a (maybe-retryable) error. */
@@ -137,33 +148,63 @@ const attemptFetch = async (spec: string): Promise<Attempt> => {
137
148
  }
138
149
  };
139
150
 
151
+ /**
152
+ * A retryable failure, wrapped in a plain Error p-retry never special-cases:
153
+ * it refuses to retry a non-network `TypeError`, and the underlying error's
154
+ * type is the server's choice, not ours. The message is the underlying
155
+ * error's, so the exhaustion throw still reads `spec -> 503 Service
156
+ * Unavailable`.
157
+ */
158
+ type RetryableFetchError = Error & { retryAfter?: number };
159
+
160
+ const retryableFetchError = (
161
+ error: Error,
162
+ retryAfter?: number
163
+ ): RetryableFetchError => {
164
+ const wrapper: RetryableFetchError = new Error(error.message, {
165
+ cause: error,
166
+ });
167
+ wrapper.name = "RetryableFetchError";
168
+ wrapper.retryAfter = retryAfter;
169
+ return wrapper;
170
+ };
171
+
140
172
  /** Fetch a remote spec's text, retrying transient failures with backoff. */
141
173
  const fetchSpecText = async (spec: string): Promise<string> => {
142
174
  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
- );
175
+ return await pRetry(
176
+ async () => {
177
+ const attempt = await attemptFetch(spec);
178
+ if ("text" in attempt) {
179
+ return attempt.text;
180
+ }
181
+ if (!attempt.retryable) {
182
+ // AbortError stops retrying and rethrows the original untouched.
183
+ throw new AbortError(attempt.error);
184
+ }
185
+ throw retryableFetchError(attempt.error, attempt.retryAfter);
186
+ },
187
+ {
188
+ factor: 2,
189
+ maxTimeout: MAX_RETRY_WAIT_MS,
190
+ minTimeout: BASE_BACKOFF_MS,
191
+ // A sane `Retry-After` replaces the exponential backoff rather than
192
+ // stacking on it: p-retry's own (capped) delay still runs after this
193
+ // hook, so only the difference is slept here.
194
+ onFailedAttempt: async (context) => {
195
+ const { retryAfter } = context.error as RetryableFetchError;
196
+ if (retryAfter !== undefined && context.retriesLeft > 0) {
197
+ await sleep(
198
+ Math.max(
199
+ 0,
200
+ Math.min(retryAfter, MAX_RETRY_WAIT_MS) - context.retryDelay
201
+ )
202
+ );
203
+ }
204
+ },
205
+ retries: MAX_ATTEMPTS - 1,
164
206
  }
165
- }
166
- throw last.error;
207
+ );
167
208
  };
168
209
 
169
210
  const cacheFileFor = (cacheDir: string, spec: string): string =>
@@ -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,
@@ -62,18 +66,37 @@ export interface ReferenceSource {
62
66
  collisions?: string[];
63
67
  }
64
68
 
65
- const NON_SLUG = /[^a-z0-9]+/gu;
69
+ // Keep Unicode letters/marks/numbers so diacritics stay in the slug (ASCII-only
70
+ // stripping turned `Größe` into `gr-e`, which the nav humanizer rendered as
71
+ // `Gr E`); `\p{M}` keeps combining marks attached to their base letter, which
72
+ // NFC cannot always compose away (Devanagari vowel signs, Turkish `İ`'s
73
+ // lowercased combining dot).
74
+ const NON_SLUG = /[^\p{L}\p{M}\p{N}]+/gu;
75
+ // Format characters (ZWNJ, ZWJ, bidi controls) separate no words — hyphenating
76
+ // them would split Persian/Indic compounds the way ASCII stripping split
77
+ // `Größe` — so they are dropped, not replaced.
78
+ const FORMAT_CHARS = /\p{Cf}/gu;
79
+ // A combining mark at the start of the slug has no base letter to attach to
80
+ // (it would glue onto the preceding `/` in a URL), and a marks-only slug must
81
+ // come out empty so callers' fallbacks (`operations`, `reference`) fire.
82
+ const LEADING_MARKS = /^\p{M}+/u;
66
83
 
84
+ /**
85
+ * Lowercase, hyphen-separated slug: `Add a Pet!` -> `add-a-pet`. Unicode
86
+ * letters are kept (`Größe` -> `größe`), NFC-normalized so canonically
87
+ * equivalent spellings (NFD input from macOS tooling) land on one slug.
88
+ * Non-ASCII slugs rely on the emitter percent-encoding the URL where a raw
89
+ * URI is required (sitemap, canonical).
90
+ */
67
91
  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
- };
92
+ trimChar(
93
+ text
94
+ .normalize("NFC")
95
+ .toLowerCase()
96
+ .replace(FORMAT_CHARS, "")
97
+ .replace(NON_SLUG, "-"),
98
+ "-"
99
+ ).replace(LEADING_MARKS, "");
77
100
 
78
101
  /** A stable per-reference token from its route: `/api/events` -> `api-events`. */
79
102
  const routeSlug = (route: string): string =>
@@ -1,3 +1,7 @@
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
+
1
5
  import type { ApiOperationRef, ApiSpecData } from "./model.ts";
2
6
  import type { ReferenceSource } from "./references.ts";
3
7
 
@@ -27,16 +31,6 @@ const ENTITIES: Record<string, string> = {
27
31
  // SDK…" is common spec prose). Entity-escape the keyword's first letter so the
28
32
  // construct can't match; it still renders as the literal word.
29
33
  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
34
  const escapeProse = (text: string): string =>
41
35
  text
42
36
  .replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char)
@@ -45,15 +39,57 @@ const escapeProse = (text: string): string =>
45
39
  (keyword) => `&#${keyword.codePointAt(0)};${keyword.slice(1)}`
46
40
  );
47
41
 
48
- /** Escape MDX-special syntax in prose while leaving backtick code verbatim. */
42
+ /**
43
+ * The source offset ranges of code constructs — inline spans and fences —
44
+ * that MDX treats as literal (entities are NOT decoded inside them, so
45
+ * escaping there would render `/pets/&#123;petId&#125;` verbatim). The ranges
46
+ * come from a CommonMark parse rather than fence-emulating regexes: the
47
+ * parser is the authority on equal-length backtick pairing, longer tilde
48
+ * closers, unclosed fences running to EOF, and fences nested in blockquotes —
49
+ * each of which the replaced regexes had to re-derive (two with a recorded
50
+ * bug history in this file).
51
+ *
52
+ * One CommonMark construct is deliberately *not* masked: indented code. MDX
53
+ * disables indented code blocks, so a 4-space-indented sample is a paragraph
54
+ * whose braces genuinely need escaping; fence-or-backtick is told apart from
55
+ * indentation by the construct's first character.
56
+ */
57
+ const codeSpans = (text: string): [number, number][] => {
58
+ const spans: [number, number][] = [];
59
+ const collect = (node: Nodes): void => {
60
+ if (node.type === "inlineCode" || node.type === "code") {
61
+ // fromMarkdown always stamps positions; -1 is an unreachable guard.
62
+ const start = node.position?.start.offset ?? -1;
63
+ const end = node.position?.end.offset ?? -1;
64
+ const head = text.slice(Math.max(start, 0), Math.max(end, 0)).trimStart();
65
+ if (
66
+ start >= 0 &&
67
+ (node.type === "inlineCode" ||
68
+ head.startsWith("`") ||
69
+ head.startsWith("~"))
70
+ ) {
71
+ spans.push([start, end]);
72
+ }
73
+ return;
74
+ }
75
+ if ("children" in node) {
76
+ for (const child of node.children) {
77
+ collect(child);
78
+ }
79
+ }
80
+ };
81
+ collect(fromMarkdown(text));
82
+ return spans;
83
+ };
84
+
85
+ /** Escape MDX-special syntax in prose while leaving code verbatim. */
49
86
  const mdxSafe = (text: string): string => {
50
87
  let out = "";
51
88
  let cursor = 0;
52
- for (const match of text.matchAll(BACKTICK_CODE)) {
53
- const start = match.index ?? 0;
89
+ for (const [start, end] of codeSpans(text)) {
54
90
  out += escapeProse(text.slice(cursor, start));
55
- out += match[0];
56
- cursor = start + match[0].length;
91
+ out += text.slice(start, end);
92
+ cursor = end;
57
93
  }
58
94
  return out + escapeProse(text.slice(cursor));
59
95
  };
@@ -70,19 +106,22 @@ export interface RenderedPage {
70
106
  // `description`: the prose already renders in the body, and a `description`
71
107
  // frontmatter field would print it a second time as the page subtitle.
72
108
  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
109
  const WHITESPACE = /\s+/gu;
77
110
  const TRAILING_WORD = /\s+\S*$/u;
78
111
 
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();
112
+ /**
113
+ * Flatten markdown prose to its first paragraph as single-line plain text,
114
+ * via a real parse (`mdast-util-to-string`). The regex strip this replaces
115
+ * was lossy on literal prose — `snake_case` → `snakecase`, `C#` → `C` — and
116
+ * these strings ship as `seo.description` meta tags. A description with no
117
+ * paragraph (say, only a heading or list) falls back to its first block.
118
+ */
119
+ const plainProse = (markdown: string): string => {
120
+ const tree = fromMarkdown(markdown);
121
+ const first =
122
+ tree.children.find((node) => node.type === "paragraph") ?? tree.children[0];
123
+ return first ? mdastToString(first).replace(WHITESPACE, " ").trim() : "";
124
+ };
86
125
 
87
126
  /** Cap `text` at `max` characters, cutting on a word boundary. */
88
127
  const clip = (text: string, max: number): string => {