blume 1.1.2 → 1.1.4

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 (61) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli/index.js +284 -109
  3. package/dist/cli/index.js.map +33 -33
  4. package/dist/types/ai/component-markdown.d.ts +10 -0
  5. package/dist/types/core/config-input.d.ts +44 -0
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +24 -24
  8. package/dist/types/core/schema.d.ts +282 -114
  9. package/dist/types/core/types.d.ts +14 -0
  10. package/dist/types/openapi/references.d.ts +5 -0
  11. package/docs/advanced/api-reference.mdx +20 -0
  12. package/docs/configuration/index.mdx +27 -0
  13. package/package.json +1 -1
  14. package/src/ai/component-markdown.ts +28 -0
  15. package/src/ai/llms.ts +11 -2
  16. package/src/ai/markdown.ts +12 -6
  17. package/src/ai/mcp/server.ts +29 -6
  18. package/src/astro/examples.ts +13 -0
  19. package/src/astro/generate.ts +141 -58
  20. package/src/astro/templates.ts +65 -21
  21. package/src/audit/checks/duplicates.ts +15 -6
  22. package/src/audit/checks/indexability.ts +11 -2
  23. package/src/audit/checks/network.ts +22 -8
  24. package/src/audit/checks/sitemap.ts +42 -16
  25. package/src/audit/redirects.ts +12 -1
  26. package/src/audit/run.ts +13 -3
  27. package/src/audit/url.ts +21 -2
  28. package/src/cli/commands/audit.ts +21 -6
  29. package/src/cli/commands/dev.ts +19 -2
  30. package/src/components/content/Frame.astro +4 -1
  31. package/src/components/content/Prompt.astro +4 -1
  32. package/src/components/content/Tooltip.astro +4 -1
  33. package/src/components/content/Update.astro +45 -0
  34. package/src/components/islands/ask-ai.tsx +19 -2
  35. package/src/components/islands/hooks.ts +38 -11
  36. package/src/components/layout/Logo.astro +2 -2
  37. package/src/components/layout/RootLayout.astro +27 -7
  38. package/src/components/layout/Search.astro +5 -1
  39. package/src/components/layout/head-scripts.ts +22 -5
  40. package/src/components/openapi/ApiTagOperations.astro +17 -8
  41. package/src/core/config-input.ts +45 -0
  42. package/src/core/data.ts +2 -0
  43. package/src/core/date-format.ts +17 -0
  44. package/src/core/deployment-env.ts +7 -2
  45. package/src/core/graph.ts +7 -1
  46. package/src/core/i18n.ts +10 -2
  47. package/src/core/navigation.ts +7 -3
  48. package/src/core/project-graph.ts +9 -0
  49. package/src/core/schema.ts +64 -0
  50. package/src/core/sources/normalize.ts +69 -8
  51. package/src/core/sources/notion.ts +4 -2
  52. package/src/core/sources/sanity.ts +5 -3
  53. package/src/core/types.ts +16 -0
  54. package/src/markdown/code-title.ts +7 -1
  55. package/src/openapi/model.ts +31 -2
  56. package/src/openapi/references.ts +6 -0
  57. package/src/openapi/render-mdx.ts +12 -7
  58. package/src/openapi/scalar.ts +4 -0
  59. package/src/registry/eject.ts +6 -3
  60. package/src/theme/entry.ts +7 -0
  61. package/src/theme/twoslash.ts +10 -0
@@ -23,6 +23,20 @@ export interface Diagnostic {
23
23
  suggestion?: string;
24
24
  docsUrl?: string;
25
25
  }
26
+ /** One discovered `examples/` file reduced to what Markdown downleveling needs. */
27
+ export interface ExampleMarkdownEntry {
28
+ /** Shiki language for the fenced block — the file's extension. */
29
+ lang: string;
30
+ /** Raw example source, shown verbatim in the agent-facing code fence. */
31
+ source: string;
32
+ }
33
+ /**
34
+ * Discovered examples keyed by their `<Component path>` (the file's location
35
+ * under `examples/`, sans extension). Lets the agent-facing Markdown downlevel
36
+ * `<Component path="…" />` to the example's source, since the live preview
37
+ * can't survive the trip to plain Markdown.
38
+ */
39
+ export type ExampleLookup = Record<string, ExampleMarkdownEntry>;
26
40
  /** A heading extracted from page content, used for the TOC and search. */
27
41
  export interface Heading {
28
42
  depth: number;
@@ -35,6 +35,11 @@ export interface ReferenceSource {
35
35
  spec: string;
36
36
  /** Per-block Scalar theme name override, if any (Scalar renderer only). */
37
37
  theme?: string;
38
+ /**
39
+ * Arbitrary Scalar config forwarded to `<ScalarComponent>` (Scalar renderer
40
+ * only). Takes precedence over Blume's derived spec/theme config.
41
+ */
42
+ scalar?: Record<string, unknown>;
38
43
  /** Display options carried through to the Blume renderer. */
39
44
  display: ReferenceDisplay;
40
45
  /**
@@ -104,6 +104,26 @@ openapi: {
104
104
 
105
105
  A Scalar-rendered reference is a self-contained embed on its own route — it doesn't weave into Blume's sidebar, search, or `llms.txt`. Its "Try it" playground calls your **target API directly from the browser** (Blume doesn't proxy), so the API must allow cross-origin requests from the docs site (`Access-Control-Allow-Origin`). `theme` and the playground apply to the Scalar renderer only.
106
106
 
107
+ ### Passing Scalar options
108
+
109
+ `theme` is a shorthand for the one option most people reach for, but Scalar supports many more. A `scalar` object forwards any [Scalar configuration](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) straight to the embedded reference — Blume doesn't gate the keys, so anything Scalar accepts flows through:
110
+
111
+ ```ts blume.config.ts lineNumbers
112
+ openapi: {
113
+ enabled: true,
114
+ renderer: "scalar",
115
+ spec: "./openapi.yaml",
116
+ scalar: {
117
+ localization: { locale: "es" }, // translate Scalar's own UI
118
+ agent: { disabled: true }, // disable the Scalar Agent
119
+ hideTestRequestButton: true,
120
+ orderSchemaPropertiesBy: "preserve",
121
+ },
122
+ }
123
+ ```
124
+
125
+ Blume's own [`i18n`](/docs/content/i18n) translates the docs chrome, but Scalar has a separate localization system — set `scalar.localization.locale` to translate the embedded reference too. Options in the `scalar` object win over Blume's derived config, so anything set here (including `theme`, `customCss`, or the spec `content`/`url`) overrides Blume's defaults. The same `scalar` block works on the `asyncapi` reference.
126
+
107
127
  ## AsyncAPI
108
128
 
109
129
  Event-driven APIs use a sibling `asyncapi` block with the same shape. AsyncAPI is rendered by Scalar (the native renderer is OpenAPI-only for now); only the default route differs (`/events`):
@@ -252,6 +252,33 @@ lastModified: 2026-06-20
252
252
 
253
253
  When enabled, the date is also emitted as schema.org `dateModified` in the page's structured data.
254
254
 
255
+ ## Date format
256
+
257
+ Both the "Last updated" stamp and the [changelog](/docs/advanced/changelog) timeline render their dates through the same `dateFormat`, so they read alike. Dates always render in the site's locale; `dateFormat` controls the _shape_. It defaults to the long form (`July 21, 2026`, `2026年7月21日`):
258
+
259
+ ```ts blume.config.ts
260
+ dateFormat: { dateStyle: "long" },
261
+ ```
262
+
263
+ `dateFormat` is a pass-through to [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat) options. Use a `dateStyle` preset for a length:
264
+
265
+ ```ts blume.config.ts
266
+ dateFormat: { dateStyle: "medium" },
267
+ ```
268
+
269
+ Or the individual component fields for a numeric house style like `2026/07/21`:
270
+
271
+ ```ts blume.config.ts
272
+ dateFormat: { year: "numeric", month: "2-digit", day: "2-digit" },
273
+ ```
274
+
275
+ | Option | Description |
276
+ | --- | --- |
277
+ | `dateStyle` | Preset length: `"full"`, `"long"`, `"medium"`, or `"short"`. Can't be combined with the component fields. |
278
+ | `weekday`, `era`, `year`, `month`, `day` | Individual components, e.g. `year: "numeric"`, `month: "2-digit"`. |
279
+ | `timeZone` | IANA time zone. Defaults to `UTC`, so a date reads the same regardless of where the site builds. |
280
+ | `calendar`, `numberingSystem` | Calendar system (e.g. `"japanese"`) and numbering system (e.g. `"arab"`). |
281
+
255
282
  ## SEO
256
283
 
257
284
  Open Graph images, RSS feeds, and JSON-LD structured data, grouped under `seo`. See the [SEO guide](/docs/configuration/seo) for metadata, frontmatter overrides, and the full reference.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -1,6 +1,7 @@
1
1
  import { mdxToMdast } from "satteri";
2
2
 
3
3
  import { parseYouTubeId } from "../components/content/youtube.ts";
4
+ import type { ExampleLookup } from "../core/types.ts";
4
5
 
5
6
  /**
6
7
  * Downlevel Blume's MDX components to plain Markdown for agent-facing output
@@ -336,6 +337,33 @@ const youtube: ComponentMarkdown = ({ props }) => {
336
337
  return `[${title}](https://www.youtube.com/watch?v=${videoId}${start})`;
337
338
  };
338
339
 
340
+ /** Fence `code` so its opening/closing run outlengths any backticks inside. */
341
+ const fencedBlock = (lang: string, code: string): string => {
342
+ const trimmed = code.replace(/(?<!\n)\n+$/u, "");
343
+ const runs = trimmed.match(/`+/gu);
344
+ const longest = runs ? Math.max(...runs.map((run) => run.length)) : 0;
345
+ const fence = "`".repeat(Math.max(3, longest + 1));
346
+ return `${fence}${lang}\n${trimmed}\n${fence}`;
347
+ };
348
+
349
+ /**
350
+ * Build the `<Component>` serializer for a project's discovered examples. The
351
+ * live preview can't survive the trip to Markdown, so the agent-facing output
352
+ * carries the example's source — the same code the "Code" tab shows — as a
353
+ * fenced block. An unknown `path` (or a missing `path` prop) declines, leaving
354
+ * the JSX verbatim, mirroring the "no example found" note the component renders
355
+ * on the page.
356
+ */
357
+ export const exampleComponentSerializers = (
358
+ examples: ExampleLookup
359
+ ): Record<string, ComponentMarkdown> => ({
360
+ Component: ({ props }) => {
361
+ const path = typeof props.path === "string" ? props.path : undefined;
362
+ const example = path === undefined ? undefined : examples[path];
363
+ return example ? fencedBlock(example.lang, example.source) : null;
364
+ },
365
+ });
366
+
339
367
  /**
340
368
  * The built-in serializer registry, keyed by JSX name. `Step` and `Tab` are
341
369
  * intentionally absent: they only carry meaning inside their containers,
package/src/ai/llms.ts CHANGED
@@ -4,7 +4,10 @@ import type { BlumeProject } from "../core/project-graph.ts";
4
4
  import { readEntryText } from "../core/sources/read.ts";
5
5
  import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
6
6
  import { buildRssFeeds } from "../deploy/rss.ts";
7
- import { downlevelComponents } from "./component-markdown.ts";
7
+ import {
8
+ downlevelComponents,
9
+ exampleComponentSerializers,
10
+ } from "./component-markdown.ts";
8
11
  import { applyAgentVisibility } from "./visibility.ts";
9
12
 
10
13
  // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top —
@@ -163,6 +166,12 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
163
166
  const pages = eligiblePages(project).toSorted((a, b) =>
164
167
  a.route.localeCompare(b.route)
165
168
  );
169
+ // Downlevel `<Component>` to its example's source; a same-name user
170
+ // `markdownComponents` entry is spread last and still wins.
171
+ const components = {
172
+ ...exampleComponentSerializers(project.examples ?? {}),
173
+ ...config.ai.markdownComponents,
174
+ };
166
175
 
167
176
  const sections = await Promise.all(
168
177
  pages.map(async (page) => {
@@ -173,7 +182,7 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
173
182
  const parsed = matter(raw);
174
183
  const body = downlevelComponents(
175
184
  applyAgentVisibility(parsed.content),
176
- config.ai.markdownComponents,
185
+ components,
177
186
  parsed.data
178
187
  ).trim();
179
188
  const url = pageUrl(
@@ -4,7 +4,10 @@ import matter from "../core/frontmatter.ts";
4
4
  import type { BlumeProject } from "../core/project-graph.ts";
5
5
  import { readEntryText } from "../core/sources/read.ts";
6
6
  import type { RouteManifestEntry } from "../core/types.ts";
7
- import { downlevelComponents } from "./component-markdown.ts";
7
+ import {
8
+ downlevelComponents,
9
+ exampleComponentSerializers,
10
+ } from "./component-markdown.ts";
8
11
  import { applyAgentVisibility } from "./visibility.ts";
9
12
 
10
13
  /** One route's raw-Markdown variants. */
@@ -37,6 +40,13 @@ export const buildRawMarkdown = async (
37
40
  ): Promise<Record<string, RawMarkdownEntry>> => {
38
41
  const pageById = new Map(project.graph.pages.map((page) => [page.id, page]));
39
42
 
43
+ // Downlevel `<Component>` to its example's source. A user `markdownComponents`
44
+ // entry of the same name is spread last, so it still wins.
45
+ const components = {
46
+ ...exampleComponentSerializers(project.examples ?? {}),
47
+ ...project.config.ai.markdownComponents,
48
+ };
49
+
40
50
  const readRoute = async (route: RouteManifestEntry): Promise<string> => {
41
51
  const page = pageById.get(route.id);
42
52
  if (page) {
@@ -50,11 +60,7 @@ export const buildRawMarkdown = async (
50
60
  const source = applyAgentVisibility(await readRoute(route));
51
61
  // The `.md` variant keeps the front-matter block in the output, but its
52
62
  // data must also be in scope for `prop={frontmatter.*}` expressions.
53
- const md = downlevelComponents(
54
- source,
55
- project.config.ai.markdownComponents,
56
- matter(source).data
57
- );
63
+ const md = downlevelComponents(source, components, matter(source).data);
58
64
  const entry: RawMarkdownEntry =
59
65
  md === source ? { mdx: source } : { md, mdx: source };
60
66
  return [route.path, entry] as const;
@@ -5,7 +5,7 @@ import {
5
5
  ListToolsRequestSchema,
6
6
  } from "@modelcontextprotocol/sdk/types.js";
7
7
 
8
- import { withBasePath } from "../../core/base-path.ts";
8
+ import { stripBasePath, withBasePath } from "../../core/base-path.ts";
9
9
  import { buildOramaIndex, queryOramaIndex } from "../../search/orama-index.ts";
10
10
  import type { OramaDoc } from "../../search/orama-index.ts";
11
11
  import type { McpData } from "./data.ts";
@@ -81,12 +81,32 @@ const asLimit = (value: unknown): number => {
81
81
  return Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT);
82
82
  };
83
83
 
84
- /** Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix). */
85
- const normalizeRoute = (input: string): string => {
86
- const noTrailing = input.trim().replace(/\/+$/u, "");
84
+ /**
85
+ * Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix).
86
+ * Accepts a full URL too — `search_docs` hits and llms.txt entries carry
87
+ * `site` + `deployment.base`, and an agent following "pass a route from
88
+ * `search_docs`" will hand one straight back — reducing it to its base-less,
89
+ * percent-decoded path.
90
+ */
91
+ const normalizeRoute = (input: string, data: McpData): string => {
92
+ let value = input.trim();
93
+ if (/^https?:\/\//iu.test(value)) {
94
+ try {
95
+ value = new URL(value).pathname;
96
+ } catch {
97
+ // Not parseable as a URL after all; treat it as a path.
98
+ }
99
+ }
100
+ try {
101
+ value = decodeURI(value);
102
+ } catch {
103
+ // Malformed percent sequence — compare it as written.
104
+ }
105
+ const noTrailing = value.replace(/\/+$/u, "");
87
106
  const noSuffix = noTrailing.replace(/\.mdx?$/u, "");
88
107
  const withSlash = noSuffix.startsWith("/") ? noSuffix : `/${noSuffix}`;
89
- return withSlash === "" ? "/" : withSlash;
108
+ const based = stripBasePath(data.base, withSlash);
109
+ return based === "" ? "/" : based;
90
110
  };
91
111
 
92
112
  /** Build the absolute (or root-relative) URL for a route. */
@@ -141,8 +161,11 @@ const buildServer = (
141
161
  asString(args.query),
142
162
  asLimit(args.limit)
143
163
  );
164
+ // `route` is the key `get_page` takes (the tool descriptions promise
165
+ // it); `url` is where the page is served.
144
166
  const results = hits.map((doc: OramaDoc) => ({
145
167
  excerpt: excerptFor(doc),
168
+ route: doc.route,
146
169
  title: doc.title,
147
170
  url: urlFor(doc.route, data),
148
171
  }));
@@ -150,7 +173,7 @@ const buildServer = (
150
173
  }
151
174
 
152
175
  if (name === "get_page") {
153
- const key = normalizeRoute(asString(args.route));
176
+ const key = normalizeRoute(asString(args.route), data);
154
177
  const markdown = data.pages[key];
155
178
  if (markdown === undefined) {
156
179
  return text(
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { join, relative } from "pathe";
4
4
  import { glob } from "tinyglobby";
5
5
 
6
+ import type { ExampleLookup } from "../core/types.ts";
6
7
  import type { IslandClientMode } from "./islands.ts";
7
8
  import { readClientMode } from "./islands.ts";
8
9
 
@@ -146,3 +147,15 @@ export const discoverExamples = async (
146
147
 
147
148
  return { examples, warnings };
148
149
  };
150
+
151
+ /**
152
+ * Reduce discovered examples to the `<Component path>` → source lookup the
153
+ * agent-facing Markdown downleveler needs (see {@link BlumeProject.examples}).
154
+ */
155
+ export const exampleMarkdownLookup = (examples: ExampleSpec[]): ExampleLookup =>
156
+ Object.fromEntries(
157
+ examples.map((example) => [
158
+ example.path,
159
+ { lang: example.lang, source: example.source },
160
+ ])
161
+ );