blume 0.6.2 → 0.6.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.
@@ -347,11 +347,6 @@ export interface ThemeConfig {
347
347
  mode?: "system" | "light" | "dark";
348
348
  /** Corner radius scale. Defaults to `md`. */
349
349
  radius?: "none" | "sm" | "md" | "lg";
350
- /**
351
- * Strict token mode: fail rather than silently fall back when a theme token
352
- * is missing. Defaults to `false`.
353
- */
354
- strict?: boolean;
355
350
  }
356
351
  /** Public credentials for the Algolia backend (the sync key stays an env var). */
357
352
  export interface AlgoliaSearch {
@@ -432,7 +427,7 @@ export interface AskConfig {
432
427
  export interface AiConfig {
433
428
  /** The Ask AI chat assistant. */
434
429
  ask?: AskConfig;
435
- /** Emit `llms.txt` (an index of the docs for LLMs). Defaults to `false`. */
430
+ /** Emit `llms.txt` (an index of the docs for LLMs). Defaults to `true`. */
436
431
  llmsTxt?: boolean;
437
432
  }
438
433
  /** An arbitrary analytics `<script>`; set exactly one of `src` or `content`. */
@@ -48,7 +48,7 @@ import type { Diagnostic } from "./types.ts";
48
48
  *
49
49
  * **Appearance**
50
50
  * - `theme` — `accent` color, `fonts` (curated Google Font slugs), `radius`,
51
- * `mode` (`system`/`light`/`dark`), `background`, and `strict` token mode.
51
+ * `mode` (`system`/`light`/`dark`), and `background`.
52
52
  * - `markdown` — `code` (language icons, inline highlighting, line wrap),
53
53
  * `headingAnchors`, `imageZoom`, and opt-in KaTeX `math`.
54
54
  * - `toc` — on-page table of contents; `true`/`false` or a heading-level range.
@@ -2395,9 +2395,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
2395
2395
  layout: z.ZodDefault<z.ZodEnum<["sidebar"]>>;
2396
2396
  mode: z.ZodDefault<z.ZodEnum<["system", "light", "dark"]>>;
2397
2397
  radius: z.ZodDefault<z.ZodEnum<["none", "sm", "md", "lg"]>>;
2398
- strict: z.ZodDefault<z.ZodBoolean>;
2399
2398
  }, "strict", z.ZodTypeAny, {
2400
- strict: boolean;
2401
2399
  accent: {
2402
2400
  dark: string;
2403
2401
  light: string;
@@ -2420,7 +2418,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
2420
2418
  light?: string | undefined;
2421
2419
  } | undefined;
2422
2420
  }, {
2423
- strict?: boolean | undefined;
2424
2421
  accent?: string | {
2425
2422
  dark: string;
2426
2423
  light: string;
@@ -2599,7 +2596,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
2599
2596
  })[] | undefined;
2600
2597
  };
2601
2598
  theme: {
2602
- strict: boolean;
2603
2599
  accent: {
2604
2600
  dark: string;
2605
2601
  light: string;
@@ -2919,7 +2915,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
2919
2915
  vercel?: boolean | undefined;
2920
2916
  } | undefined;
2921
2917
  theme?: {
2922
- strict?: boolean | undefined;
2923
2918
  accent?: string | {
2924
2919
  dark: string;
2925
2920
  light: string;
@@ -100,6 +100,10 @@ redirects: [{ from: "/old", to: "/new", status: 301 }];
100
100
 
101
101
  `status` accepts `301`, `302`, `307`, or `308` (default `301`). Server builds handle redirects at request time. Static builds emit redirect pages **and** platform files so your host issues a real HTTP redirect: `_redirects` (Netlify, Cloudflare Pages), `vercel.json` (Vercel), and `blume-redirects.json` — a structured manifest for anything else (nginx/Apache rules, an edge worker). A `_redirects` or `vercel.json` you ship in `public/` is left untouched.
102
102
 
103
+ :::note
104
+ `from` is matched as an exact path — wildcards and pattern matching (e.g. `/blog/:slug` or `/old/*`) aren't supported. If you need pattern-based rules, handle them in an infrastructure file like `vercel.json` (which supports wildcard `source` patterns) or your host's redirect config instead. A `vercel.json` you ship in `public/` is preserved as-is.
105
+ :::
106
+
103
107
  ## Environment variables
104
108
 
105
109
  When a feature needs a runtime secret, Blume warns at `blume dev`/`build` if it's missing — so the problem surfaces early instead of at the first request:
@@ -0,0 +1,132 @@
1
+ ---
2
+ title: FAQ
3
+ description: Common questions about Blume — how it compares to other documentation tools, and why a Markdown formatter might collapse your callout directives.
4
+ sidebar:
5
+ label: FAQ
6
+ ---
7
+
8
+ Answers to questions that come up often. Missing one? [Open an issue](https://github.com/haydenbleasel/blume/issues) or ask the in-page assistant.
9
+
10
+ ## How is Blume different from Mintlify, Fumadocs, and others?
11
+
12
+ Most documentation tools sit at one of two extremes. **Managed platforms** like Mintlify give you a polished result fast, but the build and hosting are their service — you author inside their system and deploy to their infrastructure. **Component libraries and starters** like Fumadocs, Nextra, or Docusaurus are open-source and flexible, but they hand you an application (a Next.js or React project) that you scaffold, wire up, and maintain before and after you write a word.
13
+
14
+ Blume takes a third path: **the framework is the template.** You point it at a folder of Markdown and it generates and drives the whole site — navigation, search, theming, Open Graph images, SEO, and AI endpoints — with no app to own. It's fully open-source and self-hostable, so there's no managed service and no vendor lock-in, but there's also no boilerplate to maintain.
15
+
16
+ | | Blume | Mintlify | Fumadocs / Nextra / Docusaurus |
17
+ | --- | --- | --- | --- |
18
+ | **Model** | Zero-config framework; content only | Hosted platform | Library + app you scaffold |
19
+ | **Source** | Open-source (MIT) | Closed core | Open-source |
20
+ | **Hosting** | Anywhere — static or a server function | Their managed infrastructure | Anywhere; you build and deploy |
21
+ | **You maintain** | Your Markdown | Your Markdown + platform config | Your Markdown + the app around it |
22
+ | **Rendering** | Astro; core theme ships zero client JS | Their runtime | React/Next.js runtime |
23
+ | **AI features** | `llms.txt`, raw Markdown, Ask AI, MCP — built in, no hosted service | Built in (hosted) | Bring your own |
24
+
25
+ A few consequences worth calling out:
26
+
27
+ - **You own the output.** `blume build` produces a plain site you host on Vercel, Netlify, Cloudflare, S3, or your own box. Nothing phones home.
28
+ - **No lock-in, two ways out.** Your content is portable Markdown, and `blume eject` turns the project into a standalone Astro app that still uses the `blume` package when you want full control.
29
+ - **Fast by default.** The core theme is React-free and renders static HTML, so pages score well on Core Web Vitals without tuning. You opt into server features (Ask AI, MCP) only when you need them.
30
+ - **Type-safe configuration.** `blume.config.ts` and every `meta.ts` are real TypeScript validated by a schema — not loosely-typed YAML.
31
+
32
+ :::note
33
+ This isn't "better than everything" — managed platforms and full frameworks are the right call when you want a hosted product or maximum control over the app. Blume is for teams who want a production-grade docs site without owning either the platform or the plumbing.
34
+ :::
35
+
36
+ See [Why Blume exists](/docs) for the longer version.
37
+
38
+ ## Is Blume free and open-source?
39
+
40
+ Yes — Blume is MIT-licensed and free. You install the `blume` package, keep your content in your own repository, and host the build wherever you like. There's no paid tier, no per-seat pricing, and no account to sign up for. The source lives on [GitHub](https://github.com/haydenbleasel/blume).
41
+
42
+ ## Do I need to know Astro, React, or Tailwind?
43
+
44
+ No. A folder of Markdown is a complete site — navigation, search, and theming are inferred or set with a handful of tokens. You only reach for the underlying stack when you want to customize: [interactive islands](/docs/content/islands) (React), [component overrides](/docs/configuration/customization), or [theme tokens](/docs/configuration/theming) (Tailwind). Even then, [`blume.config.ts`](/docs/configuration) is typed, so your editor guides you.
45
+
46
+ ## Can I use React components and MDX?
47
+
48
+ Yes. Any page can be `.md` or `.mdx`, and MDX lets you drop in the [built-in components](/docs/content/components) with no imports. You can also add your own `.tsx`/`.jsx` [islands](/docs/content/islands) — Blume auto-enables React only for the pages that use them, so the core theme stays JavaScript-free everywhere else.
49
+
50
+ ## Where can I deploy it?
51
+
52
+ Anywhere. `blume build` outputs static HTML by default, which you can serve from any static host or CDN — Vercel, Netlify, Cloudflare Pages, GitHub Pages, S3, or your own server. Server-only features (Ask AI, the MCP server, on-demand rendering) switch the build to a server function through an adapter for Vercel, Node, Netlify, or Cloudflare. See [Deployment](/docs/deployment).
53
+
54
+ ## Does search need a hosted service?
55
+
56
+ No. [Orama](/docs/configuration/search) builds a local index that works in both dev and production with nothing to host or pay for. For very large sites, [Pagefind](/docs/configuration/search) is one flag away. Either way the index ships as part of your site.
57
+
58
+ ## How do I customize the look?
59
+
60
+ Start with [theme tokens](/docs/configuration/theming) — accent color, fonts, radius, and a `theme.css` for anything else Tailwind can express. Go further by [overriding built-in components](/docs/configuration/customization) or adding [custom pages](/docs/configuration/customization#custom-pages). When you want the Astro project itself, [`blume eject`](/docs/reference/cli) hands you a standalone app that still uses the `blume` package.
61
+
62
+ ## Why is oxfmt / Ultracite collapsing my directives?
63
+
64
+ If you format your Markdown with [Ultracite](https://www.ultracite.ai) (which runs oxlint + [oxfmt](https://oxc.rs)) — as Blume itself does — you may notice that container directives get flattened onto a single line after a format pass:
65
+
66
+ ```md
67
+ :::note
68
+ Regenerate the project with blume dev.
69
+ :::
70
+ ```
71
+
72
+ becomes
73
+
74
+ ```md
75
+ :::note Regenerate the project with blume dev. :::
76
+ ```
77
+
78
+ Once the opening `:::note` fence is joined to the prose, it's no longer a directive, so it renders as literal text instead of a [callout](/docs/content/syntax#callouts).
79
+
80
+ ### Why it happens
81
+
82
+ This is a bug in oxfmt's Markdown formatter (inherited from Prettier's Markdown printer — see [prettier/prettier#19040](https://github.com/prettier/prettier/pull/19040)). When it wraps prose, it treats the `:::` fence lines as ordinary text and joins them with the adjacent line, breaking the directive. It affects every container directive — `:::note`, `:::tip`, `:::warning`, `:::danger`, `:::success`.
83
+
84
+ We reported it upstream in [oxc-project/oxc#24096](https://github.com/oxc-project/oxc/issues/24096); until it's fixed there, the patch below is the workaround.
85
+
86
+ ### The fix
87
+
88
+ Patch oxfmt so it preserves the line break that sits directly against a `:::` fence. Blume ships exactly this patch in its own repo, and you can apply the same one in any project.
89
+
90
+ 1. Save the patch as `patches/oxfmt@0.55.0.patch`:
91
+
92
+ ```diff patches/oxfmt@0.55.0.patch
93
+ diff --git a/dist/markdown-Bowe09rB.js b/dist/markdown-Bowe09rB.js
94
+ index 0fe63ff712e07c9c2188711fa0940f7a7d8f7d74..44435d91a2f5a6711512981c9bcaddedc1760be4 100644
95
+ --- a/dist/markdown-Bowe09rB.js
96
+ +++ b/dist/markdown-Bowe09rB.js
97
+ @@ -1141,7 +1141,14 @@ function Yf(e, r, t) {
98
+ case "sentence": return Yi(e, t);
99
+ case "word": return Li(e);
100
+ case "whitespace": {
101
+ - let { next: i } = e, u = i && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/u.test(i.value) ? "never" : r.proseWrap;
102
+ + let { next: i, previous: oxfmtFencePrev } = e;
103
+ + // Preserve line breaks that sit directly against a `:::` container
104
+ + // directive fence, so `proseWrap: "never"` keeps the opening/closing
105
+ + // fence on their own lines instead of joining them into the prose (which
106
+ + // breaks the directive). Ordinary prose still wraps per proseWrap.
107
+ + // See prettier/prettier#19040.
108
+ + let oxfmtIsFence = (w) => w != null && typeof w.value === "string" && w.value.startsWith(":::");
109
+ + let u = oxfmtIsFence(oxfmtFencePrev) || oxfmtIsFence(i) ? "preserve" : i && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/u.test(i.value) ? "never" : r.proseWrap;
110
+ return qt(e, n.value, u);
111
+ }
112
+ case "emphasis": {
113
+ ```
114
+
115
+ 2. Register it with your package manager's `patchedDependencies`. With Bun or pnpm, add to `package.json`:
116
+
117
+ ```json package.json
118
+ {
119
+ "patchedDependencies": {
120
+ "oxfmt@0.55.0": "patches/oxfmt@0.55.0.patch"
121
+ }
122
+ }
123
+ ```
124
+
125
+ 3. Reinstall so the patch is applied:
126
+
127
+ ```package-install
128
+ bun install
129
+ ```
130
+
131
+ :::warning[Version-pinned] The patch targets oxfmt `0.55.0` — its diff references a file hashed to that build (`dist/markdown-Bowe09rB.js`). When you bump oxfmt, regenerate the patch (e.g. `bun patch oxfmt`) or check whether the upstream fix has landed and the patch is no longer needed.
132
+ :::
@@ -3,19 +3,19 @@ title: AI
3
3
  description: Machine-readable docs with llms.txt and an optional in-page Ask AI assistant.
4
4
  ---
5
5
 
6
- Blume has several opt-in AI features: machine-readable docs for external tools (`llms.txt`), an in-page **Ask AI** assistant, and a hosted **MCP server** for coding agents. Everything is off by default, and static docs stay fully static until you turn a feature on.
6
+ Blume has a few AI features: machine-readable docs for external tools (`llms.txt`, on by default), an in-page **Ask AI** assistant, and a hosted **MCP server** for coding agents. Ask AI and MCP are opt-in, and static docs stay fully static until you turn a feature on.
7
7
 
8
8
  ## llms.txt
9
9
 
10
- Emit machine-readable versions of your docs that coding agents and chat assistants can consume:
10
+ Blume emits machine-readable versions of your docs that coding agents and chat assistants can consume. This is on by default; set `llmsTxt: false` to turn it off:
11
11
 
12
12
  ```ts blume.config.ts lineNumbers
13
13
  ai: {
14
- llmsTxt: true,
14
+ llmsTxt: false,
15
15
  }
16
16
  ```
17
17
 
18
- With this on, `blume build` writes two files to the root of your site:
18
+ While enabled, `blume build` writes two files to the root of your site:
19
19
 
20
20
  - **`/llms.txt`** — a compact index: your site title and description, then a linked list of every page with its summary.
21
21
  - **`/llms-full.txt`** — the entire corpus: each page's full Markdown body, with its source URL, in one file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -28,7 +28,7 @@ const { prev, next, strings } = Astro.props;
28
28
  >
29
29
  <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
30
30
  <span>
31
- <span class="block text-muted-foreground text-xs">
31
+ <span class="block text-muted-foreground text-xs max-md:hidden">
32
32
  {strings.previous}
33
33
  </span>
34
34
  <span class="block font-medium">{prev.label}</span>
@@ -43,7 +43,7 @@ const { prev, next, strings } = Astro.props;
43
43
  href={next.route}
44
44
  >
45
45
  <span>
46
- <span class="block text-muted-foreground text-xs">
46
+ <span class="block text-muted-foreground text-xs max-md:hidden">
47
47
  {strings.next}
48
48
  </span>
49
49
  <span class="block font-medium">{next.label}</span>
@@ -367,11 +367,15 @@ const bannerScript = banner?.dismissible
367
367
  class:list={["mx-auto grid grid-cols-1 items-start", gridClass]}
368
368
  data-blume-doc-grid
369
369
  >
370
- {
371
- showSidebar && (
372
370
  <aside
373
371
  aria-label="Primary"
374
- class="fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4"
372
+ class:list={[
373
+ "fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4",
374
+ // A "bare" landing (the changelog index) has no sidebar column on
375
+ // desktop, but the header hamburger still needs a drawer to open on
376
+ // mobile — without it the toggle only locked page scroll.
377
+ !showSidebar && "lg:hidden",
378
+ ]}
375
379
  >
376
380
  {
377
381
  // Pinned links (blog, changelog, contact…) sit above every section on
@@ -419,9 +423,17 @@ const bannerScript = banner?.dismissible
419
423
  // The header's tab bar is hidden below `md`, so the drawer is the only
420
424
  // way to move between sections on mobile: list the tabs above the
421
425
  // page tree, separated by a divider. Hidden from `md` up, where the
422
- // header tabs take over.
426
+ // header tabs take over — unless the page tree is empty (a tab-scoped
427
+ // standalone page like the changelog), where they keep the drawer
428
+ // from opening blank.
423
429
  navigation.tabs.length > 0 && (
424
- <nav aria-label="Sections" class="mb-4 border-border border-b pb-4 md:hidden">
430
+ <nav
431
+ aria-label="Sections"
432
+ class:list={[
433
+ "mb-4 border-border border-b pb-4",
434
+ sidebar.length > 0 && "md:hidden",
435
+ ]}
436
+ >
425
437
  <ul class="m-0 list-none p-0">
426
438
  {navigation.tabs.map((tab) => (
427
439
  <li>
@@ -460,8 +472,6 @@ const bannerScript = banner?.dismissible
460
472
  }
461
473
  </nav>
462
474
  </aside>
463
- )
464
- }
465
475
  <main class="px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
466
476
  <BreadcrumbsSlot crumbs={crumbs} wide={isApiOperation} />
467
477
  <TableOfContentsSlot
@@ -406,11 +406,6 @@ export interface ThemeConfig {
406
406
  mode?: "system" | "light" | "dark";
407
407
  /** Corner radius scale. Defaults to `md`. */
408
408
  radius?: "none" | "sm" | "md" | "lg";
409
- /**
410
- * Strict token mode: fail rather than silently fall back when a theme token
411
- * is missing. Defaults to `false`.
412
- */
413
- strict?: boolean;
414
409
  }
415
410
 
416
411
  // ---------------------------------------------------------------------------
@@ -512,7 +507,7 @@ export interface AskConfig {
512
507
  export interface AiConfig {
513
508
  /** The Ask AI chat assistant. */
514
509
  ask?: AskConfig;
515
- /** Emit `llms.txt` (an index of the docs for LLMs). Defaults to `false`. */
510
+ /** Emit `llms.txt` (an index of the docs for LLMs). Defaults to `true`. */
516
511
  llmsTxt?: boolean;
517
512
  }
518
513
 
@@ -56,7 +56,7 @@ import type { Diagnostic } from "./types.ts";
56
56
  *
57
57
  * **Appearance**
58
58
  * - `theme` — `accent` color, `fonts` (curated Google Font slugs), `radius`,
59
- * `mode` (`system`/`light`/`dark`), `background`, and `strict` token mode.
59
+ * `mode` (`system`/`light`/`dark`), and `background`.
60
60
  * - `markdown` — `code` (language icons, inline highlighting, line wrap),
61
61
  * `headingAnchors`, `imageZoom`, and opt-in KaTeX `math`.
62
62
  * - `toc` — on-page table of contents; `true`/`false` or a heading-level range.
@@ -108,6 +108,16 @@ const pageOrder = (page: PageRecord, filename: string): number => {
108
108
  if (filename.replace(extname(filename), "") === "index") {
109
109
  return Number.NEGATIVE_INFINITY;
110
110
  }
111
+ // Changelog entries read newest-first, matching the generated timeline. Sort
112
+ // on the negated publish timestamp so a later date yields a smaller order
113
+ // under the ascending comparator; undated entries fall back to filename order.
114
+ if (page.contentType === "changelog") {
115
+ const iso = page.meta.date ?? page.meta.changelog?.date;
116
+ const time = iso ? Date.parse(iso) : Number.NaN;
117
+ if (!Number.isNaN(time)) {
118
+ return -time;
119
+ }
120
+ }
111
121
  return numericOrder(filename);
112
122
  };
113
123
 
@@ -482,7 +482,6 @@ const themeConfigSchema = z
482
482
  layout: z.enum(["sidebar"]).default("sidebar"),
483
483
  mode: z.enum(["system", "light", "dark"]).default("system"),
484
484
  radius: z.enum(["none", "sm", "md", "lg"]).default("md"),
485
- strict: z.boolean().default(false),
486
485
  })
487
486
  .strict();
488
487
 
@@ -620,7 +619,7 @@ const aiConfigSchema = z
620
619
  }
621
620
  })
622
621
  .optional(),
623
- llmsTxt: z.boolean().default(false),
622
+ llmsTxt: z.boolean().default(true),
624
623
  })
625
624
  .strict();
626
625
 
package/src/og/card.ts CHANGED
@@ -80,27 +80,34 @@ export const truncate = (value: string, max: number): string => {
80
80
  : value;
81
81
  };
82
82
 
83
- // Brand mark sizing: target this height, but scale down so a wide wordmark logo
84
- // stays within the lockup.
83
+ // Brand mark sizing: target this height, but scale down so an extremely wide
84
+ // logo stays within the lockup. The cap leaves room for a wordmark to render
85
+ // at full height — it stands alone as the brand (no text label beside it).
85
86
  const MARK_HEIGHT = 32;
86
- const MARK_MAX_WIDTH = 100;
87
+ const MARK_MAX_WIDTH = 240;
87
88
  // Accept either quote style and a non-zero min-x/min-y; only width/height
88
89
  // matter for the aspect ratio. A miss falls back to a square mark.
89
90
  const VIEW_BOX =
90
91
  /viewBox=(?<q>["'])[\d.-]+[\s,]+[\d.-]+[\s,]+(?<w>[\d.]+)[\s,]+(?<h>[\d.]+)\k<q>/u;
91
92
 
93
+ /** The SVG's viewBox aspect ratio (w/h), or null without a usable viewBox. */
94
+ const logoAspect = (svg: string): number | null => {
95
+ const box = svg.match(VIEW_BOX);
96
+ const w = Number(box?.groups?.w);
97
+ const h = Number(box?.groups?.h);
98
+ return w && h ? w / h : null;
99
+ };
100
+
92
101
  // Render the configured logo as the brand mark. A `currentColor` logo carries
93
102
  // no intrinsic color, so it is painted in the foreground to read on the light
94
103
  // card, then handed to Takumi as a data URI sized from the SVG's aspect ratio.
95
104
  const logoMark = (svg: string): Node => {
96
105
  const painted = svg.replaceAll("currentColor", FOREGROUND);
97
- const box = painted.match(VIEW_BOX);
98
- const w = Number(box?.groups?.w);
99
- const h = Number(box?.groups?.h);
106
+ const aspect = logoAspect(painted);
100
107
  let height = MARK_HEIGHT;
101
- let width = w && h ? (MARK_HEIGHT * w) / h : MARK_HEIGHT;
108
+ let width = aspect ? MARK_HEIGHT * aspect : MARK_HEIGHT;
102
109
  if (width > MARK_MAX_WIDTH) {
103
- height = w && h ? (MARK_MAX_WIDTH * h) / w : MARK_HEIGHT;
110
+ height = aspect ? MARK_MAX_WIDTH / aspect : MARK_HEIGHT;
104
111
  width = MARK_MAX_WIDTH;
105
112
  }
106
113
  return image({
@@ -152,19 +159,13 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
152
159
  const repo = options.repo?.trim();
153
160
  const site = options.site?.trim();
154
161
 
162
+ // Logo only — no brand-name label beside it. A wordmark logo already spells
163
+ // the name, and rendering the site title next to it duplicated the brand
164
+ // ("Ultracite Ultracite"). Without a logo, the accent tile with the brand
165
+ // initial stands in.
155
166
  const header = container({
156
- children: [
157
- logo ? logoMark(logo) : initialMark(accent, initial),
158
- brand
159
- ? text(brand, {
160
- color: FOREGROUND,
161
- fontSize: 30,
162
- fontWeight: 500,
163
- letterSpacing: "-0.01em",
164
- })
165
- : container({}),
166
- ],
167
- style: { alignItems: "center", display: "flex", gap: 18 },
167
+ children: [logo ? logoMark(logo) : initialMark(accent, initial)],
168
+ style: { alignItems: "center", display: "flex" },
168
169
  });
169
170
 
170
171
  const body = container({