blume 1.0.2 → 1.0.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 (63) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli/index.js +496 -295
  3. package/dist/cli/index.js.map +18 -17
  4. package/dist/types/core/config-input.d.ts +44 -22
  5. package/dist/types/core/config.d.ts +3 -3
  6. package/dist/types/core/data.d.ts +12 -0
  7. package/dist/types/core/i18n-ui.d.ts +136 -136
  8. package/dist/types/core/schema.d.ts +502 -384
  9. package/dist/types/core/types.d.ts +10 -0
  10. package/dist/types/openapi/references.d.ts +12 -7
  11. package/docs/advanced/api-reference.mdx +11 -3
  12. package/docs/advanced/custom-pages.mdx +2 -0
  13. package/docs/configuration/ai.mdx +6 -4
  14. package/docs/configuration/index.mdx +6 -8
  15. package/docs/configuration/seo.mdx +20 -1
  16. package/docs/content/components.mdx +26 -5
  17. package/docs/content/navigation.mdx +10 -0
  18. package/docs/content/syntax.mdx +116 -4
  19. package/package.json +1 -1
  20. package/skills/blume-migrate/SKILL.md +170 -0
  21. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  22. package/skills/blume-migrate/references/docusaurus.md +95 -0
  23. package/skills/blume-migrate/references/fumadocs.md +95 -0
  24. package/skills/blume-migrate/references/mintlify.md +155 -0
  25. package/skills/blume-migrate/references/monorepo.md +224 -0
  26. package/skills/blume-migrate/references/nextra.md +76 -0
  27. package/skills/blume-migrate/references/starlight.md +116 -0
  28. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +466 -0
  29. package/src/ai/agent-readability.ts +3 -3
  30. package/src/ai/mcp/data.ts +2 -2
  31. package/src/astro/component-slots.ts +3 -2
  32. package/src/astro/generate.ts +97 -30
  33. package/src/astro/templates.ts +140 -53
  34. package/src/blume-modules.d.ts +6 -0
  35. package/src/components/content/Callout.astro +8 -2
  36. package/src/components/content/Prompt.astro +25 -13
  37. package/src/components/layout/Header.astro +19 -10
  38. package/src/components/layout/Logo.astro +13 -1
  39. package/src/components/layout/PageFeedback.astro +1 -1
  40. package/src/components/layout/PageLayout.astro +11 -11
  41. package/src/components/layout/Pagination.astro +6 -6
  42. package/src/components/layout/ReferenceLayout.astro +1 -0
  43. package/src/components/layout/RootLayout.astro +10 -11
  44. package/src/components/layout/Search.astro +1 -1
  45. package/src/components/layout/nav-utils.ts +9 -7
  46. package/src/core/config-input.ts +47 -27
  47. package/src/core/config.ts +3 -3
  48. package/src/core/data.ts +9 -1
  49. package/src/core/navigation.ts +55 -13
  50. package/src/core/schema.ts +32 -15
  51. package/src/core/server-features.ts +1 -1
  52. package/src/core/sources/watch.ts +5 -0
  53. package/src/core/types.ts +10 -0
  54. package/src/deploy/adapter-output.ts +11 -1
  55. package/src/markdown/index.ts +2 -0
  56. package/src/markdown/language-icon.ts +2 -1
  57. package/src/markdown/table-wrap.ts +43 -0
  58. package/src/og/card.ts +39 -12
  59. package/src/og/index.ts +1 -1
  60. package/src/og/logo.ts +21 -0
  61. package/src/openapi/references.ts +19 -16
  62. package/src/registry/eject.ts +11 -5
  63. package/src/theme/entry.ts +50 -5
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Wraps each `<table>` in a scroll container. Satteri does not re-descend into a
3
+ * visitor's returned replacement, so the wrapped table is not re-visited.
4
+ *
5
+ * The wrapper carries `tabindex="0"` so a horizontally scrolling table is
6
+ * reachable and scrollable by keyboard, not just pointer (WCAG 2.1.1; axe's
7
+ * `scrollable-region-focusable`). It's added unconditionally — whether a given
8
+ * table overflows isn't known at build time — which costs a tab stop on tables
9
+ * that happen to fit; no ARIA label is set to avoid an untranslated string.
10
+ */
11
+
12
+ /** A minimal hast node (avoids a hast type dependency). */
13
+ interface HastNode {
14
+ children?: HastNode[];
15
+ properties?: Record<string, unknown>;
16
+ tagName?: string;
17
+ type: string;
18
+ value?: string;
19
+ }
20
+
21
+ /** A Satteri hast plugin, typed structurally to avoid a Satteri dep. */
22
+ export interface TableWrapPlugin {
23
+ name: string;
24
+ element: {
25
+ filter: string[];
26
+ visit: (node: HastNode) => HastNode;
27
+ };
28
+ }
29
+
30
+ export const tableWrapPlugin = (): TableWrapPlugin => ({
31
+ element: {
32
+ filter: ["table"],
33
+ visit(node) {
34
+ return {
35
+ children: [node],
36
+ properties: { className: ["blume-table-scroll"], tabIndex: 0 },
37
+ tagName: "div",
38
+ type: "element",
39
+ };
40
+ },
41
+ },
42
+ name: "blume:table-wrap",
43
+ });
package/src/og/card.ts CHANGED
@@ -35,6 +35,17 @@ const resolveAccent = (accent: string): string => {
35
35
  return HEX_COLOR.test(accent) ? accent : "#3b82f6";
36
36
  };
37
37
 
38
+ const resolveColor = (color: string | undefined, fallback: string): string =>
39
+ color && HEX_COLOR.test(color) ? color : fallback;
40
+
41
+ export interface OgCardPalette {
42
+ accent?: string;
43
+ background?: string;
44
+ border?: string;
45
+ foreground?: string;
46
+ muted?: string;
47
+ }
48
+
38
49
  export interface OgCardOptions {
39
50
  /** Large headline — the page title. */
40
51
  title: string;
@@ -45,10 +56,12 @@ export interface OgCardOptions {
45
56
  /** Muted subtitle under the headline (usually the site description). */
46
57
  description?: string;
47
58
  /**
48
- * Inlined SVG markup of the configured logo (`config.logo.svg`), painted into
59
+ * Inlined SVG markup of the configured logo, painted into
49
60
  * the brand lockup. Falls back to an accent mark when absent.
50
61
  */
51
62
  logo?: string;
63
+ /** Optional colors for the generated card. */
64
+ palette?: OgCardPalette;
52
65
  /** Footer-left repository slug, e.g. `owner/repo`. */
53
66
  repo?: string;
54
67
  /** Footer-right site host, e.g. `docs.acme.com`. */
@@ -67,6 +80,17 @@ const MUTED = "#737373";
67
80
  const FAINT = "#a3a3a3";
68
81
  const BORDER = "#e5e5e5";
69
82
 
83
+ const resolvePalette = (
84
+ options: OgCardOptions
85
+ ): Required<OgCardPalette> & { faint: string } => ({
86
+ accent: resolveAccent(options.palette?.accent ?? options.accent ?? "blue"),
87
+ background: resolveColor(options.palette?.background, BG),
88
+ border: resolveColor(options.palette?.border, BORDER),
89
+ faint: resolveColor(options.palette?.muted, FAINT),
90
+ foreground: resolveColor(options.palette?.foreground, FOREGROUND),
91
+ muted: resolveColor(options.palette?.muted, MUTED),
92
+ });
93
+
70
94
  /**
71
95
  * Truncate to `max` code points with an ellipsis. Slices by code points, not
72
96
  * UTF-16 units, so cutting mid-emoji doesn't leave a lone surrogate (a broken
@@ -103,8 +127,8 @@ const logoAspect = (svg: string): number | null => {
103
127
  // Render the configured logo as the brand mark. A `currentColor` logo carries
104
128
  // no intrinsic color, so it is painted in the foreground to read on the light
105
129
  // card, then handed to Takumi as a data URI sized from the SVG's aspect ratio.
106
- const logoMark = (svg: string): Node => {
107
- const painted = svg.replaceAll("currentColor", FOREGROUND);
130
+ const logoMark = (svg: string, foreground: string): Node => {
131
+ const painted = svg.replaceAll("currentColor", foreground);
108
132
  const aspect = logoAspect(painted);
109
133
  let height = MARK_HEIGHT;
110
134
  let width = aspect ? MARK_HEIGHT * aspect : MARK_HEIGHT;
@@ -151,7 +175,8 @@ const titleSize = (title: string): number => {
151
175
 
152
176
  /** Render a 1200x630 Open Graph card to a PNG buffer. */
153
177
  export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
154
- const accent = resolveAccent(options.accent ?? "blue");
178
+ const { accent, background, border, faint, foreground, muted } =
179
+ resolvePalette(options);
155
180
  const brand = options.brand?.trim();
156
181
  const logo = options.logo?.trim();
157
182
  // Slice by code point, not code unit — `charAt(0)` would split a leading
@@ -166,14 +191,16 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
166
191
  // ("Ultracite Ultracite"). Without a logo, the accent tile with the brand
167
192
  // initial stands in.
168
193
  const header = container({
169
- children: [logo ? logoMark(logo) : initialMark(accent, initial)],
194
+ children: [
195
+ logo ? logoMark(logo, foreground) : initialMark(accent, initial),
196
+ ],
170
197
  style: { alignItems: "center", display: "flex" },
171
198
  });
172
199
 
173
200
  const body = container({
174
201
  children: [
175
202
  text(truncate(options.title, 64), {
176
- color: FOREGROUND,
203
+ color: foreground,
177
204
  fontSize: titleSize(options.title),
178
205
  fontWeight: 600,
179
206
  letterSpacing: "-0.03em",
@@ -183,7 +210,7 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
183
210
  }),
184
211
  description
185
212
  ? text(truncate(description, 140), {
186
- color: MUTED,
213
+ color: muted,
187
214
  fontSize: 30,
188
215
  lineHeight: 1.4,
189
216
  marginTop: 28,
@@ -200,15 +227,15 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
200
227
  ? container({
201
228
  children: [
202
229
  container({
203
- style: { backgroundColor: BORDER, height: 1, width: "100%" },
230
+ style: { backgroundColor: border, height: 1, width: "100%" },
204
231
  }),
205
232
  container({
206
233
  children: [
207
234
  repo
208
- ? text(repo, { color: MUTED, fontSize: 22 })
235
+ ? text(repo, { color: muted, fontSize: 22 })
209
236
  : container({}),
210
237
  site
211
- ? text(site, { color: FAINT, fontSize: 22 })
238
+ ? text(site, { color: faint, fontSize: 22 })
212
239
  : container({}),
213
240
  ],
214
241
  style: {
@@ -227,8 +254,8 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
227
254
  const node = container({
228
255
  children: [header, body, footer],
229
256
  style: {
230
- backgroundColor: BG,
231
- color: FOREGROUND,
257
+ backgroundColor: background,
258
+ color: foreground,
232
259
  display: "flex",
233
260
  flexDirection: "column",
234
261
  height: HEIGHT,
package/src/og/index.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { renderOgImage } from "./card.ts";
2
- export type { OgCardOptions } from "./card.ts";
2
+ export type { OgCardOptions, OgCardPalette } from "./card.ts";
package/src/og/logo.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+
3
+ import { join } from "pathe";
4
+
5
+ import type { BlumeProject } from "../core/project-graph.ts";
6
+
7
+ /** Resolve a configured local SVG for use in generated Open Graph cards. */
8
+ export const resolveOgLogo = (
9
+ project: BlumeProject,
10
+ source: string | undefined
11
+ ): string | undefined => {
12
+ if (!source?.toLowerCase().endsWith(".svg")) {
13
+ return;
14
+ }
15
+ const relative = source.replace(/^\//u, "");
16
+ const file = [
17
+ join(project.context.root, "public", relative),
18
+ join(project.context.root, relative),
19
+ ].find((path) => existsSync(path));
20
+ return file ? readFileSync(file, "utf-8") : undefined;
21
+ };
@@ -1,14 +1,13 @@
1
1
  import { withBasePath } from "../core/base-path.ts";
2
2
  import type { ResolvedConfig } from "../core/schema.ts";
3
3
  import { trimChar, trimEnd } from "../core/trim.ts";
4
- import type { NavTab } from "../core/types.ts";
5
4
 
6
5
  /**
7
6
  * Pure resolution of the configured API reference blocks into concrete routes,
8
- * labels, and a renderer choice — no file IO, so the content source, the nav
9
- * tabs, the Scalar page generator, and the `blume:openapi` data module all share
10
- * one source of truth. Kept free of any Astro/template imports so `core` can
11
- * depend on it without a cycle.
7
+ * labels, and a renderer choice — no file IO, so the content source, the
8
+ * nav-target validation, the Scalar page generator, and the `blume:openapi`
9
+ * data module all share one source of truth. Kept free of any Astro/template
10
+ * imports so `core` can depend on it without a cycle.
12
11
  */
13
12
 
14
13
  export type ReferenceKind = "openapi" | "asyncapi";
@@ -155,18 +154,22 @@ export const resolveReferences = (
155
154
  ),
156
155
  ];
157
156
 
158
- /** Nav tabs (header links) for every reference, regardless of renderer. */
159
- export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
160
- resolveReferences(config).map((ref) => ({
161
- label: ref.label,
157
+ /**
158
+ * Mounted route for every reference, regardless of renderer. References no
159
+ * longer add a header tab automatically — authors point a `navigation.tabs`
160
+ * entry at one of these routes to surface it (and, for Blume-rendered specs, to
161
+ * scope its operations sidebar). These routes are whitelisted as valid nav
162
+ * targets so such a tab doesn't read as a broken link.
163
+ */
164
+ export const referenceRoutes = (config: ResolvedConfig): string[] =>
165
+ resolveReferences(config).map((ref) =>
162
166
  // Blume-rendered operation pages flow through the content pipeline and are
163
- // mounted under `basePath`, so their tab must be too. Scalar references are
164
- // a single embedded page injected at the raw `route`, left root-anchored.
165
- path:
166
- ref.renderer === "blume"
167
- ? withBasePath(config.basePath, ref.route)
168
- : ref.route,
169
- }));
167
+ // mounted under `basePath`. Scalar references are a single embedded page
168
+ // injected at the raw `route`, left root-anchored.
169
+ ref.renderer === "blume"
170
+ ? withBasePath(config.basePath, ref.route)
171
+ : ref.route
172
+ );
170
173
 
171
174
  /**
172
175
  * Accept one resolved reference into the deduped Blume-rendered set, or return
@@ -20,6 +20,7 @@ import { discoverIslands } from "../astro/islands.ts";
20
20
  import { customOgRoutes, discoverPages, routeIsTaken } from "../astro/pages.ts";
21
21
  import {
22
22
  askEndpointTemplate,
23
+ askComponentTemplate,
23
24
  astroConfigTemplate,
24
25
  catchAllPageTemplate,
25
26
  changelogIndexTemplate,
@@ -135,8 +136,8 @@ const hostsMcp = (
135
136
  project: BlumeProject,
136
137
  userPages: { pattern: string }[]
137
138
  ): boolean =>
138
- project.config.mcp.enabled &&
139
- !routeIsTaken(userPages, project.graph.pages, project.config.mcp.route);
139
+ project.config.ai.mcp.enabled &&
140
+ !routeIsTaken(userPages, project.graph.pages, project.config.ai.mcp.route);
140
141
 
141
142
  /**
142
143
  * The `.well-known` MCP discovery routes, injected as prerendered pages
@@ -174,7 +175,7 @@ const mcpFiles = async (
174
175
  if (!hostsMcp(project, userPages)) {
175
176
  return [];
176
177
  }
177
- const { route } = project.config.mcp;
178
+ const { route } = project.config.ai.mcp;
178
179
  const data = await buildMcpData(project);
179
180
  const discoveryInput = {
180
181
  base: data.base,
@@ -356,6 +357,7 @@ export const eject = async (
356
357
  }[] = [
357
358
  {
358
359
  content: astroConfigTemplate({
360
+ askPath: "./src/generated/Ask.astro",
359
361
  config,
360
362
  contentRoutes: project.manifest.routes.map((route) => route.path),
361
363
  context: relContext,
@@ -390,7 +392,6 @@ export const eject = async (
390
392
  },
391
393
  {
392
394
  content: catchAllPageTemplate({
393
- askEnabled,
394
395
  exportEpub,
395
396
  exportPdf,
396
397
  mathEnabled: usesMath,
@@ -441,6 +442,12 @@ export const eject = async (
441
442
  path: join(genDir, "app.css"),
442
443
  },
443
444
  { content: buildRuntimeData(project), path: join(genDir, "data.json") },
445
+ // The header's Ask trigger behind the `blume:ask` alias. Always written — it
446
+ // renders nothing when Ask is off — so the alias always resolves.
447
+ {
448
+ content: askComponentTemplate(askEnabled),
449
+ path: join(genDir, "Ask.astro"),
450
+ },
444
451
  {
445
452
  content: `${JSON.stringify(ejectOpenApiData(project))}\n`,
446
453
  path: join(genDir, "openapi.json"),
@@ -475,7 +482,6 @@ export const eject = async (
475
482
  files.push(
476
483
  ...(await mcpFiles(project, pages, srcDir, genDir)),
477
484
  ...changelogFiles(project, pages, srcDir, {
478
- askEnabled,
479
485
  exportEpub,
480
486
  exportPdf,
481
487
  needsReact,
@@ -16,6 +16,15 @@ interface TailwindEntryOptions {
16
16
  const DARK_VARIANT = `/* Dark mode is driven by data-theme on the <html> element. */
17
17
  @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));`;
18
18
 
19
+ /** Keep browser-provided UI in sync with the active theme in both sheets. */
20
+ const COLOR_SCHEME_DEFAULTS = `:root {
21
+ color-scheme: light;
22
+ }
23
+
24
+ :root[data-theme="dark"] {
25
+ color-scheme: dark;
26
+ }`;
27
+
19
28
  /**
20
29
  * The default `--blume-*` design tokens (light + dark), shared by the app
21
30
  * sheet and the isolated example-preview sheet so previews inherit the site's
@@ -141,6 +150,8 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
141
150
 
142
151
  ${DARK_VARIANT}
143
152
 
153
+ ${COLOR_SCHEME_DEFAULTS}
154
+
144
155
  ${TOKEN_DEFAULTS}
145
156
 
146
157
  ${THEME_MAPPING}
@@ -386,13 +397,16 @@ blume-diff {
386
397
 
387
398
  /* Language icon (simple-icons) sits at the header's left edge; the label shifts
388
399
  right to make room. Injected at build time by the language-icon transformer.
389
- Shown only for top-level prose blocks, which have a header flush code in
390
- tabs and API panels is nested deeper, so the child combinator skips it. */
400
+ The icon is gated on data-language (the header bar), not just data-icon: the
401
+ transformer runs whenever icons are on, but a header-less standalone
402
+ CodeBlock (no title) never gets data-language, so without this gate the
403
+ absolutely-positioned icon would overlap the first code line. Fenced code
404
+ and titled blocks always have a header, so the icon shows there. */
391
405
  .blume-lang-icon {
392
406
  display: none;
393
407
  }
394
408
 
395
- .prose > :where(pre[data-icon]) > .blume-lang-icon {
409
+ .prose > :where(pre[data-language][data-icon]) > .blume-lang-icon {
396
410
  color: var(--blume-muted-foreground);
397
411
  display: block;
398
412
  height: 0.875rem;
@@ -402,7 +416,7 @@ blume-diff {
402
416
  width: 0.875rem;
403
417
  }
404
418
 
405
- .prose > :where(pre[data-icon])::before {
419
+ .prose > :where(pre[data-language][data-icon])::before {
406
420
  padding-left: 2.5rem;
407
421
  }
408
422
 
@@ -453,11 +467,40 @@ blume-diff {
453
467
  font-size: 0.8125rem;
454
468
  }
455
469
 
470
+ /* Scroll wide tables in place; the wrapper is added by the blume:table-wrap hast
471
+ plugin. The border+radius frames the scroll viewport so a clipped wide table
472
+ reads as scrollable rather than cut off, and carries the table's vertical
473
+ rhythm (the inner table's own margin is zeroed so it fills the frame). */
474
+ .blume-table-scroll {
475
+ overflow-x: auto;
476
+ -webkit-overflow-scrolling: touch;
477
+ margin: 1.5rem 0;
478
+ border: 1px solid var(--blume-border);
479
+ border-radius: var(--blume-radius);
480
+ }
481
+ .blume-table-scroll > table {
482
+ margin: 0;
483
+ }
484
+ /* Restore inner padding on every cell. Typography zeroes the first/last cell's
485
+ inline padding so a borderless table aligns to the prose margin; inside the
486
+ framed wrapper that leaves edge text touching the border. The :is() selector
487
+ outweighs Typography's :where()-scoped rules so the outer columns get it too. */
488
+ .blume-table-scroll :is(th, td) {
489
+ padding: 0.5rem 0.75rem;
490
+ }
491
+ /* Keep column labels on one line so a two-word header does not wrap into a
492
+ ragged stack; the table just scrolls a little wider instead. Body cells keep
493
+ wrapping so a wide table stays a scroll away, not an endless horizontal run. */
494
+ .blume-table-scroll th {
495
+ white-space: nowrap;
496
+ }
497
+
456
498
  /* GFM renders cells as <td><code> directly, which the descendant form alone
457
499
  never matches (a cell is not its own descendant). */
458
500
  .prose :where(td, th) > code,
459
501
  .prose :where(td, th) :not(pre) > code {
460
- white-space: nowrap;
502
+ overflow-wrap: break-word;
503
+ white-space: normal;
461
504
  }
462
505
 
463
506
  blume-tabs pre,
@@ -717,6 +760,8 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
717
760
 
718
761
  ${DARK_VARIANT}
719
762
 
763
+ ${COLOR_SCHEME_DEFAULTS}
764
+
720
765
  ${TOKEN_DEFAULTS}
721
766
 
722
767
  ${THEME_MAPPING}