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.
Files changed (46) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/cli/index.js +322 -644
  3. package/dist/cli/index.js.map +34 -34
  4. package/package.json +22 -1
  5. package/src/ai/component-markdown.ts +7 -6
  6. package/src/astro/generate.ts +4 -13
  7. package/src/astro/islands.ts +4 -1
  8. package/src/astro/templates.ts +3 -4
  9. package/src/audit/checks/indexability.ts +3 -6
  10. package/src/audit/checks/robots.ts +18 -37
  11. package/src/audit/crawl.ts +49 -49
  12. package/src/audit/image-size.ts +13 -53
  13. package/src/audit/report.ts +22 -33
  14. package/src/audit/types.ts +6 -2
  15. package/src/cli/commands/dev.ts +9 -21
  16. package/src/cli/commands/doctor.ts +9 -22
  17. package/src/cli/env.ts +6 -52
  18. package/src/cli/init/scaffold.ts +15 -28
  19. package/src/cli/internal-error.ts +11 -11
  20. package/src/components/islands/ask-ai.tsx +25 -100
  21. package/src/components/islands/hooks.ts +10 -3
  22. package/src/components/layout/RootLayout.astro +37 -109
  23. package/src/components/layout/Search.astro +3 -5
  24. package/src/components/layout/search/types.ts +4 -16
  25. package/src/components/openapi/helpers.ts +21 -75
  26. package/src/core/component-overrides.ts +0 -7
  27. package/src/core/config.ts +3 -3
  28. package/src/core/diagnostics.ts +10 -20
  29. package/src/core/fs-atomic.ts +22 -0
  30. package/src/core/sources/github-releases.ts +29 -26
  31. package/src/core/sources/mdx-remote.ts +10 -57
  32. package/src/core/sources/notion.ts +17 -23
  33. package/src/core/tsconfig-aliases.ts +39 -172
  34. package/src/deploy/rss.ts +4 -1
  35. package/src/deploy/sitemap.ts +3 -1
  36. package/src/eval/report.ts +20 -28
  37. package/src/markdown/directives.ts +6 -18
  38. package/src/markdown/index.ts +1 -6
  39. package/src/markdown/package-commands.ts +0 -4
  40. package/src/openapi/parse.ts +11 -9
  41. package/src/search/popular-icon.ts +3 -3
  42. package/src/translate/ledger.ts +5 -11
  43. package/src/translate/report.ts +22 -28
  44. package/src/translate/run.ts +5 -24
  45. package/src/translate/work-list.ts +0 -0
  46. 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
- // Enhances every `.prose img` so plain Markdown images open in a lightbox.
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
- const zoomImages: Iterable<HTMLImageElement> =
854
- document.body.hasAttribute("data-blume-image-zoom")
855
- ? document.querySelectorAll<HTMLImageElement>(
856
- ".prose img:not([data-no-zoom])"
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
- if (image.closest("a")) {
959
- continue;
960
- }
961
- image.classList.add("cursor-zoom-in");
962
- image.addEventListener("click", () => openZoom(image));
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
- escapeHtml,
176
- highlight,
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
- '"': "&quot;",
56
- "&": "&amp;",
57
- "'": "&#39;",
58
- "<": "&lt;",
59
- ">": "&gt;",
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 escapeHtml(text);
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. Pure and dependency-free so they run in the browser
6
- * build with no server-only imports.
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 (honoring `example` /
273
- * `const` / `default` / `enum` first). A `seen` set of `$ref`s guards against
274
- * the circular schemas that keeping refs intact allows.
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
- if (typeof schema.$ref === "string") {
285
- if (seen.has(schema.$ref)) {
286
- return null;
287
- }
288
- seen.add(schema.$ref);
289
- return exampleValue(resolveSchema(schemas, schema), schemas, seen);
290
- }
291
- const explicit = explicitExample(schema);
292
- if (explicit !== NO_VALUE) {
293
- return explicit;
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" }\`.`
@@ -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
- throw new BlumeError(
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.
@@ -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 ESC = String.fromCodePoint(27);
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 COLORS.red;
202
+ return colors.red;
213
203
  }
214
204
  if (severity === "warning") {
215
- return COLORS.yellow;
205
+ return colors.yellow;
216
206
  }
217
- return COLORS.blue;
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}${COLORS.bold}${diagnostic.code}${COLORS.reset} ${diagnostic.message}`,
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(` ${COLORS.dim}at ${diagnostic.url}${COLORS.reset}`);
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(` ${COLORS.dim}${label} ${location}${position}${COLORS.reset}`);
233
+ lines.push(` ${colors.dim(`${label} ${location}${position}`)}`);
244
234
  }
245
235
 
246
236
  if (diagnostic.suggestion) {
247
- lines.push(` ${COLORS.cyan}fix: ${diagnostic.suggestion}${COLORS.reset}`);
237
+ lines.push(` ${colors.cyan(`fix: ${diagnostic.suggestion}`)}`);
248
238
  }
249
239
 
250
240
  if (diagnostic.docsUrl) {
251
- lines.push(` ${COLORS.dim}docs: ${diagnostic.docsUrl}${COLORS.reset}`);
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
- const CHANGESET_HASH = /^[0-9a-f]{7,40}:\s+/u;
71
- const IMAGE = /!\[[^\]]*\]\([^)]*\)/gu;
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 reduced to plain
82
- * text — section headings ("### Patch Changes") and changesets' commit-hash
83
- * bullet prefixes dropped — then cut at a word boundary to fit the search
84
- * snippet cap. Undefined when the notes have no prose at all.
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 text = body
88
- .replaceAll(CODE_FENCE, " ")
89
- .replaceAll(HEADING_LINE, "")
90
- .split("\n")
91
- .map((line) => line.replace(LIST_MARK, "").replace(CHANGESET_HASH, ""))
92
- .join("\n")
93
- .replaceAll(IMAGE, " ")
94
- .replaceAll(LINK, "$<text>")
95
- .replaceAll(INLINE_CODE, "$<code>")
96
- .replaceAll(HTML_OR_JSX, " ")
97
- .replaceAll(MARKDOWN_PUNCT, " ")
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
- const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/u;
35
-
36
- /** Escape a literal character for embedding in a RegExp. */
37
- const escapeChar = (char: string): string =>
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 (!matchesInclude(rel, include)) {
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
- matchesInclude(ref, options.include)
150
+ included(ref)
198
151
  ? [{ editUrl: `${base}/${ref}`, fetchUrl: `${base}/${ref}`, ref }]
199
152
  : []
200
153
  );