blume 1.0.4 → 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 (106) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli/index.js +13255 -10232
  3. package/dist/cli/index.js.map +91 -60
  4. package/dist/types/core/config-input.d.ts +61 -1
  5. package/dist/types/core/data.d.ts +9 -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 +8 -8
  9. package/dist/types/core/schema.d.ts +131 -22
  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 +13 -0
  13. package/dist/types/og/card.d.ts +63 -0
  14. package/dist/types/og/dimensions.d.ts +12 -0
  15. package/docs/01-quickstart.mdx +1 -1
  16. package/docs/02-deployment.mdx +9 -1
  17. package/docs/advanced/api-reference.mdx +11 -0
  18. package/docs/advanced/changelog.mdx +1 -1
  19. package/docs/advanced/skills.mdx +1 -1
  20. package/docs/configuration/ai.mdx +1 -1
  21. package/docs/configuration/customization.mdx +1 -1
  22. package/docs/configuration/export.mdx +1 -1
  23. package/docs/configuration/index.mdx +21 -1
  24. package/docs/configuration/search.mdx +28 -1
  25. package/docs/configuration/seo.mdx +21 -2
  26. package/docs/configuration/theming.mdx +1 -1
  27. package/docs/content/components.mdx +14 -0
  28. package/docs/content/index.mdx +1 -1
  29. package/docs/content/meta.mdx +1 -1
  30. package/docs/content/navigation.mdx +1 -1
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/reference/cli.mdx +79 -1
  33. package/docs/reference/frontmatter.mdx +29 -1
  34. package/package.json +3 -3
  35. package/skills/blume-migrate/SKILL.md +1 -1
  36. package/skills/blume-migrate/references/mintlify.md +3 -2
  37. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
  38. package/src/ai/llms.ts +15 -0
  39. package/src/astro/adapter-root.ts +70 -0
  40. package/src/astro/generate.ts +50 -19
  41. package/src/astro/index.ts +1 -0
  42. package/src/astro/pages.ts +18 -3
  43. package/src/astro/templates.ts +65 -22
  44. package/src/audit/agent.ts +114 -0
  45. package/src/audit/catalog.ts +826 -0
  46. package/src/audit/checks/assets.ts +177 -0
  47. package/src/audit/checks/content.ts +231 -0
  48. package/src/audit/checks/duplicates.ts +131 -0
  49. package/src/audit/checks/i18n.ts +246 -0
  50. package/src/audit/checks/indexability.ts +213 -0
  51. package/src/audit/checks/links.ts +223 -0
  52. package/src/audit/checks/llms.ts +135 -0
  53. package/src/audit/checks/network.ts +272 -0
  54. package/src/audit/checks/og-image.ts +113 -0
  55. package/src/audit/checks/redirects.ts +87 -0
  56. package/src/audit/checks/robots.ts +114 -0
  57. package/src/audit/checks/sitemap.ts +229 -0
  58. package/src/audit/checks/social.ts +238 -0
  59. package/src/audit/crawl.ts +259 -0
  60. package/src/audit/graph.ts +74 -0
  61. package/src/audit/html.ts +54 -0
  62. package/src/audit/image-size.ts +63 -0
  63. package/src/audit/locate.ts +33 -0
  64. package/src/audit/redirects.ts +74 -0
  65. package/src/audit/report.ts +278 -0
  66. package/src/audit/run.ts +198 -0
  67. package/src/audit/snapshot.ts +189 -0
  68. package/src/audit/types.ts +214 -0
  69. package/src/audit/url.ts +103 -0
  70. package/src/cli/commands/audit.ts +205 -0
  71. package/src/cli/commands/build.ts +51 -12
  72. package/src/cli/index.ts +2 -0
  73. package/src/components/content/Tabs.astro +98 -15
  74. package/src/components/layout/Breadcrumbs.astro +1 -1
  75. package/src/components/layout/Header.astro +1 -0
  76. package/src/components/layout/PageFeedback.astro +1 -1
  77. package/src/components/layout/PageLayout.astro +5 -1
  78. package/src/components/layout/Pagination.astro +1 -1
  79. package/src/components/layout/RootLayout.astro +5 -3
  80. package/src/components/layout/Search.astro +35 -6
  81. package/src/components/layout/TableOfContents.astro +1 -1
  82. package/src/components/openapi/Authorization.astro +80 -0
  83. package/src/components/openapi/Operation.astro +19 -1
  84. package/src/components/openapi/ParametersTable.astro +1 -1
  85. package/src/components/openapi/security.ts +201 -0
  86. package/src/components/openapi/snippets.ts +42 -13
  87. package/src/core/config-input.ts +66 -1
  88. package/src/core/data.ts +9 -1
  89. package/src/core/deployment-env.ts +9 -0
  90. package/src/core/diagnostics.ts +59 -12
  91. package/src/core/links.ts +2 -91
  92. package/src/core/nav-diagnostics.ts +48 -4
  93. package/src/core/probe.ts +136 -0
  94. package/src/core/project-graph.ts +8 -0
  95. package/src/core/schema.ts +86 -3
  96. package/src/core/sources/normalize.ts +198 -25
  97. package/src/core/sources/types.ts +3 -1
  98. package/src/core/standard-schema.ts +54 -0
  99. package/src/core/types.ts +13 -0
  100. package/src/deploy/adapter-output.ts +27 -15
  101. package/src/deploy/headers.ts +66 -0
  102. package/src/deploy/redirects.ts +49 -9
  103. package/src/og/card.ts +98 -33
  104. package/src/og/index.ts +1 -1
  105. package/src/search/popular.ts +33 -0
  106. package/src/theme/entry.ts +6 -1
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The Standard Schema interface (https://standardschema.dev), the minimal
3
+ * `~standard` surface shared by Zod 3.24+, Zod 4, Valibot, ArkType, and
4
+ * friends. Blume accepts user-supplied validation schemas (e.g.
5
+ * `frontmatter.extend`) through this interface instead of Zod's own types:
6
+ * `blume.config.ts` imports zod from the *consumer's* node_modules, which may
7
+ * be a different major version than the zod Blume bundles, and calling Zod
8
+ * methods (`.extend()`, `.safeParse()`) across instances is unsupported. The
9
+ * `~standard.validate` contract is version- and library-agnostic.
10
+ */
11
+
12
+ /** One validation failure, with an optional path into the checked value. */
13
+ export interface StandardSchemaIssue {
14
+ readonly message: string;
15
+ readonly path?:
16
+ | readonly (PropertyKey | { readonly key: PropertyKey })[]
17
+ | undefined;
18
+ }
19
+
20
+ /** A passing validation: the (possibly transformed) output value. */
21
+ export interface StandardSchemaSuccess<Output> {
22
+ readonly value: Output;
23
+ readonly issues?: undefined;
24
+ }
25
+
26
+ /** A failing validation: one or more issues. */
27
+ export interface StandardSchemaFailure {
28
+ readonly issues: readonly StandardSchemaIssue[];
29
+ }
30
+
31
+ export type StandardSchemaResult<Output> =
32
+ | StandardSchemaSuccess<Output>
33
+ | StandardSchemaFailure;
34
+
35
+ /** A validation schema exposing the Standard Schema `~standard` contract. */
36
+ export interface StandardSchema<Input = unknown, Output = Input> {
37
+ readonly "~standard": {
38
+ readonly version: 1;
39
+ readonly vendor: string;
40
+ readonly validate: (
41
+ value: unknown
42
+ ) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
43
+ readonly types?:
44
+ | { readonly input: Input; readonly output: Output }
45
+ | undefined;
46
+ };
47
+ }
48
+
49
+ /** Whether a config-supplied value implements the `~standard` contract. */
50
+ export const isStandardSchema = (value: unknown): value is StandardSchema =>
51
+ typeof value === "object" &&
52
+ value !== null &&
53
+ typeof (value as { "~standard"?: { validate?: unknown } })["~standard"]
54
+ ?.validate === "function";
package/src/core/types.ts CHANGED
@@ -19,6 +19,13 @@ export interface Diagnostic {
19
19
  file?: string;
20
20
  line?: number;
21
21
  column?: number;
22
+ /**
23
+ * The built URL this diagnostic is about, for findings that are a property of
24
+ * the output rather than of a source file (`blume audit`). Set alongside
25
+ * `file`/`line` where the page maps back to authored content, so a finding can
26
+ * name both the URL that's wrong and the frontmatter line that fixes it.
27
+ */
28
+ url?: string;
22
29
  schemaPath?: string;
23
30
  suggestion?: string;
24
31
  docsUrl?: string;
@@ -115,6 +122,12 @@ export interface PageRecord {
115
122
  description?: string;
116
123
  contentType: string;
117
124
  meta: PageMeta;
125
+ /**
126
+ * Custom frontmatter values declared via `frontmatter.extend`, validated by
127
+ * the user-supplied schemas (schema output, so transforms apply). Present
128
+ * only when the project opts in and the page carries at least one value.
129
+ */
130
+ custom?: Record<string, unknown>;
118
131
  headings: Heading[];
119
132
  /** Whether the file is `.md`/`.mdx`. */
120
133
  format: "md" | "mdx";
@@ -8,6 +8,18 @@ import type { ProjectContext } from "../core/types.ts";
8
8
 
9
9
  type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
10
10
 
11
+ /**
12
+ * Top-level directory each server adapter writes its deploy bundle into, for
13
+ * `.gitignore` — the bundle is a build artifact, and the platform's own state
14
+ * lives alongside it (`.vercel/project.json`, `.netlify/state.json`), so the
15
+ * whole directory is ignored. `node` and `cloudflare` emit into `dist/`, which
16
+ * `blume init` already ignores.
17
+ */
18
+ export const ADAPTER_IGNORE_DIRS: Partial<Record<Adapter, string>> = {
19
+ netlify: ".netlify/",
20
+ vercel: ".vercel/",
21
+ };
22
+
11
23
  /**
12
24
  * Server adapters whose deploy bundle lands *outside* Astro's `outDir`, at a
13
25
  * path relative to the Astro project root. Blume points the Astro root at the
@@ -15,18 +27,21 @@ type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
15
27
  * `<root>/.blume/<path>` — where the deploy platform never looks. Each value is
16
28
  * the sub-path to surface up to the real project root.
17
29
  *
18
- * `vercel` writes a Build Output API v3 tree at `.vercel/output`; only that
19
- * subtree is moved, so a `vercel pull`-ed `.vercel/project.json` sitting at the
20
- * project root survives the relocation. `netlify` writes a Frameworks API tree
21
- * at `.netlify/v1` (its `.netlify/build` sibling is only the intermediate SSR
22
- * bundle, already traced into `v1/functions`); only `v1` is moved, so the
23
- * `.netlify/state.json` written by `netlify link` survives too. `node` and
24
- * `cloudflare` emit into `dist/` (already at the project root), so they are
25
- * absent here and need no relocation.
30
+ * `netlify` writes a Frameworks API tree at `.netlify/v1` (its `.netlify/build`
31
+ * sibling is only the intermediate SSR bundle, already traced into
32
+ * `v1/functions`); only `v1` is moved, so the `.netlify/state.json` written by
33
+ * `netlify link` survives too. `node` and `cloudflare` emit into `dist/`
34
+ * (already at the project root), so they are absent here and need no
35
+ * relocation.
36
+ *
37
+ * `vercel` is absent for a different reason: it is shown the real project root
38
+ * up front (see `withAdapterRoot`), because its `@vercel/nft` dependency trace
39
+ * is rooted there too and tracing from `.blume` silently drops the function's
40
+ * chunks and `node_modules`. Given the right root it writes its Build Output
41
+ * tree straight to `<root>/.vercel/output`, so there is nothing left to move.
26
42
  */
27
43
  export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
28
44
  netlify: ".netlify/v1",
29
- vercel: ".vercel/output",
30
45
  };
31
46
 
32
47
  /**
@@ -53,10 +68,10 @@ export const deployStaticDir = (
53
68
  return dist;
54
69
  };
55
70
 
56
- /** Outcome of {@link surfaceAdapterOutput}, for logging and `.gitignore`. */
71
+ /** Outcome of {@link surfaceAdapterOutput}, for logging. */
57
72
  export type SurfaceResult =
58
73
  | { moved: false }
59
- | { from: string; ignore: string; moved: true; to: string };
74
+ | { from: string; moved: true; to: string };
60
75
 
61
76
  /**
62
77
  * Move a server adapter's deploy bundle out of the hidden `.blume` runtime and
@@ -95,8 +110,5 @@ export const surfaceAdapterOutput = async (
95
110
  // bundle at `/var/task`).
96
111
  await cp(from, to, { recursive: true, verbatimSymlinks: true });
97
112
  await rm(from, { force: true, recursive: true });
98
- // The `.gitignore` entry is the surfaced top-level dir (`.vercel`/`.netlify`),
99
- // never the moved sub-path — the platform's own state (`.vercel/project.json`,
100
- // `.netlify/state.json`) lives there too and must also be ignored.
101
- return { from, ignore: `${rel.split("/")[0]}/`, moved: true, to };
113
+ return { from, moved: true, to };
102
114
  };
@@ -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 =>
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,22 +32,13 @@ 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;
25
-
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
- };
37
-
38
- const resolveColor = (color: string | undefined, fallback: string): string =>
39
- color && HEX_COLOR.test(color) ? color : fallback;
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;
40
42
 
41
43
  export interface OgCardPalette {
42
44
  accent?: string;
@@ -49,7 +51,7 @@ export interface OgCardPalette {
49
51
  export interface OgCardOptions {
50
52
  /** Large headline — the page title. */
51
53
  title: string;
52
- /** 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. */
53
55
  accent?: string;
54
56
  /** Brand/site name shown in the top-left lockup. */
55
57
  brand?: string;
@@ -66,11 +68,65 @@ export interface OgCardOptions {
66
68
  repo?: string;
67
69
  /** Footer-right site host, e.g. `docs.acme.com`. */
68
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[];
69
83
  }
70
84
 
71
85
  const WIDTH = OG_IMAGE_WIDTH;
72
86
  const HEIGHT = OG_IMAGE_HEIGHT;
73
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
+
74
130
  // Light neutral scale mirrored from the docs homepage theme tokens:
75
131
  // FOREGROUND = --foreground, MUTED = --muted-foreground, FAINT = that lighter,
76
132
  // BORDER = --border.
@@ -84,11 +140,11 @@ const resolvePalette = (
84
140
  options: OgCardOptions
85
141
  ): Required<OgCardPalette> & { faint: string } => ({
86
142
  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),
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,
92
148
  });
93
149
 
94
150
  /**
@@ -174,7 +230,9 @@ const titleSize = (title: string): number => {
174
230
  };
175
231
 
176
232
  /** Render a 1200x630 Open Graph card to a PNG buffer. */
177
- export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
233
+ export const renderOgImage = async (
234
+ options: OgCardOptions
235
+ ): Promise<Uint8Array> => {
178
236
  const { accent, background, border, faint, foreground, muted } =
179
237
  resolvePalette(options);
180
238
  const brand = options.brand?.trim();
@@ -265,9 +323,16 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
265
323
  },
266
324
  });
267
325
 
268
- 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,
269
333
  format: "png",
270
334
  height: HEIGHT,
335
+ images: resolveImages(options.images),
271
336
  width: WIDTH,
272
337
  });
273
338
  };
package/src/og/index.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { renderOgImage } from "./card.ts";
2
- export type { OgCardOptions, OgCardPalette } from "./card.ts";
2
+ export type { OgCardOptions, OgCardPalette, OgFont } from "./card.ts";
@@ -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
+ }));
@@ -55,6 +55,10 @@ const TOKEN_DEFAULTS = `:root {
55
55
  --blume-code-word-border: oklch(0.55 0.16 255 / 0.5);
56
56
  --blume-radius: 0.75rem;
57
57
 
58
+ /* Max width of the prose content column (article, breadcrumb, TOC, feedback,
59
+ pagination, last-updated). Override to re-measure the whole column at once. */
60
+ --blume-content-width: 42rem;
61
+
58
62
  /*
59
63
  * Font tokens resolve through optional src variables that Astro's Fonts API
60
64
  * populates (via theme.fonts). When unset they fall back to the system
@@ -128,6 +132,7 @@ const THEME_MAPPING = `@theme inline {
128
132
  --color-action-foreground: var(--blume-action-foreground);
129
133
  --color-code: var(--blume-code-background);
130
134
  --radius-blume: var(--blume-radius);
135
+ --container-content: var(--blume-content-width);
131
136
  --font-sans: var(--blume-font-body);
132
137
  --font-mono: var(--blume-font-mono);
133
138
  --font-display: var(--blume-font-display);
@@ -222,7 +227,7 @@ ${THEME_MAPPING}
222
227
  --tw-prose-links: var(--blume-foreground);
223
228
  --tw-prose-bold: var(--blume-foreground);
224
229
  --tw-prose-counters: var(--blume-muted-foreground);
225
- --tw-prose-bullets: var(--blume-border);
230
+ --tw-prose-bullets: var(--blume-muted-foreground);
226
231
  --tw-prose-hr: var(--blume-border);
227
232
  --tw-prose-quotes: var(--blume-muted-foreground);
228
233
  --tw-prose-quote-borders: var(--blume-border);