blume 1.0.3 → 1.1.0

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 (126) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +13784 -10579
  3. package/dist/cli/index.js.map +93 -61
  4. package/dist/types/core/config-input.d.ts +87 -8
  5. package/dist/types/core/data.d.ts +21 -0
  6. package/dist/types/core/deployment-env.d.ts +6 -0
  7. package/dist/types/core/diagnostics.d.ts +23 -0
  8. package/dist/types/core/i18n-ui.d.ts +140 -140
  9. package/dist/types/core/schema.d.ts +549 -370
  10. package/dist/types/core/sources/types.d.ts +3 -1
  11. package/dist/types/core/standard-schema.d.ts +41 -0
  12. package/dist/types/core/types.d.ts +23 -0
  13. package/dist/types/og/card.d.ts +63 -0
  14. package/dist/types/og/dimensions.d.ts +12 -0
  15. package/dist/types/openapi/references.d.ts +12 -7
  16. package/docs/01-quickstart.mdx +1 -1
  17. package/docs/02-deployment.mdx +9 -1
  18. package/docs/advanced/api-reference.mdx +22 -3
  19. package/docs/advanced/changelog.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +1 -1
  22. package/docs/configuration/customization.mdx +1 -1
  23. package/docs/configuration/export.mdx +1 -1
  24. package/docs/configuration/index.mdx +21 -1
  25. package/docs/configuration/search.mdx +28 -1
  26. package/docs/configuration/seo.mdx +40 -2
  27. package/docs/configuration/theming.mdx +1 -1
  28. package/docs/content/components.mdx +15 -2
  29. package/docs/content/index.mdx +1 -1
  30. package/docs/content/meta.mdx +1 -1
  31. package/docs/content/navigation.mdx +11 -1
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/content/syntax.mdx +116 -4
  34. package/docs/reference/cli.mdx +79 -1
  35. package/docs/reference/frontmatter.mdx +29 -1
  36. package/package.json +3 -3
  37. package/skills/blume-migrate/SKILL.md +170 -0
  38. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  39. package/skills/blume-migrate/references/docusaurus.md +95 -0
  40. package/skills/blume-migrate/references/fumadocs.md +95 -0
  41. package/skills/blume-migrate/references/mintlify.md +156 -0
  42. package/skills/blume-migrate/references/monorepo.md +224 -0
  43. package/skills/blume-migrate/references/nextra.md +76 -0
  44. package/skills/blume-migrate/references/starlight.md +116 -0
  45. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +478 -0
  46. package/src/ai/llms.ts +15 -0
  47. package/src/astro/adapter-root.ts +70 -0
  48. package/src/astro/component-slots.ts +3 -2
  49. package/src/astro/generate.ts +132 -42
  50. package/src/astro/index.ts +1 -0
  51. package/src/astro/pages.ts +18 -3
  52. package/src/astro/templates.ts +158 -56
  53. package/src/audit/agent.ts +114 -0
  54. package/src/audit/catalog.ts +826 -0
  55. package/src/audit/checks/assets.ts +177 -0
  56. package/src/audit/checks/content.ts +231 -0
  57. package/src/audit/checks/duplicates.ts +131 -0
  58. package/src/audit/checks/i18n.ts +246 -0
  59. package/src/audit/checks/indexability.ts +213 -0
  60. package/src/audit/checks/links.ts +223 -0
  61. package/src/audit/checks/llms.ts +135 -0
  62. package/src/audit/checks/network.ts +272 -0
  63. package/src/audit/checks/og-image.ts +113 -0
  64. package/src/audit/checks/redirects.ts +87 -0
  65. package/src/audit/checks/robots.ts +114 -0
  66. package/src/audit/checks/sitemap.ts +229 -0
  67. package/src/audit/checks/social.ts +238 -0
  68. package/src/audit/crawl.ts +259 -0
  69. package/src/audit/graph.ts +74 -0
  70. package/src/audit/html.ts +54 -0
  71. package/src/audit/image-size.ts +63 -0
  72. package/src/audit/locate.ts +33 -0
  73. package/src/audit/redirects.ts +74 -0
  74. package/src/audit/report.ts +278 -0
  75. package/src/audit/run.ts +198 -0
  76. package/src/audit/snapshot.ts +189 -0
  77. package/src/audit/types.ts +214 -0
  78. package/src/audit/url.ts +103 -0
  79. package/src/cli/commands/audit.ts +205 -0
  80. package/src/cli/commands/build.ts +51 -12
  81. package/src/cli/index.ts +2 -0
  82. package/src/components/content/Callout.astro +8 -2
  83. package/src/components/content/Prompt.astro +25 -13
  84. package/src/components/content/Tabs.astro +98 -15
  85. package/src/components/layout/Breadcrumbs.astro +1 -1
  86. package/src/components/layout/Header.astro +5 -8
  87. package/src/components/layout/Logo.astro +13 -1
  88. package/src/components/layout/PageFeedback.astro +2 -2
  89. package/src/components/layout/PageLayout.astro +9 -9
  90. package/src/components/layout/Pagination.astro +7 -7
  91. package/src/components/layout/RootLayout.astro +9 -11
  92. package/src/components/layout/Search.astro +36 -7
  93. package/src/components/layout/TableOfContents.astro +1 -1
  94. package/src/components/layout/nav-utils.ts +9 -7
  95. package/src/components/openapi/Authorization.astro +80 -0
  96. package/src/components/openapi/Operation.astro +19 -1
  97. package/src/components/openapi/ParametersTable.astro +1 -1
  98. package/src/components/openapi/security.ts +201 -0
  99. package/src/components/openapi/snippets.ts +42 -13
  100. package/src/core/config-input.ts +94 -8
  101. package/src/core/data.ts +18 -2
  102. package/src/core/deployment-env.ts +9 -0
  103. package/src/core/diagnostics.ts +59 -12
  104. package/src/core/links.ts +2 -91
  105. package/src/core/nav-diagnostics.ts +48 -4
  106. package/src/core/navigation.ts +55 -13
  107. package/src/core/probe.ts +136 -0
  108. package/src/core/project-graph.ts +8 -0
  109. package/src/core/schema.ts +100 -1
  110. package/src/core/sources/normalize.ts +198 -25
  111. package/src/core/sources/types.ts +3 -1
  112. package/src/core/sources/watch.ts +5 -0
  113. package/src/core/standard-schema.ts +54 -0
  114. package/src/core/types.ts +23 -0
  115. package/src/deploy/adapter-output.ts +27 -15
  116. package/src/deploy/headers.ts +66 -0
  117. package/src/deploy/redirects.ts +49 -9
  118. package/src/markdown/index.ts +2 -0
  119. package/src/markdown/language-icon.ts +2 -1
  120. package/src/markdown/table-wrap.ts +43 -0
  121. package/src/og/card.ts +128 -36
  122. package/src/og/index.ts +1 -1
  123. package/src/og/logo.ts +21 -0
  124. package/src/openapi/references.ts +19 -16
  125. package/src/search/popular.ts +33 -0
  126. package/src/theme/entry.ts +56 -6
@@ -0,0 +1,66 @@
1
+ import { normalizeBasePath } from "../core/base-path.ts";
2
+ import type { ResolvedConfig } from "../core/schema.ts";
3
+
4
+ /**
5
+ * The `_headers` file for a static build (Netlify + Cloudflare Pages/Workers
6
+ * static assets). Blume's raw AI-ready endpoints — `/<route>.md`, `/<route>.mdx`,
7
+ * and the `.txt` files (`llms.txt`, `llms-full.txt`) — are valid UTF-8, but a
8
+ * static host serves them from the file extension alone, and common hosts send
9
+ * `text/markdown` / `text/plain` with **no** `charset`. Browsers then fall back
10
+ * to Windows-1252 for non-HTML text, so any non-ASCII docs (Japanese, accented
11
+ * Latin, …) render as mojibake when the raw URL is opened directly. HTML pages
12
+ * escape this because they carry `<meta charset>`; the raw endpoints have only
13
+ * the HTTP header. Pinning `charset=utf-8` here matches the Content-Type these
14
+ * same routes already send from the dev/server runtime (see
15
+ * `astro/templates.ts`). Hosts that don't read `_headers` (Vercel, S3) ignore
16
+ * the file harmlessly.
17
+ */
18
+
19
+ /**
20
+ * One rule per served extension. `.mdx` uses `text/markdown` to match the
21
+ * runtime endpoint, which serves both variants as `text/markdown`. The raw
22
+ * Markdown mirrors live under the page routes (which carry `basePath`), while
23
+ * the `.txt` files (`llms.txt`, `llms-full.txt`) are written to the dist root
24
+ * and served at the deployment base — so only the `.md`/`.mdx` rules take the
25
+ * `basePath` layer.
26
+ */
27
+ const HEADER_RULES: readonly {
28
+ contentType: string;
29
+ ext: string;
30
+ underBasePath: boolean;
31
+ }[] = [
32
+ {
33
+ contentType: "text/markdown; charset=utf-8",
34
+ ext: "md",
35
+ underBasePath: true,
36
+ },
37
+ {
38
+ contentType: "text/markdown; charset=utf-8",
39
+ ext: "mdx",
40
+ underBasePath: true,
41
+ },
42
+ {
43
+ contentType: "text/plain; charset=utf-8",
44
+ ext: "txt",
45
+ underBasePath: false,
46
+ },
47
+ ];
48
+
49
+ /**
50
+ * `_headers` contents: a `/*.<ext>` glob per rule with an indented
51
+ * `Content-Type` line, in the two-space format Netlify and Cloudflare read. The
52
+ * glob carries the served prefix (`{deployment.base}{basePath}` for the
53
+ * Markdown mirrors, `{deployment.base}` for the root `.txt` files) so the rules
54
+ * still match once the site is mounted under a subpath (`/docs/*.md`); the
55
+ * wildcard spans path segments, so a nested route like `/docs/ja/intro.md`
56
+ * matches too.
57
+ */
58
+ export const buildNetlifyHeaders = (config: ResolvedConfig): string => {
59
+ const deployBase = normalizeBasePath(config.deployment.base);
60
+ return `${HEADER_RULES.map((rule) => {
61
+ const prefix = rule.underBasePath
62
+ ? `${deployBase}${config.basePath}`
63
+ : deployBase;
64
+ return `${prefix}/*.${rule.ext}\n Content-Type: ${rule.contentType}`;
65
+ }).join("\n")}\n`;
66
+ };
@@ -1,4 +1,8 @@
1
- import { withBasePath } from "../core/base-path.ts";
1
+ import {
2
+ normalizeBasePath,
3
+ withBasePath,
4
+ withComposedBasePath,
5
+ } from "../core/base-path.ts";
2
6
  import type { ResolvedConfig } from "../core/schema.ts";
3
7
 
4
8
  /**
@@ -12,21 +16,57 @@ import type { ResolvedConfig } from "../core/schema.ts";
12
16
  type Redirect = ResolvedConfig["redirects"][number];
13
17
 
14
18
  /**
15
- * Prepend the site-wide `basePath` to each redirect's internal `from`/`to`
16
- * (both are authored as if mounted at root); external `to` URLs pass through.
17
- * Idempotent, so re-basing an already-based redirect is safe.
19
+ * Base a redirect for Astro's `redirects` config, where the two sides are not
20
+ * symmetric:
21
+ *
22
+ * - `from` gains only `basePath`. Astro builds the match pattern with
23
+ * `deployment.base` already applied (`getPattern(segments, config.base)`), so
24
+ * adding it here would serve the redirect at `{base}{base}/from`.
25
+ * - `to` gains the full `{deployment.base}{basePath}` stack. Astro resolves a
26
+ * destination that matches a known route by regenerating it from that route's
27
+ * segments, which carry no base — and passes an unmatched destination through
28
+ * verbatim. Neither path prepends `base`, so a root-relative `to` escapes the
29
+ * base entirely (withastro/astro#7774, still the behavior in Astro 7).
30
+ *
31
+ * Both sides are authored as if mounted at root; external `to` URLs pass
32
+ * through. Idempotent, so a hand-written base isn't doubled.
18
33
  */
19
- export const applyBaseToRedirects = (
34
+ export const applyBaseToAstroRedirects = (
20
35
  redirects: Redirect[],
21
- basePath: string
22
- ): Redirect[] =>
23
- basePath
36
+ basePath: string,
37
+ deployBase: string
38
+ ): Redirect[] => {
39
+ // `deployment.base` arrives as the user wrote it (Astro accepts `/base/`,
40
+ // even `base`); normalizing here keeps the composed paths well-formed.
41
+ const base = normalizeBasePath(deployBase);
42
+ return basePath || base
24
43
  ? redirects.map((redirect) => ({
25
44
  ...redirect,
26
45
  from: withBasePath(basePath, redirect.from),
27
- to: withBasePath(basePath, redirect.to),
46
+ to: withComposedBasePath(base, basePath, redirect.to),
28
47
  }))
29
48
  : redirects;
49
+ };
50
+
51
+ /**
52
+ * Base a redirect for the host platform files below. Unlike Astro's config,
53
+ * these are matched against the real served URL, so both sides carry the full
54
+ * `{deployment.base}{basePath}` stack.
55
+ */
56
+ export const applyBaseToPlatformRedirects = (
57
+ redirects: Redirect[],
58
+ basePath: string,
59
+ deployBase: string
60
+ ): Redirect[] => {
61
+ const base = normalizeBasePath(deployBase);
62
+ return basePath || base
63
+ ? redirects.map((redirect) => ({
64
+ ...redirect,
65
+ from: withComposedBasePath(base, basePath, redirect.from),
66
+ to: withComposedBasePath(base, basePath, redirect.to),
67
+ }))
68
+ : redirects;
69
+ };
30
70
 
31
71
  /** `_redirects` text (Netlify + Cloudflare Pages): `from to status` per line. */
32
72
  export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
@@ -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
@@ -1,15 +1,26 @@
1
- import { Renderer } from "@takumi-rs/core";
2
- import { container, image, text } from "@takumi-rs/helpers";
3
- import type { Node } from "@takumi-rs/helpers";
1
+ import { render } from "takumi-js";
2
+ import type { RenderOptions } from "takumi-js";
3
+ import { container, googleFonts, image, text } from "takumi-js/helpers";
4
+ import type { FontSubset, GoogleFontFamily, Node } from "takumi-js/helpers";
4
5
 
5
6
  import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "./dimensions.ts";
6
7
 
7
- // Reuse one renderer (and its loaded default fonts) across all images.
8
- let renderer: Renderer | null = null;
9
- const getRenderer = (): Renderer => {
10
- renderer ??= new Renderer();
11
- return renderer;
12
- };
8
+ /**
9
+ * A Google Font family to load into the OG card renderer. A bare string is the
10
+ * family name (weight 400, normal style); the object form pins weight and style.
11
+ * Handed straight to Takumi's `googleFonts` helper, which fetches the family
12
+ * from Google Fonts at build and returns per-glyph coverage subsets.
13
+ */
14
+ export type OgFont =
15
+ | string
16
+ | {
17
+ /** Google Fonts family name, e.g. `"Noto Sans JP"`. */
18
+ name: string;
19
+ /** `400`, `[400, 700]`, or a variable range like `"100..900"`. */
20
+ weight?: number | number[] | string;
21
+ /** `"normal"`, `"italic"`, or both. */
22
+ style?: "normal" | "italic" | ("normal" | "italic")[];
23
+ };
13
24
 
14
25
  const ACCENT_HEX: Record<string, string> = {
15
26
  blue: "#3b82f6",
@@ -21,43 +32,101 @@ const ACCENT_HEX: Record<string, string> = {
21
32
  teal: "#14b8a6",
22
33
  };
23
34
 
24
- const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
35
+ // Named presets map to Blume's palette hex (the preset "blue" is not CSS
36
+ // blue); anything else is handed to Takumi as-is — it parses the full CSS
37
+ // color grammar, and a genuinely malformed value fails the build with a
38
+ // parse error naming it. `hasOwn` keeps a preset name like "constructor"
39
+ // from resolving up the prototype chain.
40
+ const resolveAccent = (accent: string): string =>
41
+ Object.hasOwn(ACCENT_HEX, accent) ? (ACCENT_HEX[accent] as string) : accent;
25
42
 
26
- // OG rendering uses hex (Takumi's color parser does not accept oklch); named
27
- // presets map to hex, well-formed hex passes through, anything else falls
28
- // back — a malformed hex (`#12345` typo) would throw inside Takumi and fail
29
- // the build at OG prerender with an opaque native error. `hasOwn` keeps a
30
- // preset name like "constructor" from resolving up the prototype chain.
31
- const resolveAccent = (accent: string): string => {
32
- if (Object.hasOwn(ACCENT_HEX, accent)) {
33
- return ACCENT_HEX[accent] as string;
34
- }
35
- return HEX_COLOR.test(accent) ? accent : "#3b82f6";
36
- };
43
+ export interface OgCardPalette {
44
+ accent?: string;
45
+ background?: string;
46
+ border?: string;
47
+ foreground?: string;
48
+ muted?: string;
49
+ }
37
50
 
38
51
  export interface OgCardOptions {
39
52
  /** Large headline — the page title. */
40
53
  title: string;
41
- /** Accent color (named preset or hex) for the fallback brand mark. */
54
+ /** Accent color (named preset or any CSS color) for the fallback brand mark. */
42
55
  accent?: string;
43
56
  /** Brand/site name shown in the top-left lockup. */
44
57
  brand?: string;
45
58
  /** Muted subtitle under the headline (usually the site description). */
46
59
  description?: string;
47
60
  /**
48
- * Inlined SVG markup of the configured logo (`config.logo.svg`), painted into
61
+ * Inlined SVG markup of the configured logo, painted into
49
62
  * the brand lockup. Falls back to an accent mark when absent.
50
63
  */
51
64
  logo?: string;
65
+ /** Optional colors for the generated card. */
66
+ palette?: OgCardPalette;
52
67
  /** Footer-left repository slug, e.g. `owner/repo`. */
53
68
  repo?: string;
54
69
  /** Footer-right site host, e.g. `docs.acme.com`. */
55
70
  site?: string;
71
+ /**
72
+ * Pre-fetched image entries, or a group controlling how remote images (and
73
+ * emoji glyphs) are fetched. Blume merges in a shared glyph cache; see
74
+ * {@link resolveImages}.
75
+ */
76
+ images?: RenderOptions["images"];
77
+ /**
78
+ * Google Font families for non-Latin titles. Takumi's built-in font covers
79
+ * only Latin, so a CJK (etc.) title renders as tofu without a family that
80
+ * covers its script — see {@link loadFonts}.
81
+ */
82
+ fonts?: OgFont[];
56
83
  }
57
84
 
58
85
  const WIDTH = OG_IMAGE_WIDTH;
59
86
  const HEIGHT = OG_IMAGE_HEIGHT;
60
87
 
88
+ // Emoji in a title render as Twemoji glyphs Takumi fetches from a CDN, once per
89
+ // render. A build prerenders one card per page, so an emoji in the site title
90
+ // would otherwise refetch the same glyph for every page. This cache is keyed by
91
+ // URL and holds the in-flight promise, so concurrent renders share one request
92
+ // and a build fetches each glyph once. Unbounded on purpose: it is scoped to the
93
+ // glyphs a site's own titles reference, which is a handful.
94
+ const imageFetchCache = new Map<string, Promise<ArrayBuffer>>();
95
+
96
+ /**
97
+ * Merge the shared glyph cache into the caller's `images`. An explicit
98
+ * `fetchCache` wins, so a caller can scope or opt out of the cache.
99
+ */
100
+ const resolveImages = (
101
+ images: OgCardOptions["images"]
102
+ ): OgCardOptions["images"] =>
103
+ Array.isArray(images)
104
+ ? { fetchCache: imageFetchCache, sources: images }
105
+ : { fetchCache: imageFetchCache, ...images };
106
+
107
+ // Google Fonts subsets keyed by the family set, so a build's identical per-page
108
+ // renders build the subset list once. `googleFonts` also caches the css2 request
109
+ // process-wide and the shared renderer skips subset files it has already loaded,
110
+ // so this only avoids rebuilding the list per card.
111
+ const fontSubsetCache = new Map<string, Promise<FontSubset[]>>();
112
+
113
+ /**
114
+ * Load the configured Google Font families as coverage subsets for `render`'s
115
+ * `fonts` option. `googleFonts` fetches the families from Google Fonts in one
116
+ * css2 request; `render` then registers only the subsets a card's text uses. A
117
+ * fetch failure rejects, failing the build with the cause rather than silently
118
+ * shipping tofu — the same fail-fast the OG accent relies on.
119
+ */
120
+ const loadFonts = (fonts: OgFont[]): Promise<FontSubset[]> => {
121
+ const key = JSON.stringify(fonts);
122
+ let pending = fontSubsetCache.get(key);
123
+ if (!pending) {
124
+ pending = googleFonts(fonts as GoogleFontFamily[]);
125
+ fontSubsetCache.set(key, pending);
126
+ }
127
+ return pending;
128
+ };
129
+
61
130
  // Light neutral scale mirrored from the docs homepage theme tokens:
62
131
  // FOREGROUND = --foreground, MUTED = --muted-foreground, FAINT = that lighter,
63
132
  // BORDER = --border.
@@ -67,6 +136,17 @@ const MUTED = "#737373";
67
136
  const FAINT = "#a3a3a3";
68
137
  const BORDER = "#e5e5e5";
69
138
 
139
+ const resolvePalette = (
140
+ options: OgCardOptions
141
+ ): Required<OgCardPalette> & { faint: string } => ({
142
+ accent: resolveAccent(options.palette?.accent ?? options.accent ?? "blue"),
143
+ background: options.palette?.background ?? BG,
144
+ border: options.palette?.border ?? BORDER,
145
+ faint: options.palette?.muted ?? FAINT,
146
+ foreground: options.palette?.foreground ?? FOREGROUND,
147
+ muted: options.palette?.muted ?? MUTED,
148
+ });
149
+
70
150
  /**
71
151
  * Truncate to `max` code points with an ellipsis. Slices by code points, not
72
152
  * UTF-16 units, so cutting mid-emoji doesn't leave a lone surrogate (a broken
@@ -103,8 +183,8 @@ const logoAspect = (svg: string): number | null => {
103
183
  // Render the configured logo as the brand mark. A `currentColor` logo carries
104
184
  // no intrinsic color, so it is painted in the foreground to read on the light
105
185
  // 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);
186
+ const logoMark = (svg: string, foreground: string): Node => {
187
+ const painted = svg.replaceAll("currentColor", foreground);
108
188
  const aspect = logoAspect(painted);
109
189
  let height = MARK_HEIGHT;
110
190
  let width = aspect ? MARK_HEIGHT * aspect : MARK_HEIGHT;
@@ -150,8 +230,11 @@ const titleSize = (title: string): number => {
150
230
  };
151
231
 
152
232
  /** Render a 1200x630 Open Graph card to a PNG buffer. */
153
- export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
154
- const accent = resolveAccent(options.accent ?? "blue");
233
+ export const renderOgImage = async (
234
+ options: OgCardOptions
235
+ ): Promise<Uint8Array> => {
236
+ const { accent, background, border, faint, foreground, muted } =
237
+ resolvePalette(options);
155
238
  const brand = options.brand?.trim();
156
239
  const logo = options.logo?.trim();
157
240
  // Slice by code point, not code unit — `charAt(0)` would split a leading
@@ -166,14 +249,16 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
166
249
  // ("Ultracite Ultracite"). Without a logo, the accent tile with the brand
167
250
  // initial stands in.
168
251
  const header = container({
169
- children: [logo ? logoMark(logo) : initialMark(accent, initial)],
252
+ children: [
253
+ logo ? logoMark(logo, foreground) : initialMark(accent, initial),
254
+ ],
170
255
  style: { alignItems: "center", display: "flex" },
171
256
  });
172
257
 
173
258
  const body = container({
174
259
  children: [
175
260
  text(truncate(options.title, 64), {
176
- color: FOREGROUND,
261
+ color: foreground,
177
262
  fontSize: titleSize(options.title),
178
263
  fontWeight: 600,
179
264
  letterSpacing: "-0.03em",
@@ -183,7 +268,7 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
183
268
  }),
184
269
  description
185
270
  ? text(truncate(description, 140), {
186
- color: MUTED,
271
+ color: muted,
187
272
  fontSize: 30,
188
273
  lineHeight: 1.4,
189
274
  marginTop: 28,
@@ -200,15 +285,15 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
200
285
  ? container({
201
286
  children: [
202
287
  container({
203
- style: { backgroundColor: BORDER, height: 1, width: "100%" },
288
+ style: { backgroundColor: border, height: 1, width: "100%" },
204
289
  }),
205
290
  container({
206
291
  children: [
207
292
  repo
208
- ? text(repo, { color: MUTED, fontSize: 22 })
293
+ ? text(repo, { color: muted, fontSize: 22 })
209
294
  : container({}),
210
295
  site
211
- ? text(site, { color: FAINT, fontSize: 22 })
296
+ ? text(site, { color: faint, fontSize: 22 })
212
297
  : container({}),
213
298
  ],
214
299
  style: {
@@ -227,8 +312,8 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
227
312
  const node = container({
228
313
  children: [header, body, footer],
229
314
  style: {
230
- backgroundColor: BG,
231
- color: FOREGROUND,
315
+ backgroundColor: background,
316
+ color: foreground,
232
317
  display: "flex",
233
318
  flexDirection: "column",
234
319
  height: HEIGHT,
@@ -238,9 +323,16 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
238
323
  },
239
324
  });
240
325
 
241
- return getRenderer().render(node, {
326
+ const fonts = options.fonts ?? [];
327
+ // `render` registers only the subsets a card's text uses and skips files it
328
+ // has already loaded, so passing the full family list per page is cheap.
329
+ const fontSubsets = fonts.length ? await loadFonts(fonts) : undefined;
330
+
331
+ return render(node, {
332
+ fonts: fontSubsets,
242
333
  format: "png",
243
334
  height: HEIGHT,
335
+ images: resolveImages(options.images),
244
336
  width: WIDTH,
245
337
  });
246
338
  };
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, OgFont } 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
@@ -0,0 +1,33 @@
1
+ import { withBasePath } from "../core/base-path.ts";
2
+
3
+ /** A resolved search empty-state link passed to the Search dialog. */
4
+ export interface SearchPopularPage {
5
+ /** Built-in icon name; `Search.astro` resolves it to markup at render time. */
6
+ icon?: string;
7
+ label: string;
8
+ route: string;
9
+ }
10
+
11
+ /**
12
+ * Map configured `search.popular` entries to `{ route, label }` for Search.
13
+ *
14
+ * Curated hrefs are authored as if mounted at root, so `basePath` is applied
15
+ * here — matching `navigation.featured` and tab paths, and agreeing with the
16
+ * sidebar fallback these entries replace (whose routes are already based via
17
+ * `page.route`). `withBasePath` is idempotent and skips external URLs.
18
+ *
19
+ * `deployment.base` is deliberately *not* applied: routes stay deploy-base-less
20
+ * so `createLinkRow` can prefix it at click time, same as sidebar-derived pages.
21
+ *
22
+ * `icon` stays a name here (like `navigation.featured`): the icon set is a
23
+ * server-only module, so `Search.astro` resolves it to inline SVG at render.
24
+ */
25
+ export const resolveSearchPopular = (
26
+ popular: { href: string; icon?: string; label: string }[],
27
+ basePath: string
28
+ ): SearchPopularPage[] =>
29
+ popular.map(({ href, icon, label }) => ({
30
+ ...(icon ? { icon } : {}),
31
+ label,
32
+ route: withBasePath(basePath, href),
33
+ }));