blume 1.0.3 → 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 (51) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli/index.js +441 -259
  3. package/dist/cli/index.js.map +12 -11
  4. package/dist/types/core/config-input.d.ts +27 -8
  5. package/dist/types/core/data.d.ts +12 -0
  6. package/dist/types/core/i18n-ui.d.ts +136 -136
  7. package/dist/types/core/schema.d.ts +420 -350
  8. package/dist/types/core/types.d.ts +10 -0
  9. package/dist/types/openapi/references.d.ts +12 -7
  10. package/docs/advanced/api-reference.mdx +11 -3
  11. package/docs/configuration/seo.mdx +20 -1
  12. package/docs/content/components.mdx +1 -2
  13. package/docs/content/navigation.mdx +10 -0
  14. package/docs/content/syntax.mdx +116 -4
  15. package/package.json +1 -1
  16. package/skills/blume-migrate/SKILL.md +170 -0
  17. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  18. package/skills/blume-migrate/references/docusaurus.md +95 -0
  19. package/skills/blume-migrate/references/fumadocs.md +95 -0
  20. package/skills/blume-migrate/references/mintlify.md +155 -0
  21. package/skills/blume-migrate/references/monorepo.md +224 -0
  22. package/skills/blume-migrate/references/nextra.md +76 -0
  23. package/skills/blume-migrate/references/starlight.md +116 -0
  24. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +466 -0
  25. package/src/astro/component-slots.ts +3 -2
  26. package/src/astro/generate.ts +82 -23
  27. package/src/astro/templates.ts +93 -34
  28. package/src/components/content/Callout.astro +8 -2
  29. package/src/components/content/Prompt.astro +25 -13
  30. package/src/components/layout/Header.astro +4 -8
  31. package/src/components/layout/Logo.astro +13 -1
  32. package/src/components/layout/PageFeedback.astro +1 -1
  33. package/src/components/layout/PageLayout.astro +4 -8
  34. package/src/components/layout/Pagination.astro +6 -6
  35. package/src/components/layout/RootLayout.astro +4 -8
  36. package/src/components/layout/Search.astro +1 -1
  37. package/src/components/layout/nav-utils.ts +9 -7
  38. package/src/core/config-input.ts +29 -8
  39. package/src/core/data.ts +9 -1
  40. package/src/core/navigation.ts +55 -13
  41. package/src/core/schema.ts +17 -1
  42. package/src/core/sources/watch.ts +5 -0
  43. package/src/core/types.ts +10 -0
  44. package/src/markdown/index.ts +2 -0
  45. package/src/markdown/language-icon.ts +2 -1
  46. package/src/markdown/table-wrap.ts +43 -0
  47. package/src/og/card.ts +39 -12
  48. package/src/og/index.ts +1 -1
  49. package/src/og/logo.ts +21 -0
  50. package/src/openapi/references.ts +19 -16
  51. package/src/theme/entry.ts +50 -5
@@ -30,15 +30,15 @@ const s = { ...EN_UI.page, ...strings };
30
30
  >
31
31
  {prev ? (
32
32
  <a
33
- class="flex max-w-[48%] flex-1 items-center gap-2 rounded-full border border-border px-4 py-3 text-foreground transition-colors hover:border-foreground max-md:max-w-full"
33
+ class="flex max-w-[48%] flex-1 items-center gap-2 rounded-blume border border-border px-4 py-3 text-foreground transition-colors hover:border-foreground max-md:max-w-full"
34
34
  href={withBase(prev.route)}
35
35
  >
36
36
  <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
37
- <span>
37
+ <span class="min-w-0">
38
38
  <span class="block text-muted-foreground text-xs max-md:hidden">
39
39
  {s.previous}
40
40
  </span>
41
- <span class="block font-medium">{prev.label}</span>
41
+ <span class="block truncate font-medium">{prev.label}</span>
42
42
  </span>
43
43
  </a>
44
44
  ) : (
@@ -46,14 +46,14 @@ const s = { ...EN_UI.page, ...strings };
46
46
  )}
47
47
  {next && (
48
48
  <a
49
- class="ms-auto flex max-w-[48%] flex-1 items-center justify-end gap-2 rounded-full border border-border px-4 py-3 text-end text-foreground transition-colors hover:border-foreground max-md:max-w-full"
49
+ class="ms-auto flex max-w-[48%] flex-1 items-center justify-end gap-2 rounded-blume border border-border px-4 py-3 text-end text-foreground transition-colors hover:border-foreground max-md:max-w-full"
50
50
  href={withBase(next.route)}
51
51
  >
52
- <span>
52
+ <span class="min-w-0">
53
53
  <span class="block text-muted-foreground text-xs max-md:hidden">
54
54
  {s.next}
55
55
  </span>
56
- <span class="block font-medium">{next.label}</span>
56
+ <span class="block truncate font-medium">{next.label}</span>
57
57
  </span>
58
58
  <Icon class="rtl:-scale-x-100" name="arrow-right" size={16} />
59
59
  </a>
@@ -27,10 +27,10 @@ import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
27
27
  import Header from "./Header.astro";
28
28
  import Icon from "../Icon.astro";
29
29
  import {
30
+ activeTabForRoute,
30
31
  findBreadcrumbs,
31
32
  flattenPages,
32
33
  getPagination,
33
- isUnderPath,
34
34
  sidebarForRoute,
35
35
  } from "./nav-utils.ts";
36
36
  import NavTree from "./NavTree.astro";
@@ -307,6 +307,7 @@ const mcpUrl =
307
307
  // active tab's section, so a multi-section site drills each tab into its own
308
308
  // pages. Without tabs — or on a route under none — this is the full sidebar.
309
309
  const sidebar = sidebarForRoute(navigation.sidebar, navigation.tabs, page.route);
310
+ const activeTab = activeTabForRoute(navigation.tabs, page.route);
310
311
  const crumbs = findBreadcrumbs(sidebar, page.route);
311
312
  const { prev, next } = getPagination(flattenPages(sidebar), page.route);
312
313
 
@@ -526,14 +527,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
526
527
  {navigation.tabs.map((tab) => (
527
528
  <li>
528
529
  <a
529
- aria-current={
530
- page.route === tab.path ||
531
- (tab.path !== "/" && isUnderPath(page.route, tab.path))
532
- ? "page"
533
- : undefined
534
- }
530
+ aria-current={tab === activeTab ? "page" : undefined}
535
531
  class="block rounded-[0.65rem] px-2.5 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
536
- href={withBase(tab.path)}
532
+ href={withBase(tab.href ?? tab.path)}
537
533
  >
538
534
  {tab.label}
539
535
  </a>
@@ -58,7 +58,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
58
58
 
59
59
  <dialog
60
60
  aria-label={s.label}
61
- class="m-auto h-[min(480px,90vh)] w-[min(62.5rem,94vw)] flex-col overflow-hidden rounded-blume border border-border bg-background/80 p-0 text-foreground shadow-2xl outline-none backdrop-blur-xl backdrop:bg-black/30 open:flex"
61
+ class="mx-auto mt-[8vh] mb-auto h-[min(480px,90dvh)] w-[min(62.5rem,94vw)] flex-col overflow-hidden rounded-blume border border-border bg-background/80 p-0 text-foreground shadow-2xl outline-none backdrop-blur-xl backdrop:bg-black/30 open:flex sm:mt-auto"
62
62
  data-blume-search-dialog
63
63
  >
64
64
  <div
@@ -80,14 +80,16 @@ export const isUnderPath = (route: string, base: string): boolean =>
80
80
  base === "/" || route === base || route.startsWith(`${base}/`);
81
81
 
82
82
  /**
83
- * The tab whose `path` is the longest prefix of `route`, mirroring the header's
84
- * active-tab highlight. The root tab (`/`) is skipped — it spans everything and
85
- * so never scopes the sidebar.
83
+ * The tab whose `path` is the longest prefix of `route`. The root tab (`/`)
84
+ * acts as the fallback when no more specific tab matches.
86
85
  */
87
- const activeTab = (tabs: NavTab[], route: string): NavTab | null => {
86
+ export const activeTabForRoute = (
87
+ tabs: NavTab[],
88
+ route: string
89
+ ): NavTab | null => {
88
90
  let match: NavTab | null = null;
89
91
  for (const tab of tabs) {
90
- if (tab.path === "/" || !isUnderPath(route, tab.path)) {
92
+ if (!isUnderPath(route, tab.path)) {
91
93
  continue;
92
94
  }
93
95
  if (!match || tab.path.length > match.path.length) {
@@ -187,8 +189,8 @@ export const sidebarForRoute = (
187
189
  tabs: NavTab[],
188
190
  route: string
189
191
  ): NavNode[] => {
190
- const tab = activeTab(tabs, route);
191
- if (tab) {
192
+ const tab = activeTabForRoute(tabs, route);
193
+ if (tab && tab.path !== "/") {
192
194
  return sectionChildren(sidebar, tab.path) ?? [];
193
195
  }
194
196
  const scoped = withoutTabSections(sidebar, tabs);
@@ -711,6 +711,34 @@ export interface RssConfig {
711
711
  types?: string[];
712
712
  }
713
713
 
714
+ /** Colors used by generated Open Graph cards. Values must be hex colors. */
715
+ export interface OgPaletteConfig {
716
+ /** Fallback mark color. Defaults to the light theme accent. */
717
+ accent?: string;
718
+ /** Card background. */
719
+ background?: string;
720
+ /** Footer divider. */
721
+ border?: string;
722
+ /** Headline and `currentColor` logo color. */
723
+ foreground?: string;
724
+ /** Description and footer text. */
725
+ muted?: string;
726
+ }
727
+
728
+ /** Per-page Open Graph image generation. */
729
+ export interface OgConfig {
730
+ /**
731
+ * Generate an OG image per page. Defaults to on once a deployment `site`
732
+ * URL is known and off otherwise (`og:image` must be absolute). An explicit
733
+ * value always wins.
734
+ */
735
+ enabled?: boolean;
736
+ /** Local SVG used in the generated card instead of the site logo. */
737
+ logo?: string;
738
+ /** Optional generated-card colors. */
739
+ palette?: OgPaletteConfig;
740
+ }
741
+
714
742
  /** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
715
743
  export interface SeoConfig {
716
744
  /**
@@ -721,14 +749,7 @@ export interface SeoConfig {
721
749
  /** robots.txt `Content-Signal` usage declaration. Defaults to `true`. */
722
750
  contentSignals?: ContentSignalsConfig;
723
751
  /** Per-page Open Graph image generation. */
724
- og?: {
725
- /**
726
- * Generate an OG image per page. Defaults to on once a deployment `site`
727
- * URL is known and off otherwise (`og:image` must be absolute). An explicit
728
- * value always wins.
729
- */
730
- enabled?: boolean;
731
- };
752
+ og?: OgConfig;
732
753
  /** Generate robots.txt (with a Sitemap reference when available). Defaults to `true`. */
733
754
  robots?: boolean;
734
755
  /** RSS/Atom feeds. */
package/src/core/data.ts CHANGED
@@ -15,6 +15,10 @@ export interface BlumeLogo {
15
15
  svg?: string;
16
16
  light?: string;
17
17
  dark?: string;
18
+ dimensions?: {
19
+ dark?: { height: number; width: number };
20
+ light?: { height: number; width: number };
21
+ };
18
22
  alt: string;
19
23
  href: string;
20
24
  /** Wordmark text beside the mark; `undefined` falls back to the site title. */
@@ -109,7 +113,11 @@ export interface BlumeDataConfig {
109
113
  /** Hosted MCP server, or `null` when MCP is off. */
110
114
  mcp: { name: string; route: string } | null;
111
115
  /** Open Graph image generation. */
112
- og: { enabled: boolean };
116
+ og: {
117
+ enabled: boolean;
118
+ logo?: string;
119
+ palette?: ResolvedConfig["seo"]["og"]["palette"];
120
+ };
113
121
  /** Repository URL for header/edit links, or `null`. */
114
122
  repoUrl: string | null;
115
123
  search: { enabled: boolean; provider: SearchProvider };
@@ -433,6 +433,43 @@ const buildConfigSidebar = (
433
433
  return nodes;
434
434
  };
435
435
 
436
+ /**
437
+ * Resolve a tab's clickable target. A tab's `path` scopes its sidebar section
438
+ * but need not be a real route — a section with no index page would 404 if the
439
+ * tab linked straight to it. Prefer an exact page/group at the path; otherwise
440
+ * fall back to the first linkable route in the section (sidebar order).
441
+ */
442
+ const resolveTabHref = (sidebar: NavNode[], path: string): string => {
443
+ let first: string | undefined;
444
+ const walk = (nodes: NavNode[]): boolean => {
445
+ for (const node of nodes) {
446
+ const { route } = node;
447
+ if (route === path) {
448
+ return true;
449
+ }
450
+ if (
451
+ first === undefined &&
452
+ route !== undefined &&
453
+ route.startsWith(`${path}/`)
454
+ ) {
455
+ first = route;
456
+ }
457
+ if (node.kind === "group" && walk(node.children)) {
458
+ return true;
459
+ }
460
+ }
461
+ return false;
462
+ };
463
+ return walk(sidebar) ? path : (first ?? path);
464
+ };
465
+
466
+ /** Attach a resolved `href` to each tab whose section has no index page. */
467
+ const withTabHrefs = (tabs: NavTab[], sidebar: NavNode[]): NavTab[] =>
468
+ tabs.map((tab) => {
469
+ const href = resolveTabHref(sidebar, tab.path);
470
+ return href === tab.path ? tab : { ...tab, href };
471
+ });
472
+
436
473
  /** Build the complete navigation model from pages, meta, and config. */
437
474
  export const buildNavigation = (
438
475
  pages: PageRecord[],
@@ -519,11 +556,17 @@ export const buildNavigation = (
519
556
  }
520
557
 
521
558
  if (options.sidebar) {
559
+ const sidebar = buildConfigSidebar(
560
+ options.sidebar,
561
+ byRoute,
562
+ display,
563
+ basePath
564
+ );
522
565
  return {
523
566
  featured,
524
567
  selectors,
525
- sidebar: buildConfigSidebar(options.sidebar, byRoute, display, basePath),
526
- tabs,
568
+ sidebar,
569
+ tabs: withTabHrefs(tabs, sidebar),
527
570
  };
528
571
  }
529
572
 
@@ -534,19 +577,18 @@ export const buildNavigation = (
534
577
  // prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
535
578
  // under a base, falsely scope a group named like the prefix).
536
579
  const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
580
+ const sidebar = buildFileSystemSidebar(
581
+ pages,
582
+ options.folderMeta,
583
+ sharedFolderMeta,
584
+ metaPrefix,
585
+ display,
586
+ new Set(tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path])))
587
+ );
537
588
  return {
538
589
  featured,
539
590
  selectors,
540
- sidebar: buildFileSystemSidebar(
541
- pages,
542
- options.folderMeta,
543
- sharedFolderMeta,
544
- metaPrefix,
545
- display,
546
- new Set(
547
- tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
548
- )
549
- ),
550
- tabs,
591
+ sidebar,
592
+ tabs: withTabHrefs(tabs, sidebar),
551
593
  };
552
594
  };
@@ -672,7 +672,7 @@ const navigationConfigSchema = z.strictObject({
672
672
  .transform((value) =>
673
673
  Array.isArray(value) ? { display: "flat" as const, items: value } : value
674
674
  ),
675
- tabs: z.array(navTabSchema).optional(),
675
+ tabs: z.array(navTabSchema).default([]),
676
676
  });
677
677
 
678
678
  export type AskAiProvider = (typeof askAiProviders)[number];
@@ -802,6 +802,18 @@ const xConfigSchema = z.strictObject({
802
802
  handle: xHandleSchema,
803
803
  });
804
804
 
805
+ const ogColorSchema = z
806
+ .string()
807
+ .regex(/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu);
808
+
809
+ const ogPaletteSchema = z.strictObject({
810
+ accent: ogColorSchema.optional(),
811
+ background: ogColorSchema.optional(),
812
+ border: ogColorSchema.optional(),
813
+ foreground: ogColorSchema.optional(),
814
+ muted: ogColorSchema.optional(),
815
+ });
816
+
805
817
  const ogConfigSchema = z.strictObject({
806
818
  /**
807
819
  * Generate a per-page Open Graph image. Defaults to on once a deployment
@@ -810,6 +822,10 @@ const ogConfigSchema = z.strictObject({
810
822
  * `loadConfig`. An explicit value here always wins.
811
823
  */
812
824
  enabled: z.boolean().optional(),
825
+ /** Local SVG used in the generated card instead of the site logo. */
826
+ logo: z.string().optional(),
827
+ /** Optional generated-card colors. */
828
+ palette: ogPaletteSchema.optional(),
813
829
  });
814
830
 
815
831
  const rssConfigSchema = z.strictObject({
@@ -23,6 +23,11 @@ import type { WatchListener } from "node:fs";
23
23
  */
24
24
  export const BLUME_IGNORE_DIRS = [
25
25
  ".blume",
26
+ // The isolated `blume check --isolated` runtime. A sibling of `.blume`, it is
27
+ // written while a dev server runs; without this the content-layer watcher (or
28
+ // a `.`-rooted fs.watch) would treat its generation as a content change and
29
+ // reload — the very thing `--isolated` promises not to do.
30
+ ".blume-verify",
26
31
  ".cache",
27
32
  ".git",
28
33
  ".next",
package/src/core/types.ts CHANGED
@@ -159,7 +159,17 @@ export type NavNode =
159
159
  /** Top-level tab/section. */
160
160
  export interface NavTab {
161
161
  label: string;
162
+ /**
163
+ * The tab's section prefix, used to scope the sidebar and match the active
164
+ * tab. Not necessarily a linkable route — a section may have no index page.
165
+ */
162
166
  path: string;
167
+ /**
168
+ * The clickable target. Equals `path` when the section has an index page;
169
+ * otherwise it's resolved to the section's first page so the tab never links
170
+ * to a 404. Absent when it matches `path`.
171
+ */
172
+ href?: string;
163
173
  icon?: string;
164
174
  items?: NavSelectorItem[];
165
175
  }
@@ -17,6 +17,7 @@ import { languageIconTransformer } from "./language-icon.ts";
17
17
  import { mathPlugin } from "./math.ts";
18
18
  import { mermaidPlugin } from "./mermaid.ts";
19
19
  import { packageInstallPlugin } from "./package-install.ts";
20
+ import { tableWrapPlugin } from "./table-wrap.ts";
20
21
  import { DEFAULT_CODE_THEMES } from "./themes.ts";
21
22
  import type { CodeThemes } from "./themes.ts";
22
23
 
@@ -58,6 +59,7 @@ type HastPlugin = NonNullable<
58
59
  const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
59
60
  const plugins: HastPlugin[] = [
60
61
  inlineCodeHighlightPlugin(options.codeThemes) as unknown as HastPlugin,
62
+ tableWrapPlugin() as unknown as HastPlugin,
61
63
  ];
62
64
  if (options.headingAnchors !== false) {
63
65
  plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
@@ -7,7 +7,8 @@
7
7
  * hex colors are skipped because dark-on-dark logos (Next.js, Rust…) vanish.
8
8
  *
9
9
  * The theme styles `.blume-lang-icon` and shifts the language label
10
- * (`pre[data-icon]::before`) to make room.
10
+ * (`pre[data-language][data-icon]::before`) to make room — gated on
11
+ * `data-language` so the icon only shows when a header bar exists to hold it.
11
12
  */
12
13
 
13
14
  import {
@@ -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