blume 1.5.0 → 1.5.2
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 +32 -0
- package/README.md +16 -12
- package/dist/cli/index.js +449 -135
- package/dist/cli/index.js.map +24 -23
- package/dist/types/ai/ask-context.d.ts +78 -0
- package/dist/types/core/config-input.d.ts +54 -2
- package/dist/types/core/data.d.ts +19 -2
- package/dist/types/core/open-in-chat.d.ts +9 -0
- package/dist/types/core/schema.d.ts +48 -1
- package/dist/types/core/types.d.ts +10 -3
- package/dist/types/openapi/references.d.ts +9 -0
- package/dist/types/search/orama-index.d.ts +70 -0
- package/dist/types/theme/fonts.d.ts +11 -2
- package/docs/advanced/api-reference.mdx +67 -5
- package/docs/advanced/custom-pages.mdx +5 -1
- package/docs/configuration/ai.mdx +35 -0
- package/docs/configuration/index.mdx +14 -2
- package/docs/configuration/search.mdx +4 -4
- package/docs/configuration/theming.mdx +4 -2
- package/docs/reference/cli.mdx +2 -2
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/ask-context.ts +51 -11
- package/src/ai/mcp/data.ts +3 -2
- package/src/ai/mcp/server.ts +3 -2
- package/src/assets/icon-dark.png +0 -0
- package/src/astro/generate.ts +172 -18
- package/src/astro/templates.ts +89 -15
- package/src/components/content/AccordionItem.astro +4 -0
- package/src/components/content/Update.astro +3 -0
- package/src/components/islands/AskAI.astro +6 -0
- package/src/components/islands/ask-ai.tsx +39 -9
- package/src/components/layout/Analytics.astro +9 -1
- package/src/components/layout/Favicon.astro +29 -8
- package/src/components/layout/Fonts.astro +23 -3
- package/src/components/layout/Header.astro +2 -2
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/PageActions.astro +120 -78
- package/src/components/layout/PageFeedback.astro +12 -3
- package/src/components/layout/PageLayout.astro +79 -5
- package/src/components/layout/ReferenceLayout.astro +12 -9
- package/src/components/layout/RootLayout.astro +153 -121
- package/src/components/layout/Search.astro +41 -26
- package/src/components/layout/drawer-inert.ts +10 -5
- package/src/components/layout/head-scripts.ts +34 -16
- package/src/components/layout/nav-utils.ts +34 -15
- package/src/components/layout/search/orama.ts +3 -2
- package/src/components/openapi/AsyncApiOperation.astro +22 -7
- package/src/components/openapi/MessageComposer.astro +238 -0
- package/src/components/openapi/Operation.astro +26 -12
- package/src/components/openapi/PanelTabs.astro +7 -0
- package/src/components/openapi/Playground.astro +320 -0
- package/src/components/openapi/RequestPanel.astro +1 -0
- package/src/components/openapi/async-snippets.ts +20 -7
- package/src/components/openapi/async.ts +13 -2
- package/src/components/openapi/message-composer.ts +242 -0
- package/src/components/openapi/message-model.ts +108 -0
- package/src/components/openapi/message.ts +153 -0
- package/src/components/openapi/operation-model.ts +260 -0
- package/src/components/openapi/playground-client.ts +486 -0
- package/src/components/openapi/playground-schema.ts +109 -0
- package/src/components/openapi/request.ts +287 -0
- package/src/components/openapi/security.ts +0 -56
- package/src/components/openapi/snippets.ts +23 -136
- package/src/components/openapi/validate-json.ts +144 -0
- package/src/components/openapi/ws-client.ts +194 -0
- package/src/core/config-input.ts +67 -1
- package/src/core/content-assets.ts +66 -15
- package/src/core/data.ts +16 -2
- package/src/core/last-modified.ts +76 -2
- package/src/core/links.ts +30 -4
- package/src/core/navigation.ts +26 -1
- package/src/core/open-in-chat.ts +17 -0
- package/src/core/project-graph.ts +11 -0
- package/src/core/schema.ts +60 -1
- package/src/core/server-features.ts +11 -0
- package/src/core/sources/normalize.ts +10 -2
- package/src/core/types.ts +10 -3
- package/src/deploy/vercel-negotiation.ts +34 -14
- package/src/og/card.ts +3 -1
- package/src/openapi/model.ts +7 -0
- package/src/openapi/proxy.ts +217 -0
- package/src/openapi/references.ts +8 -0
- package/src/openapi/source.ts +13 -0
- package/src/registry/eject.ts +4 -5
- package/src/search/orama-index.ts +109 -36
- package/src/theme/entry.ts +15 -2
- package/src/theme/fonts.ts +75 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket state machine behind the AsyncAPI message composer's live connect,
|
|
3
|
+
* used only for operations whose channel has a `ws`/`wss` server. Framework-
|
|
4
|
+
* free and dependency-free like the rest of the playground client code, and it
|
|
5
|
+
* never touches the `WebSocket` global at import time — the constructor is
|
|
6
|
+
* reached solely inside the default factory, so this module imports cleanly in
|
|
7
|
+
* the test runner and on the server during the Astro build.
|
|
8
|
+
*
|
|
9
|
+
* The socket factory and the clock are injectable because both are the parts a
|
|
10
|
+
* test cannot afford to use for real: `create` lets a suite drive open/message/
|
|
11
|
+
* error/close by hand instead of dialing a network, and `now` makes frame
|
|
12
|
+
* timestamps deterministic instead of wall-clock noise.
|
|
13
|
+
*
|
|
14
|
+
* v1 deliberately has no reconnect, no send queue, and no timers. A docs reader
|
|
15
|
+
* connecting to a demo broker wants to see exactly what the socket did — a
|
|
16
|
+
* silent retry loop would hide the very close code they are debugging, and
|
|
17
|
+
* reconnect policy belongs to the API's own client library, not to a docs page.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// `typeof` checks live in named predicates (the form the oxlint anti-slop
|
|
21
|
+
// config sanctions); generic so each DOM-event field keeps its own narrowing.
|
|
22
|
+
const isString = <Value>(value: Value): value is Value & string =>
|
|
23
|
+
typeof value === "string";
|
|
24
|
+
|
|
25
|
+
const isNumber = <Value>(value: Value): value is Value & number =>
|
|
26
|
+
typeof value === "number";
|
|
27
|
+
|
|
28
|
+
export type WsState = "idle" | "connecting" | "open" | "closed" | "error";
|
|
29
|
+
|
|
30
|
+
export interface WsFrame {
|
|
31
|
+
/** Wall-clock ms when the frame was logged. */
|
|
32
|
+
at: number;
|
|
33
|
+
direction: "received" | "sent";
|
|
34
|
+
text: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One socket event, reduced to the fields the client reads. */
|
|
38
|
+
export interface SocketEvent {
|
|
39
|
+
code?: number;
|
|
40
|
+
data?: unknown;
|
|
41
|
+
reason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The subset of WebSocket the client drives; lets tests inject a fake. */
|
|
45
|
+
export interface SocketLike {
|
|
46
|
+
addEventListener: (
|
|
47
|
+
type: string,
|
|
48
|
+
listener: (event: SocketEvent) => void
|
|
49
|
+
) => void;
|
|
50
|
+
close: () => void;
|
|
51
|
+
send: (data: string) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface WsClientOptions {
|
|
55
|
+
/** Socket factory; defaults to `new WebSocket(url)`. */
|
|
56
|
+
create?: (url: string) => SocketLike;
|
|
57
|
+
/** Clock for frame timestamps; defaults to `Date.now`. */
|
|
58
|
+
now?: () => number;
|
|
59
|
+
onFrame: (frame: WsFrame) => void;
|
|
60
|
+
/** State transitions, with an optional human detail (close reason/code). */
|
|
61
|
+
onState: (state: WsState, detail?: string) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface WsClient {
|
|
65
|
+
/** Open a socket. A no-op while `connecting` or `open`. */
|
|
66
|
+
connect: (url: string) => void;
|
|
67
|
+
/** Close the socket if any; state goes to `closed`. */
|
|
68
|
+
disconnect: () => void;
|
|
69
|
+
/** Send text; false when the socket is not open (nothing is logged then). */
|
|
70
|
+
send: (text: string) => boolean;
|
|
71
|
+
state: () => WsState;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Adapt a real `WebSocket` to `SocketLike`. Each DOM event is reduced to the
|
|
76
|
+
* three fields the client reads, discovered with `in`/`typeof` guards: the DOM
|
|
77
|
+
* types would need a cast to reach `code`/`data`/`reason` off a bare `Event`,
|
|
78
|
+
* and a cast here would let the rest of the module drift onto browser-only
|
|
79
|
+
* surface it has no business using.
|
|
80
|
+
*/
|
|
81
|
+
const defaultCreate = (url: string): SocketLike => {
|
|
82
|
+
const socket = new WebSocket(url);
|
|
83
|
+
return {
|
|
84
|
+
addEventListener: (type, listener) => {
|
|
85
|
+
socket.addEventListener(type, (event) => {
|
|
86
|
+
const reduced: SocketEvent = {};
|
|
87
|
+
if ("code" in event && isNumber(event.code)) {
|
|
88
|
+
reduced.code = event.code;
|
|
89
|
+
}
|
|
90
|
+
if ("data" in event) {
|
|
91
|
+
reduced.data = event.data;
|
|
92
|
+
}
|
|
93
|
+
if ("reason" in event && isString(event.reason)) {
|
|
94
|
+
reduced.reason = event.reason;
|
|
95
|
+
}
|
|
96
|
+
listener(reduced);
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
close: () => {
|
|
100
|
+
socket.close();
|
|
101
|
+
},
|
|
102
|
+
send: (data) => {
|
|
103
|
+
socket.send(data);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create the client. Nothing happens until `connect` — constructing one during
|
|
110
|
+
* page setup must not dial anything, since most readers never press Connect.
|
|
111
|
+
*/
|
|
112
|
+
export const createWsClient = (options: WsClientOptions): WsClient => {
|
|
113
|
+
const create = options.create ?? defaultCreate;
|
|
114
|
+
const now = options.now ?? Date.now;
|
|
115
|
+
let socket: SocketLike | null = null;
|
|
116
|
+
let state: WsState = "idle";
|
|
117
|
+
|
|
118
|
+
const transition = (next: WsState, detail?: string): void => {
|
|
119
|
+
state = next;
|
|
120
|
+
options.onState(next, detail);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const connect = (url: string): void => {
|
|
124
|
+
if (state === "connecting" || state === "open") {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
transition("connecting");
|
|
128
|
+
const next = create(url);
|
|
129
|
+
socket = next;
|
|
130
|
+
// Every listener below is scoped to the socket it was wired for: a browser
|
|
131
|
+
// can deliver a discarded socket's error/close/message after `disconnect`
|
|
132
|
+
// or after the reader reconnected, and replaying those would report the
|
|
133
|
+
// dead connection's fate as the live one's.
|
|
134
|
+
const stale = (): boolean => socket !== next;
|
|
135
|
+
next.addEventListener("open", () => {
|
|
136
|
+
if (stale()) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
transition("open");
|
|
140
|
+
});
|
|
141
|
+
next.addEventListener("message", (event) => {
|
|
142
|
+
if (stale()) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
options.onFrame({
|
|
146
|
+
at: now(),
|
|
147
|
+
direction: "received",
|
|
148
|
+
// Binary frames arrive as Blob/ArrayBuffer; showing their stringified
|
|
149
|
+
// form beats showing nothing while the reader debugs a payload.
|
|
150
|
+
text: isString(event.data) ? event.data : String(event.data),
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
next.addEventListener("error", () => {
|
|
154
|
+
if (stale()) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
transition("error");
|
|
158
|
+
});
|
|
159
|
+
next.addEventListener("close", (event) => {
|
|
160
|
+
// Once settled, stay settled: browsers always follow an error event with
|
|
161
|
+
// a close event, and the error is the message worth keeping. A close that
|
|
162
|
+
// answers our own `disconnect` has been reported already.
|
|
163
|
+
if (stale() || state === "error" || state === "closed") {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Code plus the reason when the server bothered to send one; a bare
|
|
167
|
+
// close stays blank so the UI says just "closed", not a meaningless "0".
|
|
168
|
+
const detail = [event.code, event.reason].filter(
|
|
169
|
+
(part) => part !== undefined && part !== ""
|
|
170
|
+
);
|
|
171
|
+
transition("closed", detail.length > 0 ? detail.join(" ") : undefined);
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const disconnect = (): void => {
|
|
176
|
+
if (!socket) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
socket.close();
|
|
180
|
+
socket = null;
|
|
181
|
+
transition("closed");
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const send = (text: string): boolean => {
|
|
185
|
+
if (state !== "open") {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
socket?.send(text);
|
|
189
|
+
options.onFrame({ at: now(), direction: "sent", text });
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return { connect, disconnect, send, state: () => state };
|
|
194
|
+
};
|
package/src/core/config-input.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { AstroIntegration } from "astro";
|
|
2
2
|
import type { z } from "zod";
|
|
3
3
|
|
|
4
|
+
import type { AskRetrievalOptions } from "../ai/ask-context.ts";
|
|
4
5
|
import type { ComponentMarkdown } from "../ai/component-markdown.ts";
|
|
5
6
|
import type { CodeTheme } from "../markdown/themes.ts";
|
|
6
7
|
import type { FontSlug } from "../theme/fonts.ts";
|
|
7
8
|
import type {
|
|
8
9
|
blumeConfigSchema,
|
|
9
10
|
OpenApiSource,
|
|
11
|
+
OpenInChatProvider,
|
|
10
12
|
SearchProvider,
|
|
11
13
|
SidebarDisplay,
|
|
12
14
|
SidebarItemConfig,
|
|
@@ -510,7 +512,7 @@ export type FontInput =
|
|
|
510
512
|
export interface FontsConfig {
|
|
511
513
|
/** Body / prose font. Defaults to `inter`. */
|
|
512
514
|
body?: FontInput;
|
|
513
|
-
/** Display / heading font. Defaults to `inter
|
|
515
|
+
/** Display / heading font. Defaults to `inter` (shared with the body). */
|
|
514
516
|
display?: FontInput;
|
|
515
517
|
/** Monospace / code font. Defaults to `ibm-plex-mono`. */
|
|
516
518
|
mono?: FontInput;
|
|
@@ -636,6 +638,28 @@ export interface AskSuggestion {
|
|
|
636
638
|
type AskProviderGateway = "gateway" | "openrouter" | "llmgateway";
|
|
637
639
|
type AskProvider = AskProviderGateway | "inkeep" | "openai-compatible";
|
|
638
640
|
|
|
641
|
+
/** How much retrieved documentation each Ask AI question carries. */
|
|
642
|
+
export interface AskRetrievalConfig {
|
|
643
|
+
/**
|
|
644
|
+
* Total injected documentation characters, across all excerpts. Defaults to
|
|
645
|
+
* `10000`. The single biggest lever on time-to-first-token — the model reads
|
|
646
|
+
* every injected character before it emits a token.
|
|
647
|
+
*/
|
|
648
|
+
contextBudget?: number;
|
|
649
|
+
/**
|
|
650
|
+
* Characters kept per excerpt. Defaults to `2000`. Raise it when one long
|
|
651
|
+
* page holds the whole answer (a table the excerpt cuts in half); the
|
|
652
|
+
* `contextBudget` still caps the total.
|
|
653
|
+
*/
|
|
654
|
+
excerptChars?: number;
|
|
655
|
+
/**
|
|
656
|
+
* Documents retrieved per question. Defaults to `6`. The page the reader is
|
|
657
|
+
* viewing is injected on top of the retrieved ones, so an answer can cite up
|
|
658
|
+
* to one page more than this.
|
|
659
|
+
*/
|
|
660
|
+
maxResults?: number;
|
|
661
|
+
}
|
|
662
|
+
|
|
639
663
|
export interface AskConfig {
|
|
640
664
|
/**
|
|
641
665
|
* Name of the env var holding the provider API key. Each provider has a
|
|
@@ -665,6 +689,12 @@ export interface AskConfig {
|
|
|
665
689
|
model?: string;
|
|
666
690
|
/** Which backend routes the request. Defaults to `gateway`. */
|
|
667
691
|
provider?: AskProvider;
|
|
692
|
+
/**
|
|
693
|
+
* How much documentation each question carries into the model's prompt.
|
|
694
|
+
* Lower values cut time-to-first-token — which dominates on a self-hosted
|
|
695
|
+
* backend — at the cost of recall. Defaults keep the built-in behavior.
|
|
696
|
+
*/
|
|
697
|
+
retrieval?: AskRetrievalConfig;
|
|
668
698
|
/** Starter prompts shown before the first question. */
|
|
669
699
|
suggestions?: AskSuggestion[];
|
|
670
700
|
}
|
|
@@ -728,6 +758,19 @@ export interface AiConfig {
|
|
|
728
758
|
markdownComponents?: Record<string, ComponentMarkdown>;
|
|
729
759
|
/** Expose the docs as an MCP server for agents. */
|
|
730
760
|
mcp?: McpConfig;
|
|
761
|
+
/**
|
|
762
|
+
* The "Open in chat" page action, which opens the current page in an AI
|
|
763
|
+
* assistant pre-filled with a prompt pointing at its raw Markdown.
|
|
764
|
+
* Defaults to `true` (every provider). Set `false` to hide the action, or
|
|
765
|
+
* list a subset of providers to show, in order.
|
|
766
|
+
*
|
|
767
|
+
* ```ts
|
|
768
|
+
* ai: {
|
|
769
|
+
* openInChat: ["claude", "chatgpt", "cursor"],
|
|
770
|
+
* }
|
|
771
|
+
* ```
|
|
772
|
+
*/
|
|
773
|
+
openInChat?: boolean | OpenInChatProvider[];
|
|
731
774
|
/**
|
|
732
775
|
* Publish Agent Skills for discovery: a directory (resolved against the
|
|
733
776
|
* project root) whose subdirectories each hold a `SKILL.md`. Skills are
|
|
@@ -1175,6 +1218,15 @@ interface ReferenceConfig {
|
|
|
1175
1218
|
enabled?: boolean;
|
|
1176
1219
|
/** Start nested schema rows expanded (Blume renderer). Defaults to `false`. */
|
|
1177
1220
|
expandSchemas?: boolean;
|
|
1221
|
+
/**
|
|
1222
|
+
* The interactive "Try it" panel on operation pages (Blume renderer). On by
|
|
1223
|
+
* default; `false` hides it. `proxy` is the CORS escape hatch the OpenAPI
|
|
1224
|
+
* Send button routes requests through: a proxy URL, or `true` for the
|
|
1225
|
+
* built-in `/_api-proxy` endpoint (which requires
|
|
1226
|
+
* `deployment.output: "server"`). `proxy` is OpenAPI-only — an event
|
|
1227
|
+
* composer's WebSocket connect is direct.
|
|
1228
|
+
*/
|
|
1229
|
+
playground?: boolean | { enabled?: boolean; proxy?: boolean | string };
|
|
1178
1230
|
/** Who renders the reference. Defaults to `blume`. */
|
|
1179
1231
|
renderer?: "blume" | "scalar";
|
|
1180
1232
|
/**
|
|
@@ -1465,3 +1517,17 @@ type _NoExtraOrMissingKeys = AssertExtends<
|
|
|
1465
1517
|
| Exclude<keyof SchemaInput, keyof BlumeConfig>,
|
|
1466
1518
|
never
|
|
1467
1519
|
>;
|
|
1520
|
+
// The retrieval shape lives in three places: this documented config interface,
|
|
1521
|
+
// the schema, and the runtime `AskRetrievalOptions` that `createAskContext`
|
|
1522
|
+
// reads (all-optional, so plain assignability is a weak-type check that a
|
|
1523
|
+
// renamed field slips through — the value would be baked into the generated
|
|
1524
|
+
// endpoint and silently ignored at request time). `Required` makes a rename in
|
|
1525
|
+
// either copy a missing property, which stops compiling.
|
|
1526
|
+
type _AskRetrievalMatchesRuntime = AssertExtends<
|
|
1527
|
+
Required<AskRetrievalConfig>,
|
|
1528
|
+
Required<AskRetrievalOptions>
|
|
1529
|
+
>;
|
|
1530
|
+
type _AskRuntimeMatchesRetrieval = AssertExtends<
|
|
1531
|
+
Required<AskRetrievalOptions>,
|
|
1532
|
+
Required<AskRetrievalConfig>
|
|
1533
|
+
>;
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
basename,
|
|
6
|
+
dirname,
|
|
7
|
+
extname,
|
|
8
|
+
normalize,
|
|
9
|
+
relative,
|
|
10
|
+
resolve,
|
|
11
|
+
} from "pathe";
|
|
5
12
|
|
|
6
13
|
import { normalizeBasePath } from "./base-path.ts";
|
|
7
14
|
import { hashText } from "./sources/cache.ts";
|
|
@@ -65,30 +72,74 @@ export const contentAssetParam = (
|
|
|
65
72
|
return rel;
|
|
66
73
|
};
|
|
67
74
|
|
|
75
|
+
/** Percent-decode an image target; malformed escapes stay verbatim. */
|
|
76
|
+
const decodeTarget = (target: string): string => {
|
|
77
|
+
try {
|
|
78
|
+
return decodeURI(target);
|
|
79
|
+
} catch {
|
|
80
|
+
return target;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a target is *shaped* like a colocated image reference: a relative
|
|
86
|
+
* filesystem path with an image extension. Exported so link validation can
|
|
87
|
+
* tell "not a colocated candidate" apart from "a candidate that resolves
|
|
88
|
+
* nowhere" — the former falls through to the public-dir probe, the latter is
|
|
89
|
+
* a broken reference beside the page source.
|
|
90
|
+
*/
|
|
91
|
+
export const isRelativeImageTarget = (target: string): boolean =>
|
|
92
|
+
isRelativeTarget(target) &&
|
|
93
|
+
IMAGE_EXTENSIONS.has(extname(decodeTarget(target)).toLowerCase());
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether `abs` is a file whose trailing `segments` names match the on-disk
|
|
97
|
+
* entries exactly. A bare `existsSync` accepts `./Diagram.PNG` for
|
|
98
|
+
* `diagram.png` (or a directory named like an image) on a case-insensitive
|
|
99
|
+
* filesystem — the reference then validates and serves locally but breaks on
|
|
100
|
+
* the case-sensitive Linux build.
|
|
101
|
+
*/
|
|
102
|
+
const existsAsWritten = (abs: string, segments: number): boolean => {
|
|
103
|
+
let current = abs;
|
|
104
|
+
for (let i = 0; i < segments; i += 1) {
|
|
105
|
+
const parent = dirname(current);
|
|
106
|
+
let entries: string[];
|
|
107
|
+
try {
|
|
108
|
+
entries = readdirSync(parent);
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if (!entries.includes(basename(current))) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
current = parent;
|
|
116
|
+
}
|
|
117
|
+
const stat = statSync(abs, { throwIfNoEntry: false });
|
|
118
|
+
return stat !== undefined && stat.isFile();
|
|
119
|
+
};
|
|
120
|
+
|
|
68
121
|
/**
|
|
69
122
|
* Resolve one image target against its page's directory. Returns the absolute
|
|
70
123
|
* file path when the target is relative, is an image, and exists on disk —
|
|
71
124
|
* anything else (remote URLs, `public/` absolutes, broken refs, code-block
|
|
72
|
-
* examples that happen to look like paths) is null and left untouched.
|
|
125
|
+
* examples that happen to look like paths) is null and left untouched. Shared
|
|
126
|
+
* with link validation, so what counts as a colocated image is decided once.
|
|
73
127
|
*/
|
|
74
|
-
const resolveRelativeImage = (
|
|
128
|
+
export const resolveRelativeImage = (
|
|
75
129
|
sourceDir: string,
|
|
76
130
|
target: string
|
|
77
131
|
): string | null => {
|
|
78
|
-
if (!
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
let decoded = target;
|
|
82
|
-
try {
|
|
83
|
-
decoded = decodeURI(target);
|
|
84
|
-
} catch {
|
|
85
|
-
// Malformed escapes — try the raw text.
|
|
86
|
-
}
|
|
87
|
-
if (!IMAGE_EXTENSIONS.has(extname(decoded).toLowerCase())) {
|
|
132
|
+
if (!isRelativeImageTarget(target)) {
|
|
88
133
|
return null;
|
|
89
134
|
}
|
|
135
|
+
const decoded = decodeTarget(target);
|
|
136
|
+
// Only the segments the author wrote are checked against on-disk names;
|
|
137
|
+
// `sourceDir`'s own casing is the filesystem's business, not the target's.
|
|
138
|
+
const segments = normalize(decoded)
|
|
139
|
+
.split("/")
|
|
140
|
+
.filter((part) => part !== "" && part !== "..").length;
|
|
90
141
|
const abs = resolve(sourceDir, decoded);
|
|
91
|
-
return
|
|
142
|
+
return existsAsWritten(abs, segments) ? abs : null;
|
|
92
143
|
};
|
|
93
144
|
|
|
94
145
|
/** Encode an endpoint param for use in a Markdown URL, keeping `/` separators. */
|
package/src/core/data.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FontHead } from "../theme/fonts.ts";
|
|
1
2
|
import type { UIStrings } from "./i18n-ui.ts";
|
|
2
3
|
import type { ResolvedConfig, SearchProvider } from "./schema.ts";
|
|
3
4
|
import type { Navigation, RouteAlternate, VersionAlternate } from "./types.ts";
|
|
@@ -29,6 +30,14 @@ export interface BlumeLogo {
|
|
|
29
30
|
export interface BlumeFavicon {
|
|
30
31
|
href: string;
|
|
31
32
|
type?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Dark-scheme variant: the `-dark` sibling of the resolved icon file (e.g.
|
|
35
|
+
* `icon.svg` → `icon-dark.svg`), or the bundled default pair. When set, the
|
|
36
|
+
* layout emits an unconditional light link plus both icons behind
|
|
37
|
+
* `media="(prefers-color-scheme: …)"` so a dark mark doesn't vanish against
|
|
38
|
+
* dark browser chrome.
|
|
39
|
+
*/
|
|
40
|
+
dark?: { href: string; type?: string };
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
/** Announcement banner, normalized from its config (string shorthand or object). */
|
|
@@ -150,6 +159,11 @@ export interface BlumeDataConfig {
|
|
|
150
159
|
*/
|
|
151
160
|
site?: string;
|
|
152
161
|
};
|
|
162
|
+
/**
|
|
163
|
+
* "Open in chat" page-action providers (`ai.openInChat`), in display order;
|
|
164
|
+
* empty hides the action.
|
|
165
|
+
*/
|
|
166
|
+
openInChat: ResolvedConfig["ai"]["openInChat"];
|
|
153
167
|
/** Repository URL for header/edit links, or `null`. */
|
|
154
168
|
repoUrl: string | null;
|
|
155
169
|
search: {
|
|
@@ -192,8 +206,8 @@ export interface BlumeClientData {
|
|
|
192
206
|
export interface BlumeData {
|
|
193
207
|
config: BlumeDataConfig;
|
|
194
208
|
feeds: BlumeFeed[];
|
|
195
|
-
/**
|
|
196
|
-
fontCssVars:
|
|
209
|
+
/** Configured fonts for the head: CSS variable + preload weights per family. */
|
|
210
|
+
fontCssVars: FontHead[];
|
|
197
211
|
/** Sidebar + tab tree for the default locale. */
|
|
198
212
|
navigation: Navigation;
|
|
199
213
|
/** Per-locale navigation trees, keyed by locale code (empty without i18n). */
|
|
@@ -2,12 +2,35 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
|
|
3
3
|
import { relative } from "pathe";
|
|
4
4
|
|
|
5
|
+
import type { Diagnostic } from "./types.ts";
|
|
6
|
+
|
|
5
7
|
/** Normalized form of the `lastModified` config. */
|
|
6
8
|
export interface ResolvedLastModified {
|
|
7
9
|
enabled: boolean;
|
|
8
10
|
source: "git" | "frontmatter";
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Env for git invocations, with the repo-locating GIT_* variables stripped. A
|
|
15
|
+
* parent git process exports an absolute GIT_DIR (and friends) to its hooks —
|
|
16
|
+
* husky pre-commit in a linked worktree, post-merge, CI wrappers — and an
|
|
17
|
+
* inherited GIT_DIR overrides `-C` discovery, silently pointing every call at
|
|
18
|
+
* the parent's repository instead of the project's.
|
|
19
|
+
*/
|
|
20
|
+
const GIT_LOCATION_VARS = new Set([
|
|
21
|
+
"GIT_COMMON_DIR",
|
|
22
|
+
"GIT_DIR",
|
|
23
|
+
"GIT_INDEX_FILE",
|
|
24
|
+
"GIT_OBJECT_DIRECTORY",
|
|
25
|
+
"GIT_PREFIX",
|
|
26
|
+
"GIT_WORK_TREE",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const gitEnv = (): NodeJS.ProcessEnv =>
|
|
30
|
+
Object.fromEntries(
|
|
31
|
+
Object.entries(process.env).filter(([key]) => !GIT_LOCATION_VARS.has(key))
|
|
32
|
+
);
|
|
33
|
+
|
|
11
34
|
/** Normalize the `lastModified` config union into `{ enabled, source }`. */
|
|
12
35
|
export const resolveLastModifiedConfig = (
|
|
13
36
|
value: boolean | { type: "git" | "frontmatter" }
|
|
@@ -64,7 +87,7 @@ export const gitLastModifiedTimes = (
|
|
|
64
87
|
// oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
|
|
65
88
|
"git",
|
|
66
89
|
["-C", root, "rev-parse", "--show-toplevel"],
|
|
67
|
-
{ encoding: "utf-8" }
|
|
90
|
+
{ encoding: "utf-8", env: gitEnv() }
|
|
68
91
|
).trim();
|
|
69
92
|
const output = execFileSync(
|
|
70
93
|
// oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
|
|
@@ -80,7 +103,7 @@ export const gitLastModifiedTimes = (
|
|
|
80
103
|
"--",
|
|
81
104
|
...contentRoots,
|
|
82
105
|
],
|
|
83
|
-
{ encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 }
|
|
106
|
+
{ encoding: "utf-8", env: gitEnv(), maxBuffer: 256 * 1024 * 1024 }
|
|
84
107
|
);
|
|
85
108
|
const byRepoPath = parseGitLog(output);
|
|
86
109
|
const result = new Map<string, string>();
|
|
@@ -95,3 +118,54 @@ export const gitLastModifiedTimes = (
|
|
|
95
118
|
return new Map();
|
|
96
119
|
}
|
|
97
120
|
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether the repository containing `root` is a shallow clone. Returns false
|
|
124
|
+
* when git is unavailable or the project isn't a repo — those cases already
|
|
125
|
+
* yield no dates at all, and the shallow warning would only mislead.
|
|
126
|
+
*/
|
|
127
|
+
export const isShallowGitRepository = (root: string): boolean => {
|
|
128
|
+
try {
|
|
129
|
+
return (
|
|
130
|
+
execFileSync(
|
|
131
|
+
// oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
|
|
132
|
+
"git",
|
|
133
|
+
["-C", root, "rev-parse", "--is-shallow-repository"],
|
|
134
|
+
// stderr silenced: outside a repository the probe fails by design.
|
|
135
|
+
{
|
|
136
|
+
encoding: "utf-8",
|
|
137
|
+
env: gitEnv(),
|
|
138
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
139
|
+
}
|
|
140
|
+
).trim() === "true"
|
|
141
|
+
);
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A warning for git-derived dates silently missing because the build ran in a
|
|
149
|
+
* shallow clone — the default on Vercel and `actions/checkout`, where `git log`
|
|
150
|
+
* only sees the last few commits, so most pages get no date and the sitemap's
|
|
151
|
+
* `<lastmod>` / "Last updated" stamps quietly disappear in production while
|
|
152
|
+
* working locally. Empty when every page got a date or the clone isn't
|
|
153
|
+
* shallow.
|
|
154
|
+
*/
|
|
155
|
+
export const lastModifiedShallowWarning = (
|
|
156
|
+
root: string,
|
|
157
|
+
undatedCount: number
|
|
158
|
+
): Diagnostic[] => {
|
|
159
|
+
if (undatedCount === 0 || !isShallowGitRepository(root)) {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
return [
|
|
163
|
+
{
|
|
164
|
+
code: "BLUME_SHALLOW_GIT_HISTORY",
|
|
165
|
+
message: `lastModified is on, but this build runs in a shallow git clone, so ${undatedCount} page(s) have no git-derived date — their sitemap <lastmod> and "Last updated" stamps are omitted.`,
|
|
166
|
+
severity: "warning",
|
|
167
|
+
suggestion:
|
|
168
|
+
"Fetch full history in CI: set the VERCEL_DEEP_CLONE=true environment variable on Vercel, or fetch-depth: 0 for actions/checkout.",
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
};
|
package/src/core/links.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
|
|
3
|
-
import { basename, join } from "pathe";
|
|
3
|
+
import { basename, dirname, join } from "pathe";
|
|
4
4
|
|
|
5
5
|
import { stripBasePath, withBasePath } from "./base-path.ts";
|
|
6
|
+
import {
|
|
7
|
+
isRelativeImageTarget,
|
|
8
|
+
resolveRelativeImage,
|
|
9
|
+
} from "./content-assets.ts";
|
|
6
10
|
import { gradeExternal, probeAll } from "./probe.ts";
|
|
7
11
|
import type {
|
|
8
12
|
ContentGraph,
|
|
@@ -154,7 +158,8 @@ const checkAnchor = (
|
|
|
154
158
|
const checkPathLink = (
|
|
155
159
|
resolved: string,
|
|
156
160
|
fragment: string,
|
|
157
|
-
|
|
161
|
+
page: PageRecord,
|
|
162
|
+
link: PageLink,
|
|
158
163
|
site: LinkSite,
|
|
159
164
|
ctx: LinkContext
|
|
160
165
|
): LinkResult => {
|
|
@@ -186,6 +191,27 @@ const checkPathLink = (
|
|
|
186
191
|
if (assetIsPresent(assetPath, ctx)) {
|
|
187
192
|
return null;
|
|
188
193
|
}
|
|
194
|
+
// A colocated image embed (``) is resolved from beside
|
|
195
|
+
// the page source and emitted to `_astro/` by the image pipeline, so it
|
|
196
|
+
// never lands in `public/`. The *raw* target goes through the same
|
|
197
|
+
// resolver that decides what the `/blume-assets/content` endpoint serves,
|
|
198
|
+
// so a reference the rewriter skips (a `?v=2` suffix, a double-encoded
|
|
199
|
+
// name) is reported here, not accepted. Plain links to the same path stay
|
|
200
|
+
// on the public-dir probe — an href resolves as a site route, and only
|
|
201
|
+
// image nodes are rewritten.
|
|
202
|
+
if (link.image && page.sourcePath && isRelativeImageTarget(link.target)) {
|
|
203
|
+
if (resolveRelativeImage(dirname(page.sourcePath), link.target)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
...site,
|
|
208
|
+
code: "BLUME_BROKEN_ASSET",
|
|
209
|
+
message: `Image ${link.target} was not found next to ${basename(page.sourcePath)}.`,
|
|
210
|
+
severity: "warning",
|
|
211
|
+
suggestion:
|
|
212
|
+
"Add the file next to the page source or fix the reference.",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
189
215
|
// Nowhere to look: no `public/` directory.
|
|
190
216
|
if (ctx.publicDir === null) {
|
|
191
217
|
return "asset-unchecked";
|
|
@@ -202,7 +228,7 @@ const checkPathLink = (
|
|
|
202
228
|
return {
|
|
203
229
|
...site,
|
|
204
230
|
code: "BLUME_BROKEN_LINK",
|
|
205
|
-
message: `Broken link to ${target}: no page resolves to ${route}.`,
|
|
231
|
+
message: `Broken link to ${link.target}: no page resolves to ${route}.`,
|
|
206
232
|
severity: "error",
|
|
207
233
|
suggestion: "Check the path, or create the target page.",
|
|
208
234
|
};
|
|
@@ -279,7 +305,7 @@ const classifyLink = (
|
|
|
279
305
|
const resolved = rawPath.startsWith("/")
|
|
280
306
|
? rawPath
|
|
281
307
|
: resolveRelative(page.route, rawPath, isIndexPage(page));
|
|
282
|
-
return checkPathLink(resolved, fragment,
|
|
308
|
+
return checkPathLink(resolved, fragment, page, link, site, ctx);
|
|
283
309
|
};
|
|
284
310
|
|
|
285
311
|
/**
|
package/src/core/navigation.ts
CHANGED
|
@@ -20,6 +20,28 @@ const NUMERIC_PREFIX = /^(?<order>\d+)[-_.]/u;
|
|
|
20
20
|
const GROUP_FOLDER = /^\((?<label>.+)\)$/u;
|
|
21
21
|
const WORD_SPLIT = /[-_]/u;
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Whether `route` is the section root `base` or nested beneath it. Requires a
|
|
25
|
+
* path boundary, so `/api-reference` is not under `/api`. The root `/` spans
|
|
26
|
+
* every route.
|
|
27
|
+
*/
|
|
28
|
+
export const isUnderPath = (route: string, base: string): boolean =>
|
|
29
|
+
base === "/" || route === base || route.startsWith(`${base}/`);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether `tab` is the root tab of the tree rooted at `root` — the tab that
|
|
33
|
+
* spans the whole sidebar rather than one section. Tab paths and the root
|
|
34
|
+
* normally share one path space, so the root tab sits at `root` exactly (`/`,
|
|
35
|
+
* `/en`, `/docs`); in an archived version tree the root is versionized
|
|
36
|
+
* (`/v1.0`, `/docs/v1.0`) while tab paths stay in current-docs space, so the
|
|
37
|
+
* root tab is any tab the whole root sits under. The one definition shared by
|
|
38
|
+
* sidebar scoping, tab-section hoisting, and the header's current-tab state —
|
|
39
|
+
* consumers that disagree on which tab is the root tab prune an archived
|
|
40
|
+
* sidebar or highlight the wrong tab over it.
|
|
41
|
+
*/
|
|
42
|
+
export const isRootTab = (tab: NavTab, root: string): boolean =>
|
|
43
|
+
isUnderPath(root, tab.path);
|
|
44
|
+
|
|
23
45
|
const humanize = (segment: string): string =>
|
|
24
46
|
segment
|
|
25
47
|
.replace(NUMERIC_PREFIX, "")
|
|
@@ -896,6 +918,9 @@ export const buildNavigation = (
|
|
|
896
918
|
// prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
|
|
897
919
|
// under a base, falsely scope a group named like the prefix). Carried on the
|
|
898
920
|
// returned navigation so render-time scoping compares in the same space too.
|
|
921
|
+
// In a version snapshot the root arrives versionized (`/v1.0`) while tab
|
|
922
|
+
// paths stay in current-docs space, so root-tab checks use `isRootTab`
|
|
923
|
+
// containment, not equality.
|
|
899
924
|
const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
|
|
900
925
|
|
|
901
926
|
// Emitted here, before the sidebar-mode branch: an explicit config sidebar
|
|
@@ -930,7 +955,7 @@ export const buildNavigation = (
|
|
|
930
955
|
sharedMetaPrefix,
|
|
931
956
|
display,
|
|
932
957
|
new Set(
|
|
933
|
-
tabs.flatMap((tab) => (tab
|
|
958
|
+
tabs.flatMap((tab) => (isRootTab(tab, rootTabPath) ? [] : [tab.path]))
|
|
934
959
|
),
|
|
935
960
|
diagnostics
|
|
936
961
|
);
|