blume 1.4.0 → 1.4.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/dist/cli/index.js +328 -644
- package/dist/cli/index.js.map +35 -35
- package/dist/types/core/data.d.ts +10 -0
- package/docs/configuration/ai.mdx +15 -1
- package/package.json +28 -7
- package/src/ai/component-markdown.ts +7 -6
- package/src/ai/link-headers.ts +7 -2
- package/src/astro/generate.ts +8 -13
- package/src/astro/islands.ts +4 -1
- package/src/astro/templates.ts +5 -4
- package/src/audit/checks/indexability.ts +3 -6
- package/src/audit/checks/robots.ts +18 -37
- package/src/audit/crawl.ts +49 -49
- package/src/audit/image-size.ts +13 -53
- package/src/audit/report.ts +22 -33
- package/src/audit/types.ts +6 -2
- package/src/cli/commands/dev.ts +9 -21
- package/src/cli/commands/doctor.ts +9 -22
- package/src/cli/env.ts +6 -52
- package/src/cli/init/scaffold.ts +15 -28
- package/src/cli/internal-error.ts +11 -11
- package/src/components/islands/ask-ai.tsx +25 -100
- package/src/components/islands/hooks.ts +10 -3
- package/src/components/layout/RootLayout.astro +78 -109
- package/src/components/layout/Search.astro +3 -5
- package/src/components/layout/search/types.ts +4 -16
- package/src/components/openapi/helpers.ts +21 -75
- package/src/core/component-overrides.ts +0 -7
- package/src/core/config.ts +3 -3
- package/src/core/data.ts +7 -0
- package/src/core/diagnostics.ts +10 -20
- package/src/core/fs-atomic.ts +22 -0
- package/src/core/sources/github-releases.ts +29 -26
- package/src/core/sources/mdx-remote.ts +10 -57
- package/src/core/sources/notion.ts +17 -23
- package/src/core/tsconfig-aliases.ts +39 -172
- package/src/deploy/rss.ts +4 -1
- package/src/deploy/sitemap.ts +3 -1
- package/src/eval/report.ts +20 -28
- package/src/markdown/directives.ts +6 -18
- package/src/markdown/index.ts +1 -6
- package/src/markdown/package-commands.ts +0 -4
- package/src/openapi/parse.ts +11 -9
- package/src/search/popular-icon.ts +3 -3
- package/src/translate/ledger.ts +5 -11
- package/src/translate/report.ts +22 -28
- package/src/translate/run.ts +5 -24
- package/src/translate/work-list.ts +0 -0
- package/src/deploy/xml.ts +0 -8
|
@@ -144,6 +144,12 @@ const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
|
|
|
144
144
|
export interface UseAskAIOptions {
|
|
145
145
|
/** Existing Ask AI endpoint; defaults to Blume's generated `/api/ask`. */
|
|
146
146
|
endpoint?: string;
|
|
147
|
+
/**
|
|
148
|
+
* Shown as the assistant's answer when the request fails or throws.
|
|
149
|
+
* Defaults to an English notice; the built-in island passes its localized
|
|
150
|
+
* dictionary string.
|
|
151
|
+
*/
|
|
152
|
+
errorMessage?: string;
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
/** Shown as the assistant's answer when the request fails or throws. */
|
|
@@ -159,6 +165,7 @@ const currentPath = (): string =>
|
|
|
159
165
|
*/
|
|
160
166
|
export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
161
167
|
const endpoint = options.endpoint ?? DEFAULT_ASK_ENDPOINT;
|
|
168
|
+
const errorMessage = options.errorMessage ?? ASK_ERROR;
|
|
162
169
|
const [messages, setMessages] = useState<AskMessage[]>([]);
|
|
163
170
|
const [loading, setLoading] = useState(false);
|
|
164
171
|
// The stream writes into the conversation via state updates, so `reset()`
|
|
@@ -205,7 +212,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
|
205
212
|
// An error body (JSON, HTML error page) must not stream in as the
|
|
206
213
|
// assistant's answer.
|
|
207
214
|
if (live()) {
|
|
208
|
-
assistant.content =
|
|
215
|
+
assistant.content = errorMessage;
|
|
209
216
|
setMessages([...history, { ...assistant }]);
|
|
210
217
|
}
|
|
211
218
|
return;
|
|
@@ -236,7 +243,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
|
236
243
|
// pre-appended empty assistant message as a stuck placeholder. A
|
|
237
244
|
// reset's abort lands here too — the guard keeps it silent.
|
|
238
245
|
if (live()) {
|
|
239
|
-
assistant.content =
|
|
246
|
+
assistant.content = errorMessage;
|
|
240
247
|
setMessages([...history, { ...assistant }]);
|
|
241
248
|
}
|
|
242
249
|
} finally {
|
|
@@ -245,7 +252,7 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
|
|
|
245
252
|
}
|
|
246
253
|
}
|
|
247
254
|
},
|
|
248
|
-
[endpoint, loading, messages]
|
|
255
|
+
[endpoint, errorMessage, loading, messages]
|
|
249
256
|
);
|
|
250
257
|
|
|
251
258
|
// Retained for the compiler-off opt-out path (`react: { compiler: false }`):
|
|
@@ -117,6 +117,14 @@ interface Props {
|
|
|
117
117
|
exportPdf?: boolean;
|
|
118
118
|
exportEpub?: boolean;
|
|
119
119
|
feeds?: { title: string; href: string }[];
|
|
120
|
+
/**
|
|
121
|
+
* Which agent-discovery resources exist, advertised as `describedby` head
|
|
122
|
+
* links on every page — so an agent entering on a deep page (a search
|
|
123
|
+
* result, a shared link) finds the machine-readable surface without probing
|
|
124
|
+
* the site root. The HTML counterpart of the homepage-only HTTP `Link`
|
|
125
|
+
* header (see `ai/link-headers.ts`).
|
|
126
|
+
*/
|
|
127
|
+
discovery?: { agentReadability: boolean; llmsTxt: boolean } | null;
|
|
120
128
|
siteUrl?: string | null;
|
|
121
129
|
pageType?: string;
|
|
122
130
|
published?: string | Date | null;
|
|
@@ -198,6 +206,7 @@ const {
|
|
|
198
206
|
exportPdf,
|
|
199
207
|
exportEpub,
|
|
200
208
|
feeds,
|
|
209
|
+
discovery,
|
|
201
210
|
siteUrl,
|
|
202
211
|
pageType,
|
|
203
212
|
published,
|
|
@@ -312,6 +321,15 @@ const formattedLastModified =
|
|
|
312
321
|
).format(lastModifiedDate)
|
|
313
322
|
: null;
|
|
314
323
|
|
|
324
|
+
// This page's raw-Markdown mirror, advertised as a `text/markdown` alternate
|
|
325
|
+
// in the head. Content routes always have one (see `markdownRoutePaths`, which
|
|
326
|
+
// serves the same `route === "/" ? "/index.md" : "<route>.md"` mapping as the
|
|
327
|
+
// PageActions menu); the generated changelog index — the only "bare" page — is
|
|
328
|
+
// not a content route and has none.
|
|
329
|
+
const markdownMirror = isBare
|
|
330
|
+
? null
|
|
331
|
+
: withBase(page.route === "/" ? "/index.md" : `${page.route}.md`);
|
|
332
|
+
|
|
315
333
|
// The hosted MCP server's absolute URL, used by the page-actions install menu.
|
|
316
334
|
// Needs a configured site to be useful, so the menu is hidden without one.
|
|
317
335
|
const mcpUrl =
|
|
@@ -428,6 +446,29 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
428
446
|
/>
|
|
429
447
|
))
|
|
430
448
|
}
|
|
449
|
+
{/* Agent discovery on every page, not just the root: an agent that enters
|
|
450
|
+
on a deep page (a search result, a shared link) never sees the homepage
|
|
451
|
+
HTTP Link header, so the head carries the same describedby links plus
|
|
452
|
+
this page's own raw-Markdown mirror. Both rels are IANA-registered. */}
|
|
453
|
+
{
|
|
454
|
+
discovery?.agentReadability && (
|
|
455
|
+
<link
|
|
456
|
+
href={withBase("/agent-readability.json")}
|
|
457
|
+
rel="describedby"
|
|
458
|
+
type="application/json"
|
|
459
|
+
/>
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
{
|
|
463
|
+
discovery?.llmsTxt && (
|
|
464
|
+
<link href={withBase("/llms.txt")} rel="describedby" type="text/plain" />
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
{
|
|
468
|
+
markdownMirror && (
|
|
469
|
+
<link href={markdownMirror} rel="alternate" type="text/markdown" />
|
|
470
|
+
)
|
|
471
|
+
}
|
|
431
472
|
{
|
|
432
473
|
structuredDataJson && (
|
|
433
474
|
<script
|
|
@@ -847,122 +888,50 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
847
888
|
pre.appendChild(button);
|
|
848
889
|
}
|
|
849
890
|
|
|
850
|
-
// Click-to-zoom for content images (gated by `markdown.imageZoom`)
|
|
851
|
-
//
|
|
891
|
+
// Click-to-zoom for content images (gated by `markdown.imageZoom`),
|
|
892
|
+
// via medium-zoom: ESC/scroll/click dismissal, natural-size capping,
|
|
893
|
+
// and the open/close transition races are its problem, not ours.
|
|
852
894
|
// Opt out per-image with `data-no-zoom`.
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
: [];
|
|
859
|
-
const zoomTargets = Array.from(zoomImages);
|
|
860
|
-
if (zoomTargets.length > 0) {
|
|
861
|
-
const reduceMotion = window.matchMedia(
|
|
862
|
-
"(prefers-reduced-motion: reduce)"
|
|
863
|
-
).matches;
|
|
864
|
-
let active: {
|
|
865
|
-
clone: HTMLImageElement;
|
|
866
|
-
original: HTMLImageElement;
|
|
867
|
-
overlay: HTMLElement;
|
|
868
|
-
} | null = null;
|
|
869
|
-
|
|
870
|
-
const closeZoom = () => {
|
|
871
|
-
if (!active) {
|
|
872
|
-
return;
|
|
873
|
-
}
|
|
874
|
-
const { clone, original, overlay } = active;
|
|
875
|
-
active = null;
|
|
876
|
-
overlay.style.opacity = "0";
|
|
877
|
-
clone.style.transform = "translate(0px, 0px) scale(1)";
|
|
878
|
-
const cleanup = () => {
|
|
879
|
-
overlay.remove();
|
|
880
|
-
original.style.visibility = "";
|
|
881
|
-
document.removeEventListener("keydown", onKey);
|
|
882
|
-
window.removeEventListener("scroll", closeZoom);
|
|
883
|
-
};
|
|
884
|
-
if (reduceMotion) {
|
|
885
|
-
cleanup();
|
|
886
|
-
} else {
|
|
887
|
-
clone.addEventListener("transitionend", cleanup, { once: true });
|
|
888
|
-
}
|
|
889
|
-
};
|
|
890
|
-
|
|
891
|
-
const onKey = (event: KeyboardEvent) => {
|
|
892
|
-
if (event.key === "Escape") {
|
|
893
|
-
closeZoom();
|
|
894
|
-
}
|
|
895
|
-
};
|
|
896
|
-
|
|
897
|
-
const openZoom = (image: HTMLImageElement) => {
|
|
898
|
-
if (active) {
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
const start = image.getBoundingClientRect();
|
|
902
|
-
if (start.width === 0 || start.height === 0) {
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
const overlay = document.createElement("div");
|
|
907
|
-
overlay.className =
|
|
908
|
-
"fixed inset-0 z-[100] flex cursor-zoom-out items-center justify-center bg-background/80 opacity-0 backdrop-blur-sm transition-opacity duration-300";
|
|
909
|
-
|
|
910
|
-
const clone = image.cloneNode(true) as HTMLImageElement;
|
|
911
|
-
clone.className = "absolute m-0 max-w-none rounded-blume shadow-2xl";
|
|
912
|
-
clone.style.top = `${start.top}px`;
|
|
913
|
-
clone.style.left = `${start.left}px`;
|
|
914
|
-
clone.style.width = `${start.width}px`;
|
|
915
|
-
clone.style.height = `${start.height}px`;
|
|
916
|
-
clone.style.transformOrigin = "top left";
|
|
917
|
-
if (!reduceMotion) {
|
|
918
|
-
clone.style.transition =
|
|
919
|
-
"transform 300ms cubic-bezier(0.22, 1, 0.36, 1)";
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
overlay.appendChild(clone);
|
|
923
|
-
document.body.appendChild(overlay);
|
|
924
|
-
image.style.visibility = "hidden";
|
|
925
|
-
active = { clone, original: image, overlay };
|
|
926
|
-
|
|
927
|
-
const margin = 0.92;
|
|
928
|
-
const ratio = start.width / start.height;
|
|
929
|
-
let targetWidth = window.innerWidth * margin;
|
|
930
|
-
let targetHeight = targetWidth / ratio;
|
|
931
|
-
if (targetHeight > window.innerHeight * margin) {
|
|
932
|
-
targetHeight = window.innerHeight * margin;
|
|
933
|
-
targetWidth = targetHeight * ratio;
|
|
934
|
-
}
|
|
935
|
-
const naturalWidth = image.naturalWidth || targetWidth;
|
|
936
|
-
if (targetWidth > naturalWidth) {
|
|
937
|
-
targetWidth = naturalWidth;
|
|
938
|
-
targetHeight = targetWidth / ratio;
|
|
939
|
-
}
|
|
940
|
-
const scale = targetWidth / start.width;
|
|
941
|
-
const dx = (window.innerWidth - targetWidth) / 2 - start.left;
|
|
942
|
-
const dy = (window.innerHeight - targetHeight) / 2 - start.top;
|
|
943
|
-
|
|
944
|
-
requestAnimationFrame(() => {
|
|
945
|
-
overlay.style.opacity = "1";
|
|
946
|
-
clone.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
|
947
|
-
});
|
|
948
|
-
|
|
949
|
-
overlay.addEventListener("click", closeZoom);
|
|
950
|
-
document.addEventListener("keydown", onKey);
|
|
951
|
-
window.addEventListener("scroll", closeZoom, { passive: true });
|
|
952
|
-
};
|
|
953
|
-
|
|
954
|
-
for (const image of zoomTargets) {
|
|
895
|
+
if (document.body.hasAttribute("data-blume-image-zoom")) {
|
|
896
|
+
const zoomTargets = Array.from(
|
|
897
|
+
document.querySelectorAll<HTMLImageElement>(
|
|
898
|
+
".prose img:not([data-no-zoom])"
|
|
899
|
+
)
|
|
955
900
|
// An image that is itself a link navigates on click — binding zoom
|
|
956
901
|
// to it would flash a zoom overlay in the instant before navigation
|
|
957
902
|
// and advertise (via the cursor) a zoom that never happens.
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
903
|
+
).filter((image) => !image.closest("a"));
|
|
904
|
+
if (zoomTargets.length > 0) {
|
|
905
|
+
// Lazy: pages without a zoomable image never load the library,
|
|
906
|
+
// matching how mermaid is only fetched on pages with a diagram.
|
|
907
|
+
const { default: mediumZoom } = await import("medium-zoom");
|
|
908
|
+
mediumZoom(zoomTargets, {
|
|
909
|
+
background:
|
|
910
|
+
"color-mix(in oklab, var(--color-background) 80%, transparent)",
|
|
911
|
+
margin: 24,
|
|
912
|
+
});
|
|
963
913
|
}
|
|
964
914
|
}
|
|
965
915
|
</script>
|
|
916
|
+
<style is:global>
|
|
917
|
+
/* medium-zoom ships no z-index; lift the lightbox above the chrome
|
|
918
|
+
(header/sidebar) the way the previous z-[100] overlay sat. */
|
|
919
|
+
.medium-zoom-overlay {
|
|
920
|
+
backdrop-filter: blur(4px);
|
|
921
|
+
z-index: 100;
|
|
922
|
+
}
|
|
923
|
+
.medium-zoom-image--opened {
|
|
924
|
+
z-index: 101;
|
|
925
|
+
}
|
|
926
|
+
@media (prefers-reduced-motion: reduce) {
|
|
927
|
+
/* html prefix outranks the library's injected rules regardless of
|
|
928
|
+
insertion order; its transition declarations carry !important. */
|
|
929
|
+
html .medium-zoom-image,
|
|
930
|
+
html .medium-zoom-overlay {
|
|
931
|
+
transition: none !important;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
</style>
|
|
966
935
|
<WebMcp />
|
|
967
936
|
</body>
|
|
968
937
|
</html>
|
|
@@ -171,11 +171,9 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
171
171
|
<script>
|
|
172
172
|
import { chromeIcons as icons } from "../../theme/chrome-icons.ts";
|
|
173
173
|
import { prefixBase } from "../islands/base-path.ts";
|
|
174
|
-
import {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
matchSnippet,
|
|
178
|
-
} from "./search/types.ts";
|
|
174
|
+
import { escape as escapeHtml } from "html-escaper";
|
|
175
|
+
|
|
176
|
+
import { highlight, matchSnippet } from "./search/types.ts";
|
|
179
177
|
import type { SearchFn, SearchHit } from "./search/types.ts";
|
|
180
178
|
|
|
181
179
|
interface Selectable {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { escape } from "html-escaper";
|
|
2
|
+
|
|
1
3
|
/** A single result rendered in the search dialog. */
|
|
2
4
|
export interface SearchHit {
|
|
3
5
|
url: string;
|
|
@@ -51,21 +53,9 @@ export const SEARCH_LIMIT = 12;
|
|
|
51
53
|
*/
|
|
52
54
|
export const RESULT_POOL = 48;
|
|
53
55
|
|
|
54
|
-
const HTML_ESCAPES: Record<string, string> = {
|
|
55
|
-
'"': """,
|
|
56
|
-
"&": "&",
|
|
57
|
-
"'": "'",
|
|
58
|
-
"<": "<",
|
|
59
|
-
">": ">",
|
|
60
|
-
};
|
|
61
|
-
const HTML_CHARS = /["&'<>]/gu;
|
|
62
56
|
const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
|
|
63
57
|
const WORD_BREAK = /\s+/u;
|
|
64
58
|
|
|
65
|
-
/** Escape HTML so untrusted text renders literally inside the dialog. */
|
|
66
|
-
export const escapeHtml = (text: string): string =>
|
|
67
|
-
text.replaceAll(HTML_CHARS, (char) => HTML_ESCAPES[char] ?? char);
|
|
68
|
-
|
|
69
59
|
/** Split a query into escaped, non-empty search tokens. */
|
|
70
60
|
const queryTokens = (query: string): string[] =>
|
|
71
61
|
query
|
|
@@ -83,15 +73,13 @@ const queryTokens = (query: string): string[] =>
|
|
|
83
73
|
export const highlight = (text: string, query: string): string => {
|
|
84
74
|
const tokens = queryTokens(query);
|
|
85
75
|
if (tokens.length === 0) {
|
|
86
|
-
return
|
|
76
|
+
return escape(text);
|
|
87
77
|
}
|
|
88
78
|
const pattern = new RegExp(`(${tokens.join("|")})`, "giu");
|
|
89
79
|
return text
|
|
90
80
|
.split(pattern)
|
|
91
81
|
.map((segment, index) =>
|
|
92
|
-
index % 2 === 1
|
|
93
|
-
? `<mark>${escapeHtml(segment)}</mark>`
|
|
94
|
-
: escapeHtml(segment)
|
|
82
|
+
index % 2 === 1 ? `<mark>${escape(segment)}</mark>` : escape(segment)
|
|
95
83
|
)
|
|
96
84
|
.join("");
|
|
97
85
|
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { sample } from "openapi-sampler";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Runtime helpers for the OpenAPI components. These operate on the parsed spec
|
|
3
5
|
* behind the `blume:openapi` alias — resolving `$ref`s (kept intact at parse
|
|
4
6
|
* time to avoid circular graphs), labelling types, and generating request
|
|
5
|
-
* examples and code samples.
|
|
6
|
-
*
|
|
7
|
+
* examples and code samples. Browser-safe (no server-only imports); example
|
|
8
|
+
* values come from openapi-sampler, which is likewise browser-safe.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
/** A permissive view of an OpenAPI 3.1 schema — only the fields we render. */
|
|
@@ -226,88 +228,32 @@ export const objectProperties = (
|
|
|
226
228
|
return { properties: [...properties.entries()], required };
|
|
227
229
|
};
|
|
228
230
|
|
|
229
|
-
/** Sentinel: no explicit example is declared on a schema. */
|
|
230
|
-
const NO_VALUE = Symbol("no-value");
|
|
231
|
-
|
|
232
|
-
/** The declared example/const/default/enum for a schema, or {@link NO_VALUE}. */
|
|
233
|
-
const explicitExample = (schema: SchemaLike): unknown => {
|
|
234
|
-
if (schema.example !== undefined) {
|
|
235
|
-
return schema.example;
|
|
236
|
-
}
|
|
237
|
-
if (Array.isArray(schema.examples) && schema.examples.length > 0) {
|
|
238
|
-
return schema.examples[0];
|
|
239
|
-
}
|
|
240
|
-
// `const` is the schema's only valid value (the 3.1 discriminator idiom), so
|
|
241
|
-
// it outranks `default`/`enum` — either of those differing would be invalid.
|
|
242
|
-
if (schema.const !== undefined) {
|
|
243
|
-
return schema.const;
|
|
244
|
-
}
|
|
245
|
-
if (schema.default !== undefined) {
|
|
246
|
-
return schema.default;
|
|
247
|
-
}
|
|
248
|
-
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
249
|
-
return schema.enum[0];
|
|
250
|
-
}
|
|
251
|
-
return NO_VALUE;
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
/** A placeholder value for a primitive (leaf) schema. */
|
|
255
|
-
const primitiveExample = (
|
|
256
|
-
types: string[],
|
|
257
|
-
format: string | undefined
|
|
258
|
-
): unknown => {
|
|
259
|
-
if (types.includes("number") || types.includes("integer")) {
|
|
260
|
-
return 0;
|
|
261
|
-
}
|
|
262
|
-
if (types.includes("boolean")) {
|
|
263
|
-
return true;
|
|
264
|
-
}
|
|
265
|
-
if (format === "date-time") {
|
|
266
|
-
return "2024-01-01T00:00:00Z";
|
|
267
|
-
}
|
|
268
|
-
return format ? `<${format}>` : "string";
|
|
269
|
-
};
|
|
270
|
-
|
|
271
231
|
/**
|
|
272
|
-
* Build a representative example value for a schema
|
|
273
|
-
*
|
|
274
|
-
*
|
|
232
|
+
* Build a representative example value for a schema via openapi-sampler
|
|
233
|
+
* (Redoc's generator): declared `example`/`const`/`default`/`enum` values
|
|
234
|
+
* win, formats produce realistic placeholders (`email`, `uuid`, `date-time`),
|
|
235
|
+
* `readOnly` fields are skipped (these samples illustrate *requests*, and a
|
|
236
|
+
* server-generated field has no place in one), and circular `$ref` chains —
|
|
237
|
+
* which keeping refs intact allows — terminate safely.
|
|
275
238
|
*/
|
|
276
239
|
export const exampleValue = (
|
|
277
240
|
schema: SchemaLike | undefined,
|
|
278
|
-
schemas: Record<string, SchemaLike
|
|
279
|
-
seen = new Set<string>()
|
|
241
|
+
schemas: Record<string, SchemaLike>
|
|
280
242
|
): unknown => {
|
|
281
243
|
if (!schema) {
|
|
282
244
|
return null;
|
|
283
245
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return
|
|
294
|
-
}
|
|
295
|
-
const branch = schema.oneOf?.[0] ?? schema.anyOf?.[0];
|
|
296
|
-
if (branch) {
|
|
297
|
-
return exampleValue(branch, schemas, seen);
|
|
298
|
-
}
|
|
299
|
-
const types = nonNullTypes(schema.type);
|
|
300
|
-
if (types.includes("array")) {
|
|
301
|
-
return [exampleValue(schema.items, schemas, seen)];
|
|
302
|
-
}
|
|
303
|
-
if (types.includes("object") || schema.properties || schema.allOf) {
|
|
304
|
-
const out: Record<string, unknown> = {};
|
|
305
|
-
for (const [name, prop] of objectProperties(schema, schemas).properties) {
|
|
306
|
-
out[name] = exampleValue(prop, schemas, new Set(seen));
|
|
307
|
-
}
|
|
308
|
-
return out;
|
|
246
|
+
try {
|
|
247
|
+
return sample(
|
|
248
|
+
schema as Parameters<typeof sample>[0],
|
|
249
|
+
{ quiet: true, skipReadOnly: true },
|
|
250
|
+
{ components: { schemas } }
|
|
251
|
+
);
|
|
252
|
+
} catch {
|
|
253
|
+
// An unresolvable $ref or malformed schema is a spec problem the schema
|
|
254
|
+
// tables already surface; a sample is best-effort.
|
|
255
|
+
return null;
|
|
309
256
|
}
|
|
310
|
-
return primitiveExample(types, schema.format);
|
|
311
257
|
};
|
|
312
258
|
|
|
313
259
|
/** Pretty-print a JSON value for an example/code block. */
|
|
@@ -328,13 +328,6 @@ const finalize = (
|
|
|
328
328
|
);
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
if (client && !source) {
|
|
332
|
-
warnings.push(
|
|
333
|
-
`Override "${key}" declares client: "${client}" but its component couldn't be resolved to a file, so it can't hydrate. Reference it by an imported component or a path string.`
|
|
334
|
-
);
|
|
335
|
-
return { identifier, key, source: null };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
331
|
if (!client && source?.framework) {
|
|
339
332
|
warnings.push(
|
|
340
333
|
`Override "${key}" points to a ${FRAMEWORK_LABEL[source.framework]} component (${label}) but has no hydration mode, so it renders as static HTML with no interactivity. Add one, e.g. \`${key}: { component: ${JSON.stringify(label)}, client: "load" }\`.`
|
package/src/core/config.ts
CHANGED
|
@@ -219,14 +219,14 @@ export const loadConfig = async (
|
|
|
219
219
|
// Surface every issue in one failing run — reporting only the first turns
|
|
220
220
|
// a three-mistake config into three fix-rerun-fail loops.
|
|
221
221
|
const moreIssues = rest.map((d) => ` - ${d.message}`).join("\n");
|
|
222
|
-
|
|
222
|
+
const detail =
|
|
223
223
|
rest.length > 0
|
|
224
224
|
? {
|
|
225
225
|
...primary,
|
|
226
226
|
message: `${primary.message}\n${rest.length} more config issue(s):\n${moreIssues}`,
|
|
227
227
|
}
|
|
228
|
-
: primary
|
|
229
|
-
);
|
|
228
|
+
: primary;
|
|
229
|
+
throw new BlumeError(detail);
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
// Resolve the canonical site URL, then SEO defaults that depend on it.
|
package/src/core/data.ts
CHANGED
|
@@ -107,6 +107,13 @@ export interface BlumeDataConfig {
|
|
|
107
107
|
/** `dateFormat`: `Intl.DateTimeFormat` options for the date stamps. */
|
|
108
108
|
dateFormat: ResolvedConfig["dateFormat"];
|
|
109
109
|
description: string | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* Which agent-discovery resources exist for the layout to advertise in every
|
|
112
|
+
* page's `<head>` (`seo.agentReadability`, `ai.llmsTxt.enabled`) — the HTML
|
|
113
|
+
* counterpart of the homepage-only HTTP `Link` header, for agents that enter
|
|
114
|
+
* on a deep page (see `ai/link-headers.ts`).
|
|
115
|
+
*/
|
|
116
|
+
discovery: { agentReadability: boolean; llmsTxt: boolean };
|
|
110
117
|
favicon: BlumeFavicon;
|
|
111
118
|
feedback: boolean;
|
|
112
119
|
i18n: BlumeDataI18n | null;
|
package/src/core/diagnostics.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { colors } from "consola/utils";
|
|
1
2
|
import { relative } from "pathe";
|
|
2
3
|
import type { ZodError } from "zod";
|
|
3
4
|
|
|
@@ -196,25 +197,14 @@ export const diagnosticsFromZod = (
|
|
|
196
197
|
options
|
|
197
198
|
);
|
|
198
199
|
|
|
199
|
-
const
|
|
200
|
-
const COLORS = {
|
|
201
|
-
blue: `${ESC}[34m`,
|
|
202
|
-
bold: `${ESC}[1m`,
|
|
203
|
-
cyan: `${ESC}[36m`,
|
|
204
|
-
dim: `${ESC}[2m`,
|
|
205
|
-
red: `${ESC}[31m`,
|
|
206
|
-
reset: `${ESC}[0m`,
|
|
207
|
-
yellow: `${ESC}[33m`,
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const severityColor = (severity: Diagnostic["severity"]): string => {
|
|
200
|
+
const severityColor = (severity: Diagnostic["severity"]) => {
|
|
211
201
|
if (severity === "error") {
|
|
212
|
-
return
|
|
202
|
+
return colors.red;
|
|
213
203
|
}
|
|
214
204
|
if (severity === "warning") {
|
|
215
|
-
return
|
|
205
|
+
return colors.yellow;
|
|
216
206
|
}
|
|
217
|
-
return
|
|
207
|
+
return colors.blue;
|
|
218
208
|
};
|
|
219
209
|
|
|
220
210
|
/** Format a single diagnostic for terminal output. */
|
|
@@ -224,14 +214,14 @@ export const formatDiagnostic = (
|
|
|
224
214
|
): string => {
|
|
225
215
|
const color = severityColor(diagnostic.severity);
|
|
226
216
|
const lines: string[] = [
|
|
227
|
-
`${color
|
|
217
|
+
`${color(colors.bold(diagnostic.code))} ${diagnostic.message}`,
|
|
228
218
|
];
|
|
229
219
|
|
|
230
220
|
// An audit finding is about a built URL, and names the source file that fixes
|
|
231
221
|
// it as a second line ("at /docs/api" / "in docs/api.mdx:3:2"). Everything
|
|
232
222
|
// else is about a file alone, and keeps the original single `at file` line.
|
|
233
223
|
if (diagnostic.url) {
|
|
234
|
-
lines.push(` ${
|
|
224
|
+
lines.push(` ${colors.dim(`at ${diagnostic.url}`)}`);
|
|
235
225
|
}
|
|
236
226
|
if (diagnostic.file) {
|
|
237
227
|
const location = root ? relative(root, diagnostic.file) : diagnostic.file;
|
|
@@ -240,15 +230,15 @@ export const formatDiagnostic = (
|
|
|
240
230
|
const position =
|
|
241
231
|
diagnostic.line === undefined ? "" : `:${diagnostic.line}${column}`;
|
|
242
232
|
const label = diagnostic.url ? "in" : "at";
|
|
243
|
-
lines.push(` ${
|
|
233
|
+
lines.push(` ${colors.dim(`${label} ${location}${position}`)}`);
|
|
244
234
|
}
|
|
245
235
|
|
|
246
236
|
if (diagnostic.suggestion) {
|
|
247
|
-
lines.push(` ${
|
|
237
|
+
lines.push(` ${colors.cyan(`fix: ${diagnostic.suggestion}`)}`);
|
|
248
238
|
}
|
|
249
239
|
|
|
250
240
|
if (diagnostic.docsUrl) {
|
|
251
|
-
lines.push(` ${
|
|
241
|
+
lines.push(` ${colors.dim(`docs: ${diagnostic.docsUrl}`)}`);
|
|
252
242
|
}
|
|
253
243
|
|
|
254
244
|
return lines.join("\n");
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { dirname } from "pathe";
|
|
4
|
+
import writeFileAtomic from "write-file-atomic";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Write text to `path` atomically (unique temp file + rename) after ensuring
|
|
8
|
+
* the parent directory exists, so a concurrent reader or file watcher never
|
|
9
|
+
* observes a missing or half-written file. write-file-atomic's temp names are
|
|
10
|
+
* unique per call — a pid-suffixed temp name is not, and two concurrent
|
|
11
|
+
* writers to the same target in one process (translate lanes, staged-content
|
|
12
|
+
* writes) would interleave through a shared temp file. `fsync` is off to
|
|
13
|
+
* match the previous behavior: the point is watcher atomicity, not crash
|
|
14
|
+
* durability, and a per-file fsync would slow dev regeneration.
|
|
15
|
+
*/
|
|
16
|
+
export const writeTextAtomic = async (
|
|
17
|
+
path: string,
|
|
18
|
+
text: string
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
await mkdir(dirname(path), { recursive: true });
|
|
21
|
+
await writeFileAtomic(path, text, { encoding: "utf-8", fsync: false });
|
|
22
|
+
};
|