blume 0.1.5 → 0.3.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 (85) hide show
  1. package/dist/cli/index.js +2123 -555
  2. package/dist/cli/index.js.map +39 -25
  3. package/dist/types/core/data.d.ts +16 -0
  4. package/dist/types/core/define-components.d.ts +9 -2
  5. package/dist/types/core/diagnostics.d.ts +5 -0
  6. package/dist/types/core/schema.d.ts +136 -508
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/docs/02-deployment.mdx +21 -2
  9. package/docs/advanced/changelog.mdx +28 -1
  10. package/docs/advanced/custom-pages.mdx +63 -2
  11. package/docs/configuration/ai.mdx +20 -3
  12. package/docs/configuration/customization.mdx +103 -5
  13. package/docs/configuration/index.mdx +25 -11
  14. package/docs/configuration/search.mdx +13 -1
  15. package/docs/configuration/seo.mdx +5 -0
  16. package/docs/configuration/theming.mdx +51 -0
  17. package/docs/content/components.mdx +18 -0
  18. package/docs/content/islands.mdx +73 -0
  19. package/docs/content/navigation.mdx +25 -0
  20. package/docs/content/sources.mdx +43 -0
  21. package/docs/content/syntax.mdx +1 -1
  22. package/docs/index.mdx +3 -12
  23. package/docs/reference/cli.mdx +49 -1
  24. package/docs/reference/frontmatter.mdx +9 -1
  25. package/package.json +3 -1
  26. package/src/ai/ask-context.ts +131 -0
  27. package/src/ai/ask-data.ts +25 -0
  28. package/src/astro/component-slots.ts +165 -0
  29. package/src/astro/generate.ts +162 -26
  30. package/src/astro/integration.ts +59 -0
  31. package/src/astro/pages.ts +5 -12
  32. package/src/astro/templates.ts +102 -45
  33. package/src/blume-modules.d.ts +25 -0
  34. package/src/cli/commands/build.ts +186 -1
  35. package/src/cli/commands/check.ts +62 -0
  36. package/src/cli/commands/dev.ts +21 -1
  37. package/src/cli/commands/doctor.ts +23 -6
  38. package/src/cli/commands/init.ts +163 -15
  39. package/src/cli/commands/validate.ts +16 -2
  40. package/src/cli/env.ts +84 -0
  41. package/src/cli/index.ts +20 -0
  42. package/src/cli/internal-error.ts +63 -0
  43. package/src/cli/log.ts +30 -1
  44. package/src/cli/prepare.ts +22 -3
  45. package/src/cli/required-secrets.ts +44 -0
  46. package/src/components/BlumePage.astro +107 -0
  47. package/src/components/content/CodeBlock.astro +7 -2
  48. package/src/components/index.ts +3 -3
  49. package/src/components/islands/ask-ai.tsx +15 -1
  50. package/src/components/islands/hooks.ts +188 -0
  51. package/src/components/layout/Empty.astro +6 -0
  52. package/src/components/layout/Header.astro +24 -39
  53. package/src/components/layout/Logo.astro +50 -0
  54. package/src/components/layout/NavSelector.astro +75 -0
  55. package/src/components/layout/PageLayout.astro +38 -2
  56. package/src/components/layout/RootLayout.astro +70 -4
  57. package/src/components/layout/hydration-hint.ts +30 -0
  58. package/src/components/layout/overrides.ts +6 -4
  59. package/src/components/props.ts +68 -0
  60. package/src/core/builtin-tags.ts +39 -0
  61. package/src/core/component-diagnostics.ts +44 -0
  62. package/src/core/component-overrides.ts +478 -0
  63. package/src/core/config.ts +8 -0
  64. package/src/core/data.ts +14 -0
  65. package/src/core/define-components.ts +9 -2
  66. package/src/core/diagnostics.ts +90 -1
  67. package/src/core/graph.ts +7 -0
  68. package/src/core/nav-diagnostics.ts +205 -0
  69. package/src/core/project-graph.ts +40 -1
  70. package/src/core/schema.ts +54 -96
  71. package/src/core/sources/github-releases.ts +200 -0
  72. package/src/core/sources/normalize.ts +51 -0
  73. package/src/core/sources/resolve.ts +16 -0
  74. package/src/core/types.ts +2 -2
  75. package/src/deploy/redirects.ts +43 -0
  76. package/src/markdown/index.ts +24 -0
  77. package/src/migrate/mintlify/config.ts +1 -176
  78. package/src/migrate/starlight/config.ts +0 -4
  79. package/src/og/card.ts +163 -38
  80. package/src/registry/eject.ts +39 -9
  81. package/src/registry/registry.ts +166 -0
  82. package/src/runtime/index.ts +61 -0
  83. package/src/vite-env.d.ts +14 -0
  84. package/docs/changelog/v0-1-0.mdx +0 -12
  85. package/docs/changelog/v0-2-0.mdx +0 -16
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+
1
3
  import GithubSlugger from "github-slugger";
2
4
  import { extname } from "pathe";
3
5
 
@@ -146,6 +148,44 @@ export const extractLinks = (body: string): PageLink[] => {
146
148
  return links;
147
149
  };
148
150
 
151
+ const INLINE_CODE = /`[^`]*`/gu;
152
+ // Double-quoted strings hold JSX attribute values and JSON in `{...}` props; a
153
+ // `<Tag>` written inside prose there (e.g. an "Astro <Font> integration" note)
154
+ // isn't a real usage. Single quotes are left alone so prose apostrophes don't
155
+ // swallow a real tag between two words.
156
+ const DOUBLE_QUOTED = /"[^"]*"/gu;
157
+ const JSX_OPEN = /<(?<tag>[A-Z][A-Za-z0-9]*)/gu;
158
+
159
+ /**
160
+ * Capitalized JSX component tags used in an `.mdx` body (`<Callout>`,
161
+ * `<Tree.File>` → `Tree`). Skips fenced code, inline code, and double-quoted
162
+ * strings so code samples and prose don't count. Powers the missing-component
163
+ * diagnostic.
164
+ */
165
+ export const extractComponentTags = (body: string): string[] => {
166
+ const tags = new Set<string>();
167
+ let inFence = false;
168
+ for (const line of body.split("\n")) {
169
+ if (CODE_FENCE.test(line.trimStart())) {
170
+ inFence = !inFence;
171
+ continue;
172
+ }
173
+ if (inFence) {
174
+ continue;
175
+ }
176
+ const clean = line
177
+ .replaceAll(INLINE_CODE, "")
178
+ .replaceAll(DOUBLE_QUOTED, "");
179
+ for (const match of clean.matchAll(JSX_OPEN)) {
180
+ const tag = match.groups?.tag;
181
+ if (tag) {
182
+ tags.add(tag);
183
+ }
184
+ }
185
+ }
186
+ return [...tags];
187
+ };
188
+
149
189
  const deriveTitle = (
150
190
  meta: PageMeta,
151
191
  headings: Heading[],
@@ -179,10 +219,19 @@ export const normalizeEntry = (
179
219
 
180
220
  const result = pageMetaSchema.safeParse(entry.data);
181
221
  if (!result.success) {
222
+ // Source text lets the error carry a line/column into the frontmatter block:
223
+ // `entry.raw` for non-filesystem sources, else the file itself (read only on
224
+ // this rare error path, so filesystem entries stay cheap in the happy path).
225
+ const source =
226
+ entry.raw ??
227
+ (entry.sourcePath && existsSync(entry.sourcePath)
228
+ ? readFileSync(entry.sourcePath, "utf-8")
229
+ : undefined);
182
230
  return {
183
231
  diagnostics: diagnosticsFromZod(result.error, {
184
232
  code: "BLUME_FRONTMATTER_INVALID",
185
233
  file: entry.sourcePath ?? `${ctx.source.name}:${entry.ref}`,
234
+ source,
186
235
  }),
187
236
  pages: [],
188
237
  };
@@ -212,6 +261,8 @@ export const normalizeEntry = (
212
261
  const base = {
213
262
  body: staged ? { format, text: entry.raw ?? entry.body.text } : undefined,
214
263
  collection: staged ? "staged" : undefined,
264
+ componentsUsed:
265
+ format === "mdx" ? extractComponentTags(entry.body.text) : undefined,
215
266
  contentType: meta.type ?? ctx.defaultType,
216
267
  description: meta.description,
217
268
  editUrl: entry.editUrl,
@@ -3,6 +3,7 @@ import { join } from "pathe";
3
3
  import type { ContentSourceConfig, ResolvedConfig } from "../schema.ts";
4
4
  import type { ProjectContext } from "../types.ts";
5
5
  import { filesystemSource } from "./filesystem.ts";
6
+ import { githubReleasesSource } from "./github-releases.ts";
6
7
  import { mdxRemoteSource } from "./mdx-remote.ts";
7
8
  import { mintlifySource } from "./mintlify.ts";
8
9
  import { notionSource } from "./notion.ts";
@@ -106,6 +107,21 @@ const buildSource = (
106
107
  sourceContext(context, name, runtime)
107
108
  );
108
109
  }
110
+ if (def.type === "github-releases") {
111
+ return githubReleasesSource(
112
+ {
113
+ drafts: def.drafts,
114
+ limit: def.limit,
115
+ name,
116
+ owner: def.owner,
117
+ pollInterval: def.pollInterval,
118
+ prefix: def.prefix,
119
+ prereleases: def.prereleases,
120
+ repo: def.repo,
121
+ },
122
+ sourceContext(context, name, runtime)
123
+ );
124
+ }
109
125
  return mdxRemoteSource(
110
126
  {
111
127
  files: def.files,
package/src/core/types.ts CHANGED
@@ -113,6 +113,8 @@ export interface PageRecord {
113
113
  format: "md" | "mdx";
114
114
  /** Internal/asset links discovered in the page (for validation). */
115
115
  links: PageLink[];
116
+ /** Capitalized JSX component tags used in the body (`.mdx` only). */
117
+ componentsUsed?: string[];
116
118
  /** Resolved "last updated" ISO date, when the feature is enabled. */
117
119
  lastModified?: string;
118
120
  }
@@ -181,8 +183,6 @@ export interface NavSidebarVariant {
181
183
  export interface NavChromeVariant {
182
184
  path: string;
183
185
  banner?: ResolvedConfig["banner"];
184
- footer?: ResolvedConfig["footer"];
185
- navbar?: ResolvedConfig["navbar"];
186
186
  }
187
187
 
188
188
  /** The complete navigation model derived from the content graph. */
@@ -0,0 +1,43 @@
1
+ import type { ResolvedConfig } from "../core/schema.ts";
2
+
3
+ /**
4
+ * Platform redirect files for a static build. Astro already emits redirect HTML
5
+ * (meta-refresh) pages for `deployment.output: "static"`, but that's a soft
6
+ * client redirect. These give the host a real HTTP 3xx: Netlify/Cloudflare read
7
+ * `_redirects`, Vercel reads `vercel.json`, and `blume-redirects.json` is a
8
+ * structured manifest for anything else (Apache/nginx rules, an edge worker).
9
+ */
10
+
11
+ type Redirect = ResolvedConfig["redirects"][number];
12
+
13
+ /** `_redirects` text (Netlify + Cloudflare Pages): `from to status` per line. */
14
+ export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
15
+ `${redirects
16
+ .map((redirect) => `${redirect.from} ${redirect.to} ${redirect.status}`)
17
+ .join("\n")}\n`;
18
+
19
+ /** `vercel.json` contents with a `redirects` array (permanent = 301/308). */
20
+ export const buildVercelConfig = (redirects: Redirect[]): string =>
21
+ `${JSON.stringify(
22
+ {
23
+ redirects: redirects.map((redirect) => ({
24
+ destination: redirect.to,
25
+ permanent: redirect.status === 301 || redirect.status === 308,
26
+ source: redirect.from,
27
+ })),
28
+ },
29
+ null,
30
+ 2
31
+ )}\n`;
32
+
33
+ /** Structured manifest for hosts that need manual wiring. */
34
+ export const buildRedirectManifest = (redirects: Redirect[]): string =>
35
+ `${JSON.stringify(
36
+ redirects.map((redirect) => ({
37
+ from: redirect.from,
38
+ status: redirect.status,
39
+ to: redirect.to,
40
+ })),
41
+ null,
42
+ 2
43
+ )}\n`;
@@ -128,9 +128,26 @@ const astroCodeClassTransformer = (extra?: string): ShikiTransformer =>
128
128
  },
129
129
  }) as unknown as ShikiTransformer;
130
130
 
131
+ /**
132
+ * Tag the `<pre>` with `data-language` — raw `codeToHtml` omits it (unlike
133
+ * Astro's Markdown Shiki), and the theme's code header keys off it. Applied only
134
+ * on the titled path so a titled standalone block renders the same header bar a
135
+ * fence would, while header-less panes (e.g. the Component source view) stay
136
+ * untouched.
137
+ */
138
+ const languageAttrTransformer = (lang: string): ShikiTransformer =>
139
+ ({
140
+ name: "blume:data-language",
141
+ pre(node: { properties: Record<string, unknown> }) {
142
+ node.properties.dataLanguage ??= lang;
143
+ },
144
+ }) as unknown as ShikiTransformer;
145
+
131
146
  export interface HighlightCodeOptions extends BlumeShikiOptions {
132
147
  /** Extra `<pre>` class names, e.g. `blume-source` for a height-capped pane. */
133
148
  className?: string;
149
+ /** Header title (a filename), matching a fence's `title="..."` meta. */
150
+ title?: string;
134
151
  }
135
152
 
136
153
  /**
@@ -152,10 +169,17 @@ export const highlightCode = async (
152
169
  return await codeToHtml(code, {
153
170
  defaultColor: false,
154
171
  lang,
172
+ // The code-title transformer reads the fence meta; feeding a `title="..."`
173
+ // string here gives non-fence callers (e.g. `<CodeBlock title>`) the same
174
+ // `data-title` header a Markdown fence gets.
175
+ meta: options.title
176
+ ? { __raw: `title="${options.title.replaceAll('"', "")}"` }
177
+ : undefined,
155
178
  themes: CODE_THEMES,
156
179
  transformers: [
157
180
  ...blumeShikiTransformers({ icons: options.icons }),
158
181
  astroCodeClassTransformer(options.className),
182
+ ...(options.title ? [languageAttrTransformer(lang)] : []),
159
183
  ],
160
184
  });
161
185
  } catch {
@@ -579,72 +579,6 @@ const mintlifySelectors = (spec: JsonObject): NavigationSelectors => {
579
579
  ].filter((selector) => selector.items.length > 0);
580
580
  };
581
581
 
582
- const navbarTypeLabel = (type: string | undefined): string | undefined => {
583
- if (type === "github") {
584
- return "GitHub";
585
- }
586
- if (type === "discord") {
587
- return "Discord";
588
- }
589
- return undefined;
590
- };
591
-
592
- const navbarLinkType = (
593
- type: string | undefined
594
- ): "github" | "discord" | undefined =>
595
- type === "github" || type === "discord" ? type : undefined;
596
-
597
- const navbarPrimaryType = (
598
- type: string | undefined
599
- ): "button" | "github" | "discord" =>
600
- type === "github" || type === "discord" ? type : "button";
601
-
602
- const mintlifyNavbar = (value: unknown): NonNullable<BlumeConfig["navbar"]> => {
603
- const object = asObject(value);
604
- if (!object) {
605
- return { links: [] };
606
- }
607
-
608
- const links = asArray(object.links).flatMap((item) => {
609
- const itemObject = asObject(item);
610
- const href = itemObject ? asString(itemObject.href) : undefined;
611
- const type = itemObject ? asString(itemObject.type) : undefined;
612
- const label = itemObject
613
- ? (asString(itemObject.label) ?? navbarTypeLabel(type))
614
- : undefined;
615
- if (!itemObject || !href || !label) {
616
- return [];
617
- }
618
- return [
619
- withoutUndefined({
620
- href,
621
- icon: asString(itemObject.icon),
622
- label,
623
- type: navbarLinkType(type),
624
- }),
625
- ];
626
- });
627
-
628
- const primaryObject = asObject(object.primary);
629
- const primaryHref = primaryObject ? asString(primaryObject.href) : undefined;
630
- const primaryType = primaryObject
631
- ? (asString(primaryObject.type) ?? "button")
632
- : undefined;
633
- const primaryLabel = primaryObject
634
- ? (asString(primaryObject.label) ?? navbarTypeLabel(primaryType))
635
- : undefined;
636
- const primary =
637
- primaryObject && primaryHref && primaryLabel
638
- ? withoutUndefined({
639
- href: primaryHref,
640
- label: primaryLabel,
641
- type: navbarPrimaryType(primaryType),
642
- })
643
- : undefined;
644
-
645
- return withoutUndefined({ links, primary });
646
- };
647
-
648
582
  const mintignorePatterns = async (root: string): Promise<string[]> => {
649
583
  try {
650
584
  const raw = await readFile(resolve(root, ".mintignore"), "utf-8");
@@ -678,78 +612,6 @@ const mintlifyRedirects = (
678
612
  return [{ from, to }];
679
613
  });
680
614
 
681
- const mintlifyContextual = (
682
- value: unknown
683
- ): NonNullable<BlumeConfig["contextual"]> => {
684
- const object = asObject(value);
685
- if (!object) {
686
- return { options: [] };
687
- }
688
-
689
- const display = object.display === "toc" ? "toc" : "header";
690
- const options: NonNullable<BlumeConfig["contextual"]>["options"] = [];
691
- for (const option of asArray(object.options)) {
692
- if (typeof option === "string") {
693
- options.push(option);
694
- continue;
695
- }
696
-
697
- const optionObject = asObject(option);
698
- const title = optionObject ? asString(optionObject.title) : undefined;
699
- if (!optionObject || !title) {
700
- continue;
701
- }
702
-
703
- options.push(
704
- withoutUndefined({
705
- description: asString(optionObject.description),
706
- href: asString(optionObject.href),
707
- icon: asString(optionObject.icon),
708
- title,
709
- })
710
- );
711
- }
712
-
713
- return { display, options };
714
- };
715
-
716
- const mintlifyFooter = (value: unknown): NonNullable<BlumeConfig["footer"]> => {
717
- const object = asObject(value);
718
- const socials = asObject(object?.socials);
719
- const links = asArray(object?.links)
720
- .flatMap((group) => {
721
- const groupObject = asObject(group);
722
- const items = asArray(groupObject?.items).flatMap((item) => {
723
- const itemObject = asObject(item);
724
- const label = itemObject ? asString(itemObject.label) : undefined;
725
- const href = itemObject ? asString(itemObject.href) : undefined;
726
- return label && href ? [{ href, label }] : [];
727
- });
728
- if (!groupObject || items.length === 0) {
729
- return [];
730
- }
731
- return [
732
- withoutUndefined({
733
- header: asString(groupObject.header),
734
- items,
735
- }),
736
- ];
737
- })
738
- .slice(0, 4);
739
-
740
- return {
741
- links,
742
- socials: socials
743
- ? Object.fromEntries(
744
- Object.entries(socials).flatMap(([label, href]) => {
745
- const hrefValue = asString(href);
746
- return hrefValue ? [[label, hrefValue]] : [];
747
- })
748
- )
749
- : {},
750
- };
751
- };
752
-
753
615
  const mintlifyLogo = (value: unknown): BlumeConfig["logo"] => {
754
616
  if (typeof value === "string") {
755
617
  return value;
@@ -826,21 +688,13 @@ const mintlifyChromeVariants = (spec: JsonObject): NavigationChromeVariants => {
826
688
  const banner = hasOwn(object, "banner")
827
689
  ? mintlifyBanner(object.banner)
828
690
  : undefined;
829
- const footer = hasOwn(object, "footer")
830
- ? mintlifyFooter(object.footer)
831
- : undefined;
832
- const navbar = hasOwn(object, "navbar")
833
- ? mintlifyNavbar(object.navbar)
834
- : undefined;
835
- if (!banner && !footer && !navbar) {
691
+ if (!banner) {
836
692
  return [];
837
693
  }
838
694
 
839
695
  return [
840
696
  withoutUndefined({
841
697
  banner,
842
- footer,
843
- navbar,
844
698
  path,
845
699
  }),
846
700
  ];
@@ -914,19 +768,6 @@ const mintlifyMarkdown = (
914
768
  });
915
769
  };
916
770
 
917
- const mintlifyStyling = (
918
- value: unknown
919
- ): NonNullable<BlumeConfig["styling"]> => {
920
- const object = asObject(value);
921
- const eyebrows = asString(object?.eyebrows);
922
- return withoutUndefined({
923
- eyebrows:
924
- eyebrows === "breadcrumbs" || eyebrows === "section"
925
- ? eyebrows
926
- : undefined,
927
- });
928
- };
929
-
930
771
  const mintlifySeo = (value: unknown): NonNullable<BlumeConfig["seo"]> => {
931
772
  const object = asObject(value);
932
773
  const metatags = asObject(object?.metatags);
@@ -942,17 +783,6 @@ const mintlifySeo = (value: unknown): NonNullable<BlumeConfig["seo"]> => {
942
783
  };
943
784
  };
944
785
 
945
- const mintlifyIcons = (value: unknown): NonNullable<BlumeConfig["icons"]> => {
946
- const object = asObject(value);
947
- const library = object?.library;
948
- return withoutUndefined({
949
- library:
950
- library === "fontawesome" || library === "lucide" || library === "tabler"
951
- ? library
952
- : undefined,
953
- });
954
- };
955
-
956
786
  export const loadMintlifyConfig = async (
957
787
  root: string,
958
788
  file: string
@@ -993,14 +823,10 @@ export const loadMintlifyConfig = async (
993
823
  ],
994
824
  root: ".",
995
825
  },
996
- contextual: mintlifyContextual(spec.contextual),
997
826
  description: asString(spec.description),
998
827
  favicon: mintlifyFavicon(spec.favicon),
999
- footer: mintlifyFooter(spec.footer),
1000
- icons: mintlifyIcons(spec.icons),
1001
828
  logo: mintlifyLogo(spec.logo),
1002
829
  markdown: mintlifyMarkdown(spec.markdown, styling),
1003
- navbar: mintlifyNavbar(spec.navbar),
1004
830
  navigation: {
1005
831
  chromeVariants: mintlifyChromeVariants(spec),
1006
832
  selectors: mintlifySelectors(spec),
@@ -1016,7 +842,6 @@ export const loadMintlifyConfig = async (
1016
842
  prompt: asString(search.prompt),
1017
843
  },
1018
844
  seo: mintlifySeo(seo),
1019
- styling: mintlifyStyling(styling),
1020
845
  theme: {
1021
846
  accent: asString(colors.primary) ?? "blue",
1022
847
  accentDark: asString(colors.light),
@@ -425,10 +425,6 @@ export const mapStarlightConfig = (
425
425
  }
426
426
 
427
427
  const social = mapSocial(options.social);
428
- if (Object.keys(social.socials).length > 0) {
429
- config.footer = { socials: social.socials };
430
- }
431
-
432
428
  const github = mapEditLink(options.editLink) ?? social.github;
433
429
  if (github) {
434
430
  config.github = github;
package/src/og/card.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Renderer } from "@takumi-rs/core";
2
- import { container, text } from "@takumi-rs/helpers";
2
+ import { container, image, text } from "@takumi-rs/helpers";
3
+ import type { Node } from "@takumi-rs/helpers";
3
4
 
4
5
  // Reuse one renderer (and its loaded default fonts) across all images.
5
6
  let renderer: Renderer | null = null;
@@ -24,62 +25,186 @@ const resolveAccent = (accent: string): string =>
24
25
  ACCENT_HEX[accent] ?? (accent.startsWith("#") ? accent : "#3b82f6");
25
26
 
26
27
  export interface OgCardOptions {
28
+ /** Large headline — the page title. */
27
29
  title: string;
28
- eyebrow?: string;
30
+ /** Accent color (named preset or hex) for the fallback brand mark. */
29
31
  accent?: string;
32
+ /** Brand/site name shown in the top-left lockup. */
33
+ brand?: string;
34
+ /** Muted subtitle under the headline (usually the site description). */
35
+ description?: string;
36
+ /**
37
+ * Inlined SVG markup of the configured logo (`config.logo.svg`), painted into
38
+ * the brand lockup. Falls back to an accent mark when absent.
39
+ */
40
+ logo?: string;
41
+ /** Footer-left repository slug, e.g. `owner/repo`. */
42
+ repo?: string;
43
+ /** Footer-right site host, e.g. `docs.acme.com`. */
44
+ site?: string;
30
45
  }
31
46
 
32
47
  const WIDTH = 1200;
33
48
  const HEIGHT = 630;
34
49
 
50
+ // Light neutral scale mirrored from the docs homepage theme tokens:
51
+ // FOREGROUND = --foreground, MUTED = --muted-foreground, FAINT = that lighter,
52
+ // BORDER = --border.
53
+ const BG = "#fafafa";
54
+ const FOREGROUND = "#0a0a0a";
55
+ const MUTED = "#737373";
56
+ const FAINT = "#a3a3a3";
57
+ const BORDER = "#e5e5e5";
58
+
59
+ const truncate = (value: string, max: number): string =>
60
+ value.length > max ? `${value.slice(0, max - 1).trimEnd()}…` : value;
61
+
62
+ // Brand mark sizing: target this height, but scale down so a wide wordmark logo
63
+ // stays within the lockup.
64
+ const MARK_HEIGHT = 32;
65
+ const MARK_MAX_WIDTH = 100;
66
+ const VIEW_BOX = /viewBox="0 0 (?<w>[\d.]+) (?<h>[\d.]+)"/u;
67
+
68
+ // Render the configured logo as the brand mark. A `currentColor` logo carries
69
+ // no intrinsic color, so it is painted in the foreground to read on the light
70
+ // card, then handed to Takumi as a data URI sized from the SVG's aspect ratio.
71
+ const logoMark = (svg: string): Node => {
72
+ const painted = svg.replaceAll("currentColor", FOREGROUND);
73
+ const box = painted.match(VIEW_BOX);
74
+ const w = Number(box?.groups?.w);
75
+ const h = Number(box?.groups?.h);
76
+ let height = MARK_HEIGHT;
77
+ let width = w && h ? (MARK_HEIGHT * w) / h : MARK_HEIGHT;
78
+ if (width > MARK_MAX_WIDTH) {
79
+ height = w && h ? (MARK_MAX_WIDTH * h) / w : MARK_HEIGHT;
80
+ width = MARK_MAX_WIDTH;
81
+ }
82
+ return image({
83
+ height: Math.round(height),
84
+ src: `data:image/svg+xml;base64,${Buffer.from(painted).toString("base64")}`,
85
+ width: Math.round(width),
86
+ });
87
+ };
88
+
89
+ // Fallback mark when no SVG logo is configured: an accent tile with the brand's
90
+ // initial, matching the docs favicon aesthetic.
91
+ const initialMark = (accent: string, initial: string): Node =>
92
+ container({
93
+ children: initial
94
+ ? [text(initial, { color: "#ffffff", fontSize: 32, fontWeight: 600 })]
95
+ : [],
96
+ style: {
97
+ alignItems: "center",
98
+ backgroundColor: accent,
99
+ borderRadius: 14,
100
+ display: "flex",
101
+ height: 60,
102
+ justifyContent: "center",
103
+ width: 60,
104
+ },
105
+ });
106
+
107
+ // The headline shrinks as the title grows so it never spills past a couple of
108
+ // lines within the card's content width.
109
+ const titleSize = (title: string): number => {
110
+ if (title.length > 60) {
111
+ return 52;
112
+ }
113
+ if (title.length > 40) {
114
+ return 64;
115
+ }
116
+ return 76;
117
+ };
118
+
35
119
  /** Render a 1200x630 Open Graph card to a PNG buffer. */
36
120
  export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
37
121
  const accent = resolveAccent(options.accent ?? "blue");
122
+ const brand = options.brand?.trim();
123
+ const logo = options.logo?.trim();
124
+ const initial = brand ? brand.charAt(0).toUpperCase() : "";
125
+ const description = options.description?.trim();
126
+ const repo = options.repo?.trim();
127
+ const site = options.site?.trim();
38
128
 
39
- const node = container({
129
+ const header = container({
40
130
  children: [
41
- container({
42
- children: [
43
- container({
44
- style: {
45
- backgroundColor: accent,
46
- borderRadius: 6,
47
- height: 32,
48
- width: 32,
49
- },
50
- }),
51
- options.eyebrow
52
- ? text(options.eyebrow, {
53
- color: "#94a3b8",
54
- fontSize: 30,
55
- })
56
- : container({}),
57
- ],
58
- style: { alignItems: "center", display: "flex", gap: 16 },
59
- }),
60
- text(options.title, {
61
- color: "#f8fafc",
62
- fontSize: 76,
63
- fontWeight: 700,
64
- lineHeight: 1.1,
65
- }),
66
- container({
67
- style: {
68
- backgroundColor: accent,
69
- borderRadius: 4,
70
- height: 8,
71
- width: 120,
72
- },
131
+ logo ? logoMark(logo) : initialMark(accent, initial),
132
+ brand
133
+ ? text(brand, {
134
+ color: FOREGROUND,
135
+ fontSize: 30,
136
+ fontWeight: 500,
137
+ letterSpacing: "-0.01em",
138
+ })
139
+ : container({}),
140
+ ],
141
+ style: { alignItems: "center", display: "flex", gap: 18 },
142
+ });
143
+
144
+ const body = container({
145
+ children: [
146
+ text(truncate(options.title, 64), {
147
+ color: FOREGROUND,
148
+ fontSize: titleSize(options.title),
149
+ fontWeight: 600,
150
+ letterSpacing: "-0.03em",
151
+ lineHeight: 1.05,
152
+ maxWidth: 1010,
153
+ textWrap: "balance",
73
154
  }),
155
+ description
156
+ ? text(truncate(description, 140), {
157
+ color: MUTED,
158
+ fontSize: 30,
159
+ lineHeight: 1.4,
160
+ marginTop: 28,
161
+ maxWidth: 900,
162
+ textWrap: "balance",
163
+ })
164
+ : container({}),
74
165
  ],
166
+ style: { display: "flex", flexDirection: "column" },
167
+ });
168
+
169
+ const footer =
170
+ repo || site
171
+ ? container({
172
+ children: [
173
+ container({
174
+ style: { backgroundColor: BORDER, height: 1, width: "100%" },
175
+ }),
176
+ container({
177
+ children: [
178
+ repo
179
+ ? text(repo, { color: MUTED, fontSize: 22 })
180
+ : container({}),
181
+ site
182
+ ? text(site, { color: FAINT, fontSize: 22 })
183
+ : container({}),
184
+ ],
185
+ style: {
186
+ alignItems: "center",
187
+ display: "flex",
188
+ justifyContent: "space-between",
189
+ marginTop: 28,
190
+ width: "100%",
191
+ },
192
+ }),
193
+ ],
194
+ style: { display: "flex", flexDirection: "column", width: "100%" },
195
+ })
196
+ : container({});
197
+
198
+ const node = container({
199
+ children: [header, body, footer],
75
200
  style: {
76
- backgroundColor: "#0b1020",
77
- color: "#ffffff",
201
+ backgroundColor: BG,
202
+ color: FOREGROUND,
78
203
  display: "flex",
79
204
  flexDirection: "column",
80
205
  height: HEIGHT,
81
206
  justifyContent: "space-between",
82
- padding: 80,
207
+ padding: 72,
83
208
  width: WIDTH,
84
209
  },
85
210
  });