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.
Files changed (50) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/cli/index.js +328 -644
  3. package/dist/cli/index.js.map +35 -35
  4. package/dist/types/core/data.d.ts +10 -0
  5. package/docs/configuration/ai.mdx +15 -1
  6. package/package.json +28 -7
  7. package/src/ai/component-markdown.ts +7 -6
  8. package/src/ai/link-headers.ts +7 -2
  9. package/src/astro/generate.ts +8 -13
  10. package/src/astro/islands.ts +4 -1
  11. package/src/astro/templates.ts +5 -4
  12. package/src/audit/checks/indexability.ts +3 -6
  13. package/src/audit/checks/robots.ts +18 -37
  14. package/src/audit/crawl.ts +49 -49
  15. package/src/audit/image-size.ts +13 -53
  16. package/src/audit/report.ts +22 -33
  17. package/src/audit/types.ts +6 -2
  18. package/src/cli/commands/dev.ts +9 -21
  19. package/src/cli/commands/doctor.ts +9 -22
  20. package/src/cli/env.ts +6 -52
  21. package/src/cli/init/scaffold.ts +15 -28
  22. package/src/cli/internal-error.ts +11 -11
  23. package/src/components/islands/ask-ai.tsx +25 -100
  24. package/src/components/islands/hooks.ts +10 -3
  25. package/src/components/layout/RootLayout.astro +78 -109
  26. package/src/components/layout/Search.astro +3 -5
  27. package/src/components/layout/search/types.ts +4 -16
  28. package/src/components/openapi/helpers.ts +21 -75
  29. package/src/core/component-overrides.ts +0 -7
  30. package/src/core/config.ts +3 -3
  31. package/src/core/data.ts +7 -0
  32. package/src/core/diagnostics.ts +10 -20
  33. package/src/core/fs-atomic.ts +22 -0
  34. package/src/core/sources/github-releases.ts +29 -26
  35. package/src/core/sources/mdx-remote.ts +10 -57
  36. package/src/core/sources/notion.ts +17 -23
  37. package/src/core/tsconfig-aliases.ts +39 -172
  38. package/src/deploy/rss.ts +4 -1
  39. package/src/deploy/sitemap.ts +3 -1
  40. package/src/eval/report.ts +20 -28
  41. package/src/markdown/directives.ts +6 -18
  42. package/src/markdown/index.ts +1 -6
  43. package/src/markdown/package-commands.ts +0 -4
  44. package/src/openapi/parse.ts +11 -9
  45. package/src/search/popular-icon.ts +3 -3
  46. package/src/translate/ledger.ts +5 -11
  47. package/src/translate/report.ts +22 -28
  48. package/src/translate/run.ts +5 -24
  49. package/src/translate/work-list.ts +0 -0
  50. 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 = ASK_ERROR;
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 = ASK_ERROR;
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
- // Enhances every `.prose img` so plain Markdown images open in a lightbox.
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
- 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) {
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
- if (image.closest("a")) {
959
- continue;
960
- }
961
- image.classList.add("cursor-zoom-in");
962
- image.addEventListener("click", () => openZoom(image));
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
- 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.
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;
@@ -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
+ };