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.
Files changed (89) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -12
  3. package/dist/cli/index.js +449 -135
  4. package/dist/cli/index.js.map +24 -23
  5. package/dist/types/ai/ask-context.d.ts +78 -0
  6. package/dist/types/core/config-input.d.ts +54 -2
  7. package/dist/types/core/data.d.ts +19 -2
  8. package/dist/types/core/open-in-chat.d.ts +9 -0
  9. package/dist/types/core/schema.d.ts +48 -1
  10. package/dist/types/core/types.d.ts +10 -3
  11. package/dist/types/openapi/references.d.ts +9 -0
  12. package/dist/types/search/orama-index.d.ts +70 -0
  13. package/dist/types/theme/fonts.d.ts +11 -2
  14. package/docs/advanced/api-reference.mdx +67 -5
  15. package/docs/advanced/custom-pages.mdx +5 -1
  16. package/docs/configuration/ai.mdx +35 -0
  17. package/docs/configuration/index.mdx +14 -2
  18. package/docs/configuration/search.mdx +4 -4
  19. package/docs/configuration/theming.mdx +4 -2
  20. package/docs/reference/cli.mdx +2 -2
  21. package/package.json +1 -1
  22. package/skills/blume-migrate/SKILL.md +1 -1
  23. package/skills/blume-migrate/references/mintlify.md +1 -1
  24. package/src/ai/ask-context.ts +51 -11
  25. package/src/ai/mcp/data.ts +3 -2
  26. package/src/ai/mcp/server.ts +3 -2
  27. package/src/assets/icon-dark.png +0 -0
  28. package/src/astro/generate.ts +172 -18
  29. package/src/astro/templates.ts +89 -15
  30. package/src/components/content/AccordionItem.astro +4 -0
  31. package/src/components/content/Update.astro +3 -0
  32. package/src/components/islands/AskAI.astro +6 -0
  33. package/src/components/islands/ask-ai.tsx +39 -9
  34. package/src/components/layout/Analytics.astro +9 -1
  35. package/src/components/layout/Favicon.astro +29 -8
  36. package/src/components/layout/Fonts.astro +23 -3
  37. package/src/components/layout/Header.astro +2 -2
  38. package/src/components/layout/NavSelector.astro +1 -1
  39. package/src/components/layout/PageActions.astro +120 -78
  40. package/src/components/layout/PageFeedback.astro +12 -3
  41. package/src/components/layout/PageLayout.astro +79 -5
  42. package/src/components/layout/ReferenceLayout.astro +12 -9
  43. package/src/components/layout/RootLayout.astro +153 -121
  44. package/src/components/layout/Search.astro +41 -26
  45. package/src/components/layout/drawer-inert.ts +10 -5
  46. package/src/components/layout/head-scripts.ts +34 -16
  47. package/src/components/layout/nav-utils.ts +34 -15
  48. package/src/components/layout/search/orama.ts +3 -2
  49. package/src/components/openapi/AsyncApiOperation.astro +22 -7
  50. package/src/components/openapi/MessageComposer.astro +238 -0
  51. package/src/components/openapi/Operation.astro +26 -12
  52. package/src/components/openapi/PanelTabs.astro +7 -0
  53. package/src/components/openapi/Playground.astro +320 -0
  54. package/src/components/openapi/RequestPanel.astro +1 -0
  55. package/src/components/openapi/async-snippets.ts +20 -7
  56. package/src/components/openapi/async.ts +13 -2
  57. package/src/components/openapi/message-composer.ts +242 -0
  58. package/src/components/openapi/message-model.ts +108 -0
  59. package/src/components/openapi/message.ts +153 -0
  60. package/src/components/openapi/operation-model.ts +260 -0
  61. package/src/components/openapi/playground-client.ts +486 -0
  62. package/src/components/openapi/playground-schema.ts +109 -0
  63. package/src/components/openapi/request.ts +287 -0
  64. package/src/components/openapi/security.ts +0 -56
  65. package/src/components/openapi/snippets.ts +23 -136
  66. package/src/components/openapi/validate-json.ts +144 -0
  67. package/src/components/openapi/ws-client.ts +194 -0
  68. package/src/core/config-input.ts +67 -1
  69. package/src/core/content-assets.ts +66 -15
  70. package/src/core/data.ts +16 -2
  71. package/src/core/last-modified.ts +76 -2
  72. package/src/core/links.ts +30 -4
  73. package/src/core/navigation.ts +26 -1
  74. package/src/core/open-in-chat.ts +17 -0
  75. package/src/core/project-graph.ts +11 -0
  76. package/src/core/schema.ts +60 -1
  77. package/src/core/server-features.ts +11 -0
  78. package/src/core/sources/normalize.ts +10 -2
  79. package/src/core/types.ts +10 -3
  80. package/src/deploy/vercel-negotiation.ts +34 -14
  81. package/src/og/card.ts +3 -1
  82. package/src/openapi/model.ts +7 -0
  83. package/src/openapi/proxy.ts +217 -0
  84. package/src/openapi/references.ts +8 -0
  85. package/src/openapi/source.ts +13 -0
  86. package/src/registry/eject.ts +4 -5
  87. package/src/search/orama-index.ts +109 -36
  88. package/src/theme/entry.ts +15 -2
  89. 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
+ };
@@ -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-tight`. */
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 { existsSync } from "node:fs";
1
+ import { readdirSync, statSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
 
4
- import { dirname, extname, relative, resolve } from "pathe";
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 (!isRelativeTarget(target)) {
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 existsSync(abs) ? abs : null;
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
- /** CSS variable names for the configured fonts (Astro `<Font>` integration). */
196
- fontCssVars: string[];
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
- target: string,
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 (`![](./diagram.png)`) 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, target, site, ctx);
308
+ return checkPathLink(resolved, fragment, page, link, site, ctx);
283
309
  };
284
310
 
285
311
  /**
@@ -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.path === rootTabPath ? [] : [tab.path]))
958
+ tabs.flatMap((tab) => (isRootTab(tab, rootTabPath) ? [] : [tab.path]))
934
959
  ),
935
960
  diagnostics
936
961
  );