blume 0.5.2 → 0.5.4
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/dist/cli/index.js +2235 -5942
- package/dist/cli/index.js.map +36 -65
- package/dist/types/core/config.d.ts +0 -8
- package/dist/types/core/data.d.ts +2 -2
- package/dist/types/core/schema.d.ts +58 -453
- package/dist/types/core/types.d.ts +1 -7
- package/docs/advanced/meta.ts +1 -8
- package/docs/advanced/skills.mdx +28 -0
- package/docs/configuration/ai.mdx +3 -1
- package/docs/configuration/index.mdx +13 -17
- package/docs/configuration/theming.mdx +3 -11
- package/docs/content/components.mdx +2 -53
- package/docs/content/i18n.mdx +1 -5
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +1 -1
- package/docs/index.mdx +0 -1
- package/docs/reference/cli.mdx +12 -13
- package/package.json +4 -6
- package/skills/blume/SKILL.md +71 -0
- package/skills/blume-update-docs/SKILL.md +52 -0
- package/skills/blume-update-docs/references/audit-checklist.md +46 -0
- package/src/ai/mcp/discovery.ts +3 -1
- package/src/ai/mcp/server.ts +3 -1
- package/src/astro/component-slots.ts +10 -2
- package/src/astro/generate.ts +15 -12
- package/src/astro/integration.ts +0 -21
- package/src/astro/templates.ts +62 -37
- package/src/cli/coalesce.ts +43 -0
- package/src/cli/commands/dev.ts +30 -22
- package/src/cli/commands/validate.ts +0 -2
- package/src/cli/dev-lock.ts +4 -2
- package/src/cli/index.ts +0 -2
- package/src/components/BlumePage.astro +0 -6
- package/src/components/Icon.astro +1 -12
- package/src/components/content/AccordionItem.astro +3 -6
- package/src/components/content/Badge.astro +1 -3
- package/src/components/content/Callout.astro +3 -9
- package/src/components/content/Card.astro +2 -3
- package/src/components/content/ColorItem.astro +8 -5
- package/src/components/content/Column.astro +1 -1
- package/src/components/content/Prompt.astro +8 -4
- package/src/components/content/Step.astro +3 -4
- package/src/components/content/Tab.astro +2 -3
- package/src/components/content/Tabs.astro +13 -2
- package/src/components/content/TypeTable.astro +5 -5
- package/src/components/content/Update.astro +1 -1
- package/src/components/content/mermaid-element.ts +20 -2
- package/src/components/islands/ask-ai.tsx +4 -8
- package/src/components/islands/base-path.ts +30 -0
- package/src/components/islands/hooks.ts +12 -8
- package/src/components/layout/Header.astro +26 -3
- package/src/components/layout/Logo.astro +5 -1
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/NavTree.astro +14 -14
- package/src/components/layout/PageActions.astro +17 -11
- package/src/components/layout/PageLayout.astro +42 -0
- package/src/components/layout/ReferenceLayout.astro +1 -0
- package/src/components/layout/RootLayout.astro +31 -2
- package/src/components/layout/Search.astro +9 -6
- package/src/components/layout/nav-utils.ts +9 -4
- package/src/components/layout/search/types.ts +16 -5
- package/src/components/openapi/ApiOverview.astro +4 -50
- package/src/components/openapi/ApiTagOperations.astro +42 -0
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/SchemaProperty.astro +1 -1
- package/src/components/openapi/SchemaTable.astro +3 -3
- package/src/components/openapi/helpers.ts +17 -8
- package/src/components/openapi/snippets.ts +17 -4
- package/src/core/builtin-tags.ts +1 -3
- package/src/core/config.ts +19 -33
- package/src/core/data.ts +2 -2
- package/src/core/graph.ts +7 -5
- package/src/core/links.ts +5 -19
- package/src/core/meta.ts +1 -1
- package/src/core/navigation.ts +5 -6
- package/src/core/project-graph.ts +0 -5
- package/src/core/schema.ts +13 -90
- package/src/core/sources/filesystem.ts +19 -1
- package/src/core/sources/mdx-remote.ts +20 -4
- package/src/core/sources/normalize.ts +28 -6
- package/src/core/sources/resolve.ts +0 -13
- package/src/core/sources/watch.ts +45 -0
- package/src/core/types.ts +1 -8
- package/src/markdown/code-title.ts +6 -3
- package/src/markdown/package-install.ts +3 -1
- package/src/og/card.ts +16 -4
- package/src/openapi/parse.ts +197 -14
- package/src/openapi/render-mdx.ts +54 -11
- package/src/openapi/source.ts +19 -2
- package/src/search/sync/orama-cloud.ts +2 -0
- package/src/search/sync/typesense.ts +4 -0
- package/src/theme/entry.ts +45 -17
- package/src/theme/icons.ts +22 -104
- package/src/theme/palette.ts +35 -47
- package/src/theme/twoslash.ts +6 -1
- package/dist/types/core/bridge.d.ts +0 -24
- package/dist/types/migrate/mintlify/assets.d.ts +0 -8
- package/dist/types/migrate/mintlify/config.d.ts +0 -16
- package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
- package/docs/advanced/bridge.mdx +0 -76
- package/docs/advanced/migrate.mdx +0 -123
- package/src/astro/static-assets.ts +0 -117
- package/src/cli/commands/migrate.ts +0 -39
- package/src/components/content/ApiField.astro +0 -75
- package/src/components/content/ParamField.astro +0 -39
- package/src/components/content/RequestField.astro +0 -23
- package/src/components/content/ResponseField.astro +0 -23
- package/src/components/content/Warning.astro +0 -9
- package/src/core/assets.ts +0 -31
- package/src/core/bridge.ts +0 -102
- package/src/core/sources/mintlify.ts +0 -204
- package/src/migrate/fumadocs/config.ts +0 -155
- package/src/migrate/fumadocs/content.ts +0 -378
- package/src/migrate/fumadocs/frontmatter.ts +0 -18
- package/src/migrate/fumadocs/groups.ts +0 -237
- package/src/migrate/fumadocs/index.ts +0 -340
- package/src/migrate/fumadocs/meta.ts +0 -244
- package/src/migrate/migrate.ts +0 -53
- package/src/migrate/mintlify/assets.ts +0 -46
- package/src/migrate/mintlify/config.ts +0 -958
- package/src/migrate/mintlify/content.ts +0 -120
- package/src/migrate/mintlify/frontmatter.ts +0 -126
- package/src/migrate/mintlify/i18n.ts +0 -51
- package/src/migrate/mintlify/icons.ts +0 -128
- package/src/migrate/mintlify/index.ts +0 -372
- package/src/migrate/mintlify/snippets.ts +0 -306
- package/src/migrate/mintlify/transform.ts +0 -82
- package/src/migrate/nextra/content.ts +0 -46
- package/src/migrate/nextra/frontmatter.ts +0 -40
- package/src/migrate/nextra/index.ts +0 -374
- package/src/migrate/nextra/meta.ts +0 -266
- package/src/migrate/shared.ts +0 -728
- package/src/migrate/starlight/config.ts +0 -455
- package/src/migrate/starlight/content.ts +0 -78
- package/src/migrate/starlight/frontmatter.ts +0 -111
- package/src/migrate/starlight/i18n.ts +0 -54
- package/src/migrate/starlight/index.ts +0 -131
package/src/core/types.ts
CHANGED
|
@@ -173,24 +173,17 @@ export interface NavSelectorItem {
|
|
|
173
173
|
tag?: string;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
/** Top-level
|
|
176
|
+
/** Top-level partition selectors (products, versions, languages). */
|
|
177
177
|
export interface NavSelector {
|
|
178
178
|
label: string;
|
|
179
179
|
kind: "dropdown" | "language" | "product" | "version";
|
|
180
180
|
items: NavSelectorItem[];
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
/** Chrome overrides used when the current route belongs to a nav partition. */
|
|
184
|
-
export interface NavChromeVariant {
|
|
185
|
-
path: string;
|
|
186
|
-
banner?: ResolvedConfig["banner"];
|
|
187
|
-
}
|
|
188
|
-
|
|
189
183
|
/** The complete navigation model derived from the content graph. */
|
|
190
184
|
export interface Navigation {
|
|
191
185
|
tabs: NavTab[];
|
|
192
186
|
selectors: NavSelector[];
|
|
193
|
-
chromeVariants: NavChromeVariant[];
|
|
194
187
|
sidebar: NavNode[];
|
|
195
188
|
/** Repo URL for the header link, or null when hidden (`navigation.repo`). */
|
|
196
189
|
repoUrl?: string | null;
|
|
@@ -25,7 +25,9 @@ export interface CodeTitleTransformer {
|
|
|
25
25
|
pre: (this: CodeMetaContext, node: PreNode) => void;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// The body excludes only the delimiting quote, so `title="foo's file.ts"`
|
|
29
|
+
// (an apostrophe inside double quotes) still matches.
|
|
30
|
+
const TITLE_ATTR = /title=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/u;
|
|
29
31
|
const LINE_NUMBERS = /(?:^|\s)lineNumbers(?=\s|$)/u;
|
|
30
32
|
|
|
31
33
|
const parseTitle = (raw: string | undefined): string | undefined => {
|
|
@@ -33,8 +35,9 @@ const parseTitle = (raw: string | undefined): string | undefined => {
|
|
|
33
35
|
return undefined;
|
|
34
36
|
}
|
|
35
37
|
const explicit = raw.match(TITLE_ATTR);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
const attrTitle = explicit?.groups?.dq ?? explicit?.groups?.sq;
|
|
39
|
+
if (attrTitle) {
|
|
40
|
+
return attrTitle;
|
|
38
41
|
}
|
|
39
42
|
// The first bare token is the title (```ts blume.config.ts), skipping Shiki
|
|
40
43
|
// line ranges (`{1,3-5}`), `key=value` attrs, and the reserved `lineNumbers`
|
|
@@ -31,7 +31,9 @@ export const packageInstallPlugin = () => ({
|
|
|
31
31
|
node,
|
|
32
32
|
jsxFlowElement(
|
|
33
33
|
"Tabs",
|
|
34
|
-
|
|
34
|
+
// hash off: clicking "pnpm" in an install block must not rewrite the
|
|
35
|
+
// page hash (clobbering the heading anchor the reader arrived with).
|
|
36
|
+
[jsxAttribute("hash", "false")],
|
|
35
37
|
PACKAGE_MANAGERS.map((manager) => tabNode(manager, commands[manager]))
|
|
36
38
|
)
|
|
37
39
|
);
|
package/src/og/card.ts
CHANGED
|
@@ -19,10 +19,19 @@ const ACCENT_HEX: Record<string, string> = {
|
|
|
19
19
|
teal: "#14b8a6",
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
|
|
23
|
+
|
|
22
24
|
// OG rendering uses hex (Takumi's color parser does not accept oklch); named
|
|
23
|
-
// presets map to hex,
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
// presets map to hex, well-formed hex passes through, anything else falls
|
|
26
|
+
// back — a malformed hex (`#12345` typo) would throw inside Takumi and fail
|
|
27
|
+
// the build at OG prerender with an opaque native error. `hasOwn` keeps a
|
|
28
|
+
// preset name like "constructor" from resolving up the prototype chain.
|
|
29
|
+
const resolveAccent = (accent: string): string => {
|
|
30
|
+
if (Object.hasOwn(ACCENT_HEX, accent)) {
|
|
31
|
+
return ACCENT_HEX[accent] as string;
|
|
32
|
+
}
|
|
33
|
+
return HEX_COLOR.test(accent) ? accent : "#3b82f6";
|
|
34
|
+
};
|
|
26
35
|
|
|
27
36
|
export interface OgCardOptions {
|
|
28
37
|
/** Large headline — the page title. */
|
|
@@ -75,7 +84,10 @@ export const truncate = (value: string, max: number): string => {
|
|
|
75
84
|
// stays within the lockup.
|
|
76
85
|
const MARK_HEIGHT = 32;
|
|
77
86
|
const MARK_MAX_WIDTH = 100;
|
|
78
|
-
|
|
87
|
+
// Accept either quote style and a non-zero min-x/min-y; only width/height
|
|
88
|
+
// matter for the aspect ratio. A miss falls back to a square mark.
|
|
89
|
+
const VIEW_BOX =
|
|
90
|
+
/viewBox=(?<q>["'])[\d.-]+[\s,]+[\d.-]+[\s,]+(?<w>[\d.]+)[\s,]+(?<h>[\d.]+)\k<q>/u;
|
|
79
91
|
|
|
80
92
|
// Render the configured logo as the brand mark. A `currentColor` logo carries
|
|
81
93
|
// no intrinsic color, so it is painted in the foreground to read on the light
|
package/src/openapi/parse.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
3
|
|
|
3
4
|
import { normalize, upgrade } from "@scalar/openapi-parser";
|
|
4
5
|
import { isAbsolute, join } from "pathe";
|
|
5
6
|
|
|
7
|
+
import { hashText } from "../core/sources/cache.ts";
|
|
6
8
|
import type { ApiDocument } from "./model.ts";
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -10,39 +12,220 @@ import type { ApiDocument } from "./model.ts";
|
|
|
10
12
|
* (`@scalar/openapi-parser`) to read a spec (YAML or JSON), then upgrade Swagger
|
|
11
13
|
* 2.0 / OpenAPI 3.0 documents to 3.1 so the renderer only handles one shape.
|
|
12
14
|
* Internal `$ref`s are deliberately left in place (see `model.ts`).
|
|
15
|
+
*
|
|
16
|
+
* Remote (`http(s)`) specs are fetched defensively — bounded per attempt, retried
|
|
17
|
+
* on transient failures, proxy-aware, and cached on disk — mirroring the
|
|
18
|
+
* resilience the external link checker (`core/links.ts`) and the Notion source
|
|
19
|
+
* (`core/sources/notion.ts`) already have. A bare `fetch` is the classic "curl
|
|
20
|
+
* works but the build doesn't" gap: it ignores `*_PROXY`, has no timeout, and
|
|
21
|
+
* dies on a single cold-start blip.
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
24
|
const URL_SPEC = /^https?:\/\//u;
|
|
16
25
|
|
|
26
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
27
|
+
const MAX_ATTEMPTS = 3;
|
|
28
|
+
const BASE_BACKOFF_MS = 500;
|
|
29
|
+
const SECOND_MS = 1000;
|
|
30
|
+
// Worth another try: request timeout, too-early, rate-limited, and the 5xx range.
|
|
31
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
32
|
+
const USER_AGENT = "blume (+https://github.com/haydenbleasel/blume)";
|
|
33
|
+
const PROXY_ENV_VARS = [
|
|
34
|
+
"HTTP_PROXY",
|
|
35
|
+
"http_proxy",
|
|
36
|
+
"HTTPS_PROXY",
|
|
37
|
+
"https_proxy",
|
|
38
|
+
"ALL_PROXY",
|
|
39
|
+
"all_proxy",
|
|
40
|
+
];
|
|
41
|
+
|
|
17
42
|
export interface ParsedSpec {
|
|
18
43
|
document: ApiDocument;
|
|
19
44
|
warnings: string[];
|
|
20
45
|
}
|
|
21
46
|
|
|
47
|
+
/** Where and whether to cache a remote spec's text between runs. */
|
|
48
|
+
export interface SpecFetchOptions {
|
|
49
|
+
/** Dir for a last-good on-disk copy of a remote spec (offline fallback). */
|
|
50
|
+
cacheDir?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Re-fetch even when a cached copy exists. Builds/sync refresh; dev is
|
|
53
|
+
* cache-first for fast, offline-tolerant restarts (see `SourceContext`).
|
|
54
|
+
*/
|
|
55
|
+
refresh?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Route Node's global `fetch` through an HTTP(S) proxy the first time a remote
|
|
60
|
+
* spec is fetched with one configured. Node's built-in `fetch` ignores `*_PROXY`
|
|
61
|
+
* on its own; undici's env proxy agent, installed on the shared global-dispatcher
|
|
62
|
+
* symbol, wires it in without replacing `fetch` (so tests can still stub it).
|
|
63
|
+
* Best-effort and lazy: no proxy env means no undici import at all, and an
|
|
64
|
+
* unavailable undici just leaves the direct connection in place.
|
|
65
|
+
*/
|
|
66
|
+
let proxyInstalled = false;
|
|
67
|
+
const ensureProxyDispatcher = async (): Promise<void> => {
|
|
68
|
+
// Only memoize a successful install: with no proxy configured we cheaply
|
|
69
|
+
// re-check each time, so a proxy set later in the process still takes effect.
|
|
70
|
+
if (proxyInstalled || !PROXY_ENV_VARS.some((name) => process.env[name])) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import("undici");
|
|
75
|
+
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
76
|
+
proxyInstalled = true;
|
|
77
|
+
} catch {
|
|
78
|
+
// No proxy support available; fall back to a direct connection.
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** `Retry-After` in ms when the server sent a sane one, else undefined. */
|
|
83
|
+
const retryAfterMs = (response: Response): number | undefined => {
|
|
84
|
+
const header = response.headers.get("retry-after");
|
|
85
|
+
const seconds = header ? Number(header) : Number.NaN;
|
|
86
|
+
return Number.isFinite(seconds) && seconds > 0
|
|
87
|
+
? seconds * SECOND_MS
|
|
88
|
+
: undefined;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** One fetch attempt, normalized: the body text, or a (maybe-retryable) error. */
|
|
92
|
+
type Attempt =
|
|
93
|
+
| { text: string }
|
|
94
|
+
| { error: Error; retryable: boolean; retryAfter?: number };
|
|
95
|
+
|
|
96
|
+
const attemptFetch = async (spec: string): Promise<Attempt> => {
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(spec, {
|
|
99
|
+
headers: {
|
|
100
|
+
accept: "application/json, application/yaml, text/yaml, */*",
|
|
101
|
+
"user-agent": USER_AGENT,
|
|
102
|
+
},
|
|
103
|
+
redirect: "follow",
|
|
104
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
105
|
+
});
|
|
106
|
+
if (response.ok) {
|
|
107
|
+
return { text: await response.text() };
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
error: new Error(`${spec} -> ${response.status} ${response.statusText}`),
|
|
111
|
+
retryAfter: retryAfterMs(response),
|
|
112
|
+
retryable: RETRYABLE_STATUS.has(response.status),
|
|
113
|
+
};
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// Network error, DNS/TLS failure, or an aborted (timed-out) request — all
|
|
116
|
+
// transient by nature, so worth a retry.
|
|
117
|
+
return {
|
|
118
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
119
|
+
retryable: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Fetch a remote spec's text, retrying transient failures with backoff. */
|
|
125
|
+
const fetchSpecText = async (spec: string): Promise<string> => {
|
|
126
|
+
await ensureProxyDispatcher();
|
|
127
|
+
let last: Attempt = {
|
|
128
|
+
error: new Error(`Could not fetch ${spec}`),
|
|
129
|
+
retryable: false,
|
|
130
|
+
};
|
|
131
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
|
132
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential retry attempts
|
|
133
|
+
last = await attemptFetch(spec);
|
|
134
|
+
if ("text" in last) {
|
|
135
|
+
return last.text;
|
|
136
|
+
}
|
|
137
|
+
if (!last.retryable || attempt === MAX_ATTEMPTS - 1) {
|
|
138
|
+
throw last.error;
|
|
139
|
+
}
|
|
140
|
+
// oxlint-disable-next-line no-await-in-loop -- back off before retrying
|
|
141
|
+
await sleep(last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt);
|
|
142
|
+
}
|
|
143
|
+
throw last.error;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const cacheFileFor = (cacheDir: string, spec: string): string =>
|
|
147
|
+
join(cacheDir, `spec-${hashText(spec)}.cache`);
|
|
148
|
+
|
|
149
|
+
const readCache = async (file: string): Promise<string | undefined> => {
|
|
150
|
+
try {
|
|
151
|
+
return await readFile(file, "utf-8");
|
|
152
|
+
} catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const writeCache = async (
|
|
158
|
+
dir: string,
|
|
159
|
+
file: string,
|
|
160
|
+
text: string
|
|
161
|
+
): Promise<void> => {
|
|
162
|
+
try {
|
|
163
|
+
await mkdir(dir, { recursive: true });
|
|
164
|
+
await writeFile(file, text, "utf-8");
|
|
165
|
+
} catch {
|
|
166
|
+
// Best-effort cache; a write failure must not fail the load.
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
22
170
|
/** Read a spec's raw text from an `http(s)` URL or a local (project-relative) path. */
|
|
23
|
-
const readSpecText = async (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
171
|
+
const readSpecText = async (
|
|
172
|
+
spec: string,
|
|
173
|
+
root: string,
|
|
174
|
+
options: SpecFetchOptions
|
|
175
|
+
): Promise<{ text: string; warnings: string[] }> => {
|
|
176
|
+
if (!URL_SPEC.test(spec)) {
|
|
177
|
+
const absolute = isAbsolute(spec) ? spec : join(root, spec);
|
|
178
|
+
return { text: await readFile(absolute, "utf-8"), warnings: [] };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const cacheFile = options.cacheDir
|
|
182
|
+
? cacheFileFor(options.cacheDir, spec)
|
|
183
|
+
: undefined;
|
|
184
|
+
|
|
185
|
+
// Cache-first in dev: serve the last-good snapshot without touching the network.
|
|
186
|
+
if (cacheFile && options.refresh === false) {
|
|
187
|
+
const cached = await readCache(cacheFile);
|
|
188
|
+
if (cached !== undefined) {
|
|
189
|
+
return { text: cached, warnings: [] };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const text = await fetchSpecText(spec);
|
|
195
|
+
if (options.cacheDir && cacheFile) {
|
|
196
|
+
await writeCache(options.cacheDir, cacheFile, text);
|
|
197
|
+
}
|
|
198
|
+
return { text, warnings: [] };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// A transient outage falls back to the last good fetch, with a warning.
|
|
201
|
+
if (cacheFile) {
|
|
202
|
+
const cached = await readCache(cacheFile);
|
|
203
|
+
if (cached !== undefined) {
|
|
204
|
+
return {
|
|
205
|
+
text: cached,
|
|
206
|
+
warnings: [
|
|
207
|
+
`Could not fetch ${spec} (${(error as Error).message}); using the last cached copy.`,
|
|
208
|
+
],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
28
211
|
}
|
|
29
|
-
|
|
212
|
+
throw error;
|
|
30
213
|
}
|
|
31
|
-
const absolute = isAbsolute(spec) ? spec : join(root, spec);
|
|
32
|
-
return await readFile(absolute, "utf-8");
|
|
33
214
|
};
|
|
34
215
|
|
|
35
216
|
/**
|
|
36
217
|
* Read, normalize, and upgrade a spec to an OpenAPI 3.1 document. Throws when the
|
|
37
|
-
* spec can't be read; callers turn that into a source
|
|
38
|
-
*
|
|
218
|
+
* spec can't be read and no cache is available; callers turn that into a source
|
|
219
|
+
* diagnostic (an error in build, a warning in dev) rather than a hard failure so
|
|
220
|
+
* a broken spec doesn't take down the whole build.
|
|
39
221
|
*/
|
|
40
222
|
export const parseSpec = async (
|
|
41
223
|
spec: string,
|
|
42
|
-
root: string
|
|
224
|
+
root: string,
|
|
225
|
+
options: SpecFetchOptions = {}
|
|
43
226
|
): Promise<ParsedSpec> => {
|
|
44
|
-
const text = await readSpecText(spec, root);
|
|
227
|
+
const { text, warnings } = await readSpecText(spec, root, options);
|
|
45
228
|
const normalized = normalize(text);
|
|
46
229
|
const { specification } = upgrade(normalized);
|
|
47
|
-
return { document: specification as ApiDocument, warnings
|
|
230
|
+
return { document: specification as ApiDocument, warnings };
|
|
48
231
|
};
|
|
@@ -21,8 +21,17 @@ const ENTITIES: Record<string, string> = {
|
|
|
21
21
|
"{": "{",
|
|
22
22
|
"}": "}",
|
|
23
23
|
};
|
|
24
|
+
// MDX also parses lines starting with `import`/`export` as ESM ("import the
|
|
25
|
+
// SDK…" is common spec prose). Entity-escape the keyword's first letter so the
|
|
26
|
+
// construct can't match; it still renders as the literal word.
|
|
27
|
+
const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
|
|
24
28
|
const mdxSafe = (text: string): string =>
|
|
25
|
-
text
|
|
29
|
+
text
|
|
30
|
+
.replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char)
|
|
31
|
+
.replace(
|
|
32
|
+
MDX_ESM_KEYWORD,
|
|
33
|
+
(keyword) => `&#${keyword.codePointAt(0)};${keyword.slice(1)}`
|
|
34
|
+
);
|
|
26
35
|
|
|
27
36
|
/** Frontmatter + body for one operation or overview page. */
|
|
28
37
|
export interface RenderedPage {
|
|
@@ -64,13 +73,47 @@ export const operationMdx = (
|
|
|
64
73
|
};
|
|
65
74
|
};
|
|
66
75
|
|
|
67
|
-
export const overviewMdx = (spec: ApiSpecData): RenderedPage =>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
export const overviewMdx = (spec: ApiSpecData): RenderedPage => {
|
|
77
|
+
// Tag sections: declared tags in spec order, then any tag an operation
|
|
78
|
+
// references that isn't declared under `tags`. The section headings are
|
|
79
|
+
// emitted as real markdown `##` (not markup inside a component) so the
|
|
80
|
+
// markdown pipeline gives them ids, permalink anchors, and table-of-contents
|
|
81
|
+
// entries; only the operation-link list defers to a component.
|
|
82
|
+
const operations = Object.values(spec.operations);
|
|
83
|
+
const sections = [...spec.tags];
|
|
84
|
+
const known = new Set(spec.tags.map((tag) => tag.slug));
|
|
85
|
+
for (const operation of operations) {
|
|
86
|
+
if (!known.has(operation.tagSlug)) {
|
|
87
|
+
known.add(operation.tagSlug);
|
|
88
|
+
sections.push({
|
|
89
|
+
description: "",
|
|
90
|
+
name: operation.tag,
|
|
91
|
+
slug: operation.tagSlug,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const tagSections = sections
|
|
96
|
+
.filter((tag) =>
|
|
97
|
+
operations.some((operation) => operation.tagSlug === tag.slug)
|
|
98
|
+
)
|
|
99
|
+
.map((tag) =>
|
|
100
|
+
[
|
|
101
|
+
`## ${mdxSafe(tag.name)}`,
|
|
102
|
+
...(tag.description.trim() ? [mdxSafe(tag.description.trim())] : []),
|
|
103
|
+
`<ApiTagOperations source="${spec.slug}" tag="${tag.slug}" />`,
|
|
104
|
+
].join("\n\n")
|
|
105
|
+
);
|
|
106
|
+
return {
|
|
107
|
+
body: [
|
|
108
|
+
withDescription(
|
|
109
|
+
spec.description,
|
|
110
|
+
`<ApiOverview source="${spec.slug}" />`
|
|
111
|
+
),
|
|
112
|
+
...tagSections,
|
|
113
|
+
].join("\n\n"),
|
|
114
|
+
data: {
|
|
115
|
+
sidebar: { label: "Overview" },
|
|
116
|
+
title: spec.title || spec.label,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
};
|
package/src/openapi/source.ts
CHANGED
|
@@ -69,6 +69,8 @@ interface LoadedSpec {
|
|
|
69
69
|
slug: string;
|
|
70
70
|
spec: ApiSpecData;
|
|
71
71
|
entries: SourceEntry[];
|
|
72
|
+
/** Non-fatal notes from the load (e.g. an offline cache fallback). */
|
|
73
|
+
diagnostics: Diagnostic[];
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
export const openApiSource = (
|
|
@@ -81,7 +83,11 @@ export const openApiSource = (
|
|
|
81
83
|
reference: ReferenceSource
|
|
82
84
|
): Promise<LoadedSpec | Diagnostic> => {
|
|
83
85
|
try {
|
|
84
|
-
const { document } = await parseSpec(
|
|
86
|
+
const { document, warnings } = await parseSpec(
|
|
87
|
+
reference.spec,
|
|
88
|
+
ctx.projectRoot,
|
|
89
|
+
{ cacheDir: ctx.cacheDir, refresh: ctx.refresh }
|
|
90
|
+
);
|
|
85
91
|
const { operations, tags } = extractOperations(document, reference.route);
|
|
86
92
|
const info = document.info ?? { title: reference.label, version: "" };
|
|
87
93
|
const spec: ApiSpecData = {
|
|
@@ -100,6 +106,11 @@ export const openApiSource = (
|
|
|
100
106
|
version: info.version ?? "",
|
|
101
107
|
};
|
|
102
108
|
return {
|
|
109
|
+
diagnostics: warnings.map((message) => ({
|
|
110
|
+
code: "BLUME_OPENAPI_STALE",
|
|
111
|
+
message,
|
|
112
|
+
severity: "warning" as const,
|
|
113
|
+
})),
|
|
103
114
|
entries: specEntries(spec, operations),
|
|
104
115
|
slug: reference.slug,
|
|
105
116
|
spec,
|
|
@@ -108,7 +119,12 @@ export const openApiSource = (
|
|
|
108
119
|
return {
|
|
109
120
|
code: "BLUME_OPENAPI_UNAVAILABLE",
|
|
110
121
|
message: `Could not load OpenAPI spec "${reference.spec}" for ${reference.route} (${(error as Error).message}); its reference pages were skipped.`,
|
|
111
|
-
|
|
122
|
+
// A configured-but-unloadable spec ships a dead nav tab (a 404 route),
|
|
123
|
+
// so fail loudly in build (blocks under --strict) while staying a warning
|
|
124
|
+
// in dev so offline work still runs.
|
|
125
|
+
severity: ctx.mode === "build" ? "error" : "warning",
|
|
126
|
+
suggestion:
|
|
127
|
+
"Check the spec URL/path is reachable from the build environment; behind a proxy, set HTTP(S)_PROXY.",
|
|
112
128
|
};
|
|
113
129
|
}
|
|
114
130
|
};
|
|
@@ -125,6 +141,7 @@ export const openApiSource = (
|
|
|
125
141
|
}
|
|
126
142
|
data[result.slug] = result.spec;
|
|
127
143
|
entries.push(...result.entries);
|
|
144
|
+
diagnostics.push(...result.diagnostics);
|
|
128
145
|
}
|
|
129
146
|
parsed = data;
|
|
130
147
|
return { diagnostics, entries };
|
|
@@ -31,6 +31,8 @@ export const syncOramaCloud = async (
|
|
|
31
31
|
content: record.content,
|
|
32
32
|
description: record.description,
|
|
33
33
|
id: record._id,
|
|
34
|
+
// Carried so an i18n site can filter hosted results per language.
|
|
35
|
+
locale: record.locale,
|
|
34
36
|
tag: record.tag,
|
|
35
37
|
title: record.title,
|
|
36
38
|
url: record.url,
|
|
@@ -54,6 +54,9 @@ export const syncTypesense = async (
|
|
|
54
54
|
{ name: "content", type: "string" },
|
|
55
55
|
{ name: "url", type: "string" },
|
|
56
56
|
{ facet: true, name: "tag", optional: true, type: "string" },
|
|
57
|
+
// Carried as a facet so an i18n site can filter hosted results per
|
|
58
|
+
// language (the SearchRecord contract).
|
|
59
|
+
{ facet: true, name: "locale", optional: true, type: "string" },
|
|
57
60
|
],
|
|
58
61
|
name: config.collection,
|
|
59
62
|
});
|
|
@@ -62,6 +65,7 @@ export const syncTypesense = async (
|
|
|
62
65
|
content: record.content,
|
|
63
66
|
description: record.description,
|
|
64
67
|
id: record._id,
|
|
68
|
+
locale: record.locale,
|
|
65
69
|
tag: record.tag,
|
|
66
70
|
title: record.title,
|
|
67
71
|
url: record.url,
|
package/src/theme/entry.ts
CHANGED
|
@@ -32,9 +32,6 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
|
|
|
32
32
|
|
|
33
33
|
:root {
|
|
34
34
|
--blume-background: oklch(1 0 0);
|
|
35
|
-
--blume-background-decoration: none;
|
|
36
|
-
--blume-background-decoration-repeat: no-repeat;
|
|
37
|
-
--blume-background-decoration-size: auto;
|
|
38
35
|
--blume-background-image: none;
|
|
39
36
|
--blume-background-image-repeat: no-repeat;
|
|
40
37
|
--blume-background-image-size: cover;
|
|
@@ -90,9 +87,6 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
|
|
|
90
87
|
|
|
91
88
|
:root[data-theme="dark"] {
|
|
92
89
|
--blume-background: oklch(0.085 0 0);
|
|
93
|
-
--blume-background-decoration: none;
|
|
94
|
-
--blume-background-decoration-repeat: no-repeat;
|
|
95
|
-
--blume-background-decoration-size: auto;
|
|
96
90
|
--blume-background-image: none;
|
|
97
91
|
--blume-background-image-repeat: no-repeat;
|
|
98
92
|
--blume-background-image-size: cover;
|
|
@@ -134,9 +128,22 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
|
|
|
134
128
|
}
|
|
135
129
|
|
|
136
130
|
@layer base {
|
|
131
|
+
/* Nothing refuses to shrink below its intrinsic content width. This global
|
|
132
|
+
min-width reset defuses the classic flex/grid overflow — a long or
|
|
133
|
+
truncating child forcing its container (and the page) past the viewport
|
|
134
|
+
edge — so components don't need per-element min-w-0 overrides. */
|
|
135
|
+
* {
|
|
136
|
+
min-width: 0;
|
|
137
|
+
}
|
|
138
|
+
/* Interactive controls get a pointer cursor unless disabled. */
|
|
139
|
+
button:not(:disabled),
|
|
140
|
+
[role="button"]:not(:disabled) {
|
|
141
|
+
cursor: pointer;
|
|
142
|
+
}
|
|
137
143
|
html {
|
|
138
144
|
scroll-behavior: smooth;
|
|
139
145
|
scroll-padding-top: 4.5rem;
|
|
146
|
+
text-rendering: optimizeLegibility;
|
|
140
147
|
}
|
|
141
148
|
/* Headings use the display font (defaults to the body font when unset). */
|
|
142
149
|
h1,
|
|
@@ -153,16 +160,11 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
|
|
|
153
160
|
border-radius: 2px;
|
|
154
161
|
}
|
|
155
162
|
body {
|
|
156
|
-
background-attachment: fixed
|
|
157
|
-
background-image:
|
|
158
|
-
|
|
159
|
-
background-
|
|
160
|
-
background-
|
|
161
|
-
var(--blume-background-image-repeat),
|
|
162
|
-
var(--blume-background-decoration-repeat);
|
|
163
|
-
background-size:
|
|
164
|
-
var(--blume-background-image-size),
|
|
165
|
-
var(--blume-background-decoration-size);
|
|
163
|
+
background-attachment: fixed;
|
|
164
|
+
background-image: var(--blume-background-image);
|
|
165
|
+
background-position: center top;
|
|
166
|
+
background-repeat: var(--blume-background-image-repeat);
|
|
167
|
+
background-size: var(--blume-background-image-size);
|
|
166
168
|
}
|
|
167
169
|
@media (prefers-reduced-motion: reduce) {
|
|
168
170
|
html {
|
|
@@ -298,7 +300,7 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
|
|
|
298
300
|
line-height: 1.55;
|
|
299
301
|
margin: 1.5rem 0;
|
|
300
302
|
overflow-x: auto;
|
|
301
|
-
padding: 1rem
|
|
303
|
+
padding: 1rem 0;
|
|
302
304
|
position: relative;
|
|
303
305
|
}
|
|
304
306
|
|
|
@@ -395,6 +397,32 @@ blume-diff {
|
|
|
395
397
|
padding: 0;
|
|
396
398
|
}
|
|
397
399
|
|
|
400
|
+
/* Long lines scroll inside the code element, not the pre: the pre stays static
|
|
401
|
+
so its absolute header bar (::before) and copy button don't drift with the
|
|
402
|
+
scroll. The pre's horizontal padding lives here so content still scrolls
|
|
403
|
+
edge-to-edge past it. Two contexts opt out: twoslash blocks (popups must
|
|
404
|
+
escape any scroll container — see theme/twoslash.ts), and the API request
|
|
405
|
+
panel, which owns its code layout and keeps the copy control in the panel
|
|
406
|
+
header. Every other component that hosts a code block — Tabs, CodeGroup,
|
|
407
|
+
Steps, Callout, Card, Accordion — is real prose content and keeps the inset,
|
|
408
|
+
even though its chrome wrapper is not-prose. */
|
|
409
|
+
.prose :where(pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code) {
|
|
410
|
+
display: block;
|
|
411
|
+
overflow-x: auto;
|
|
412
|
+
padding: 0 1.25rem;
|
|
413
|
+
/* The scroller is only as tall as the code, so an overlay scrollbar would
|
|
414
|
+
draw on top of the last line; hide it (wheel/trackpad/keyboard scrolling
|
|
415
|
+
still works). */
|
|
416
|
+
scrollbar-width: none;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.prose
|
|
420
|
+
:where(
|
|
421
|
+
pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code
|
|
422
|
+
)::-webkit-scrollbar {
|
|
423
|
+
display: none;
|
|
424
|
+
}
|
|
425
|
+
|
|
398
426
|
/* Word wrap (markdown.code.wrap): long lines wrap instead of scrolling. The
|
|
399
427
|
attribute is set on <body> from config; default code keeps \`white-space: pre\`. */
|
|
400
428
|
[data-blume-code-wrap] pre,
|