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.
- package/CHANGELOG.md +54 -0
- package/dist/cli/index.js +694 -579
- package/dist/cli/index.js.map +56 -55
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +8 -0
- package/dist/types/core/schema.d.ts +4 -0
- package/dist/types/core/sources/types.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +8 -2
- package/docs/configuration/ai.mdx +26 -8
- package/docs/content/sources.mdx +1 -1
- package/package.json +11 -1
- package/src/ai/agent-readability.ts +3 -2
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +45 -12
- package/src/ai/llms.ts +2 -1
- package/src/ai/mcp/discovery.ts +25 -6
- package/src/ai/mcp/server.ts +108 -98
- package/src/ai/tar.ts +29 -70
- package/src/astro/examples.ts +7 -3
- package/src/astro/generate.ts +59 -34
- package/src/astro/islands.ts +7 -3
- package/src/astro/templates.ts +34 -9
- package/src/audit/agent.ts +14 -29
- package/src/audit/crawl.ts +41 -16
- package/src/audit/run.ts +10 -3
- package/src/audit/snapshot.ts +27 -2
- package/src/cli/commands/audit.ts +12 -17
- package/src/cli/commands/build.ts +15 -7
- package/src/cli/commands/dev.ts +13 -15
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +17 -27
- package/src/cli/env.ts +13 -30
- package/src/cli/init/scaffold.ts +21 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- package/src/components/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +78 -9
- package/src/components/layout/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +6 -48
- package/src/components/layout/Search.astro +56 -9
- package/src/components/layout/drawer-inert.ts +31 -0
- package/src/components/layout/search/pagefind.ts +6 -5
- package/src/components/layout/search/types.ts +32 -0
- package/src/components/openapi/panel.ts +11 -8
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/config-input.ts +8 -0
- package/src/core/frontmatter.ts +45 -1
- package/src/core/probe.ts +7 -19
- package/src/core/project-graph.ts +12 -1
- package/src/core/schema.ts +6 -0
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/cache.ts +10 -8
- package/src/core/sources/github-releases.ts +21 -1
- package/src/core/sources/normalize.ts +26 -2
- package/src/core/sources/notion.ts +27 -5
- package/src/core/sources/portable-text.ts +16 -1
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/types.ts +13 -1
- package/src/deploy/cloudflare-negotiation.ts +15 -1
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +56 -7
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +1 -14
- package/src/markdown/package-commands.ts +61 -54
- package/src/og/card.ts +24 -26
- package/src/openapi/model.ts +9 -9
- package/src/openapi/parse.ts +69 -28
- package/src/openapi/references.ts +35 -12
- package/src/openapi/render-mdx.ts +64 -25
- package/src/openapi/scalar.ts +2 -2
- package/src/openapi/source.ts +28 -1
- package/src/search/documents.ts +78 -34
- package/src/search/orama-index.ts +51 -12
- package/src/theme/palette.ts +6 -2
- package/src/translate/ledger.ts +4 -2
- package/src/translate/report.ts +1 -14
- package/src/translate/run.ts +20 -35
- package/src/cli/coalesce.ts +0 -43
package/src/eval/agents.ts
CHANGED
|
@@ -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
|
|
85
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
83
86
|
stdout += chunk.toString("utf-8");
|
|
84
87
|
});
|
|
85
|
-
child.stderr
|
|
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
|
|
118
|
+
child.stdin?.end(options.prompt);
|
|
116
119
|
});
|
|
117
120
|
|
|
118
121
|
/** The spawn signature `runEval` accepts, injectable for tests. */
|
package/src/eval/report.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
package/src/openapi/model.ts
CHANGED
|
@@ -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
|
|
107
|
-
*
|
|
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
|
*/
|
package/src/openapi/parse.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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/{petId}`). 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
|
-
/**
|
|
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/{petId}` 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
|
|
53
|
-
const start = match.index ?? 0;
|
|
89
|
+
for (const [start, end] of codeSpans(text)) {
|
|
54
90
|
out += escapeProse(text.slice(cursor, start));
|
|
55
|
-
out +=
|
|
56
|
-
cursor =
|
|
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
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 => {
|