blume 1.4.0 → 1.4.1
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 +22 -0
- package/dist/cli/index.js +322 -644
- package/dist/cli/index.js.map +34 -34
- package/package.json +22 -1
- package/src/ai/component-markdown.ts +7 -6
- package/src/astro/generate.ts +4 -13
- package/src/astro/islands.ts +4 -1
- package/src/astro/templates.ts +3 -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 +37 -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/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
|
@@ -847,122 +847,50 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
847
847
|
pre.appendChild(button);
|
|
848
848
|
}
|
|
849
849
|
|
|
850
|
-
// Click-to-zoom for content images (gated by `markdown.imageZoom`)
|
|
851
|
-
//
|
|
850
|
+
// Click-to-zoom for content images (gated by `markdown.imageZoom`),
|
|
851
|
+
// via medium-zoom: ESC/scroll/click dismissal, natural-size capping,
|
|
852
|
+
// and the open/close transition races are its problem, not ours.
|
|
852
853
|
// 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) {
|
|
854
|
+
if (document.body.hasAttribute("data-blume-image-zoom")) {
|
|
855
|
+
const zoomTargets = Array.from(
|
|
856
|
+
document.querySelectorAll<HTMLImageElement>(
|
|
857
|
+
".prose img:not([data-no-zoom])"
|
|
858
|
+
)
|
|
955
859
|
// An image that is itself a link navigates on click — binding zoom
|
|
956
860
|
// to it would flash a zoom overlay in the instant before navigation
|
|
957
861
|
// and advertise (via the cursor) a zoom that never happens.
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
862
|
+
).filter((image) => !image.closest("a"));
|
|
863
|
+
if (zoomTargets.length > 0) {
|
|
864
|
+
// Lazy: pages without a zoomable image never load the library,
|
|
865
|
+
// matching how mermaid is only fetched on pages with a diagram.
|
|
866
|
+
const { default: mediumZoom } = await import("medium-zoom");
|
|
867
|
+
mediumZoom(zoomTargets, {
|
|
868
|
+
background:
|
|
869
|
+
"color-mix(in oklab, var(--color-background) 80%, transparent)",
|
|
870
|
+
margin: 24,
|
|
871
|
+
});
|
|
963
872
|
}
|
|
964
873
|
}
|
|
965
874
|
</script>
|
|
875
|
+
<style is:global>
|
|
876
|
+
/* medium-zoom ships no z-index; lift the lightbox above the chrome
|
|
877
|
+
(header/sidebar) the way the previous z-[100] overlay sat. */
|
|
878
|
+
.medium-zoom-overlay {
|
|
879
|
+
backdrop-filter: blur(4px);
|
|
880
|
+
z-index: 100;
|
|
881
|
+
}
|
|
882
|
+
.medium-zoom-image--opened {
|
|
883
|
+
z-index: 101;
|
|
884
|
+
}
|
|
885
|
+
@media (prefers-reduced-motion: reduce) {
|
|
886
|
+
/* html prefix outranks the library's injected rules regardless of
|
|
887
|
+
insertion order; its transition declarations carry !important. */
|
|
888
|
+
html .medium-zoom-image,
|
|
889
|
+
html .medium-zoom-overlay {
|
|
890
|
+
transition: none !important;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
</style>
|
|
966
894
|
<WebMcp />
|
|
967
895
|
</body>
|
|
968
896
|
</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/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
|
+
};
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
2
|
+
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
3
|
+
import { toString as mdastToString } from "mdast-util-to-string";
|
|
4
|
+
import { gfm } from "micromark-extension-gfm";
|
|
5
|
+
|
|
1
6
|
import matter from "../frontmatter.ts";
|
|
2
7
|
import {
|
|
3
8
|
hashText,
|
|
@@ -62,39 +67,37 @@ const EDGE_DASHES = /^-+|-+$/gu;
|
|
|
62
67
|
const DESCRIPTION_MAX = 160;
|
|
63
68
|
const DESCRIPTION_MIN = 110;
|
|
64
69
|
|
|
65
|
-
const CODE_FENCE = /```[\s\S]*?```/gu;
|
|
66
|
-
const HEADING_LINE = /^#{1,6}\s.*$/gmu;
|
|
67
|
-
const LIST_MARK = /^\s*(?:[-*+]|\d+[.)])\s+/u;
|
|
68
70
|
// Changesets-generated release bullets open with the changeset's short commit
|
|
69
|
-
// hash (`- cf8fa22: Fix …`) — noise in a search snippet.
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
|
|
73
|
-
const INLINE_CODE = /`(?<code>[^`]+)`/gu;
|
|
74
|
-
// Tag-shaped only: a bare `<` in prose must not swallow text up to a later `>`.
|
|
75
|
-
const HTML_OR_JSX = /<\/?[a-zA-Z][^\n<>]*>|<\/?>/gu;
|
|
76
|
-
const MARKDOWN_PUNCT = /[*_~>]+/gu;
|
|
71
|
+
// hash (`- cf8fa22: Fix …`) — noise in a search snippet. Stripped from the
|
|
72
|
+
// raw lines (where the bullet anchor still exists) before parsing.
|
|
73
|
+
const CHANGESET_HASH = /^(?<mark>\s*(?:[-*+]|\d+[.)])\s+)[0-9a-f]{7,40}:\s+/gmu;
|
|
77
74
|
const WHITESPACE = /\s+/gu;
|
|
78
75
|
const TRAILING_FRAGMENT = /[\s,;:.—–-]+$/u;
|
|
79
76
|
|
|
77
|
+
/** Block nodes with no place in a search snippet. */
|
|
78
|
+
const NON_PROSE = new Set(["code", "heading", "html", "thematicBreak"]);
|
|
79
|
+
|
|
80
80
|
/**
|
|
81
|
-
* Derive a meta description from release notes: markdown
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
81
|
+
* Derive a meta description from release notes: GitHub-flavored markdown
|
|
82
|
+
* parsed to mdast and reduced to the plain text of its prose blocks —
|
|
83
|
+
* section headings ("### Patch Changes"), code fences, and changesets'
|
|
84
|
+
* commit-hash bullet prefixes dropped; link/emphasis text and inline code
|
|
85
|
+
* content kept — then cut at a word boundary to fit the search snippet cap.
|
|
86
|
+
* Undefined when the notes have no prose at all.
|
|
85
87
|
*/
|
|
86
88
|
const releaseDescription = (body: string): string | undefined => {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
const tree = fromMarkdown(body.replaceAll(CHANGESET_HASH, "$<mark>"), {
|
|
90
|
+
extensions: [gfm()],
|
|
91
|
+
mdastExtensions: [gfmFromMarkdown()],
|
|
92
|
+
});
|
|
93
|
+
const text = tree.children
|
|
94
|
+
.filter((node) => !NON_PROSE.has(node.type))
|
|
95
|
+
// Images vanish (their alt is not prose) and raw HTML/JSX tags drop,
|
|
96
|
+
// matching what a reader of the rendered notes would see as text.
|
|
97
|
+
.map((node) =>
|
|
98
|
+
mdastToString(node, { includeHtml: false, includeImageAlt: false })
|
|
99
|
+
)
|
|
100
|
+
.join(" ")
|
|
98
101
|
.replaceAll(WHITESPACE, " ")
|
|
99
102
|
.trim();
|
|
100
103
|
if (!text) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import picomatch from "picomatch";
|
|
2
|
+
|
|
1
3
|
import { BlumeError } from "../diagnostics.ts";
|
|
2
4
|
import matter from "../frontmatter.ts";
|
|
3
5
|
import type { Diagnostic } from "../types.ts";
|
|
@@ -31,61 +33,10 @@ export interface MdxRemoteSourceOptions {
|
|
|
31
33
|
fetchImpl?: typeof fetch;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
REGEX_SPECIAL.test(char) ? `\\${char}` : char;
|
|
39
|
-
|
|
40
|
-
/** Translate one glob token at `i` into RegExp source + the next index. */
|
|
41
|
-
const globToken = (
|
|
42
|
-
pattern: string,
|
|
43
|
-
i: number
|
|
44
|
-
): { source: string; next: number } => {
|
|
45
|
-
const char = pattern[i] ?? "";
|
|
46
|
-
if (char === "*") {
|
|
47
|
-
if (pattern[i + 1] === "*") {
|
|
48
|
-
// `**/` spans zero or more whole segments — `docs/**/guide.md` must
|
|
49
|
-
// match `docs/guide.md` and `docs/a/guide.md` but not `docs/subguide.md`.
|
|
50
|
-
if (pattern[i + 2] === "/") {
|
|
51
|
-
return { next: i + 3, source: "(?:.*/)?" };
|
|
52
|
-
}
|
|
53
|
-
return { next: i + 2, source: ".*" };
|
|
54
|
-
}
|
|
55
|
-
return { next: i + 1, source: "[^/]*" };
|
|
56
|
-
}
|
|
57
|
-
if (char === "?") {
|
|
58
|
-
return { next: i + 1, source: "[^/]" };
|
|
59
|
-
}
|
|
60
|
-
if (char === "{") {
|
|
61
|
-
const end = pattern.indexOf("}", i);
|
|
62
|
-
if (end !== -1) {
|
|
63
|
-
const options = pattern
|
|
64
|
-
.slice(i + 1, end)
|
|
65
|
-
.split(",")
|
|
66
|
-
.map((part) => [...part].map(escapeChar).join(""))
|
|
67
|
-
.join("|");
|
|
68
|
-
return { next: end + 1, source: `(?:${options})` };
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return { next: i + 1, source: escapeChar(char) };
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
/** Compile a glob (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp. */
|
|
75
|
-
const globToRegExp = (pattern: string): RegExp => {
|
|
76
|
-
let source = "";
|
|
77
|
-
let i = 0;
|
|
78
|
-
while (i < pattern.length) {
|
|
79
|
-
const token = globToken(pattern, i);
|
|
80
|
-
source += token.source;
|
|
81
|
-
i = token.next;
|
|
82
|
-
}
|
|
83
|
-
return new RegExp(`^${source}$`, "u");
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
/** Whether a ref matches any of the include globs. */
|
|
87
|
-
const matchesInclude = (ref: string, patterns: string[]): boolean =>
|
|
88
|
-
patterns.some((pattern) => globToRegExp(pattern).test(ref));
|
|
36
|
+
// Include globs compile through picomatch — what the filesystem source's
|
|
37
|
+
// tinyglobby uses under the hood — so the same `include` array means the same
|
|
38
|
+
// thing on every source type: negation, character classes, nested braces, and
|
|
39
|
+
// extglobs included. Compiled once per enumeration, not per ref.
|
|
89
40
|
|
|
90
41
|
/** A file to fetch: its source-local ref plus where to read it from. */
|
|
91
42
|
interface RemoteRef {
|
|
@@ -136,12 +87,13 @@ const enumerateGithub = async (
|
|
|
136
87
|
truncated?: boolean;
|
|
137
88
|
};
|
|
138
89
|
const prefix = base ? `${base}/` : "";
|
|
90
|
+
const included = picomatch(include);
|
|
139
91
|
const refs = (body.tree ?? []).flatMap((node) => {
|
|
140
92
|
if (!(node.type === "blob" && node.path.startsWith(prefix))) {
|
|
141
93
|
return [];
|
|
142
94
|
}
|
|
143
95
|
const rel = node.path.slice(prefix.length);
|
|
144
|
-
if (!
|
|
96
|
+
if (!included(rel)) {
|
|
145
97
|
return [];
|
|
146
98
|
}
|
|
147
99
|
return [
|
|
@@ -193,8 +145,9 @@ export const mdxRemoteSource = (
|
|
|
193
145
|
return await enumerateGithub(options.github, options.include, doFetch);
|
|
194
146
|
}
|
|
195
147
|
const base = (options.url ?? "").replace(/\/$/u, "");
|
|
148
|
+
const included = picomatch(options.include);
|
|
196
149
|
const refs = (options.files ?? []).flatMap((ref) =>
|
|
197
|
-
|
|
150
|
+
included(ref)
|
|
198
151
|
? [{ editUrl: `${base}/${ref}`, fetchUrl: `${base}/${ref}`, ref }]
|
|
199
152
|
: []
|
|
200
153
|
);
|