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.
- package/CHANGELOG.md +65 -0
- package/dist/cli/index.js +13255 -10232
- package/dist/cli/index.js.map +91 -60
- package/dist/types/core/config-input.d.ts +61 -1
- package/dist/types/core/data.d.ts +9 -0
- package/dist/types/core/deployment-env.d.ts +6 -0
- package/dist/types/core/diagnostics.d.ts +23 -0
- package/dist/types/core/i18n-ui.d.ts +8 -8
- package/dist/types/core/schema.d.ts +131 -22
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +41 -0
- package/dist/types/core/types.d.ts +13 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +11 -0
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +1 -1
- package/docs/configuration/customization.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +21 -1
- package/docs/configuration/search.mdx +28 -1
- package/docs/configuration/seo.mdx +21 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +14 -0
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +1 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/reference/cli.mdx +79 -1
- package/docs/reference/frontmatter.mdx +29 -1
- package/package.json +3 -3
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
- package/src/ai/llms.ts +15 -0
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/generate.ts +50 -19
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +18 -3
- package/src/astro/templates.ts +65 -22
- package/src/audit/agent.ts +114 -0
- package/src/audit/catalog.ts +826 -0
- package/src/audit/checks/assets.ts +177 -0
- package/src/audit/checks/content.ts +231 -0
- package/src/audit/checks/duplicates.ts +131 -0
- package/src/audit/checks/i18n.ts +246 -0
- package/src/audit/checks/indexability.ts +213 -0
- package/src/audit/checks/links.ts +223 -0
- package/src/audit/checks/llms.ts +135 -0
- package/src/audit/checks/network.ts +272 -0
- package/src/audit/checks/og-image.ts +113 -0
- package/src/audit/checks/redirects.ts +87 -0
- package/src/audit/checks/robots.ts +114 -0
- package/src/audit/checks/sitemap.ts +229 -0
- package/src/audit/checks/social.ts +238 -0
- package/src/audit/crawl.ts +259 -0
- package/src/audit/graph.ts +74 -0
- package/src/audit/html.ts +54 -0
- package/src/audit/image-size.ts +63 -0
- package/src/audit/locate.ts +33 -0
- package/src/audit/redirects.ts +74 -0
- package/src/audit/report.ts +278 -0
- package/src/audit/run.ts +198 -0
- package/src/audit/snapshot.ts +189 -0
- package/src/audit/types.ts +214 -0
- package/src/audit/url.ts +103 -0
- package/src/cli/commands/audit.ts +205 -0
- package/src/cli/commands/build.ts +51 -12
- package/src/cli/index.ts +2 -0
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +1 -0
- package/src/components/layout/PageFeedback.astro +1 -1
- package/src/components/layout/PageLayout.astro +5 -1
- package/src/components/layout/Pagination.astro +1 -1
- package/src/components/layout/RootLayout.astro +5 -3
- package/src/components/layout/Search.astro +35 -6
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/openapi/Authorization.astro +80 -0
- package/src/components/openapi/Operation.astro +19 -1
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/security.ts +201 -0
- package/src/components/openapi/snippets.ts +42 -13
- package/src/core/config-input.ts +66 -1
- package/src/core/data.ts +9 -1
- package/src/core/deployment-env.ts +9 -0
- package/src/core/diagnostics.ts +59 -12
- package/src/core/links.ts +2 -91
- package/src/core/nav-diagnostics.ts +48 -4
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +8 -0
- package/src/core/schema.ts +86 -3
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +13 -0
- package/src/deploy/adapter-output.ts +27 -15
- package/src/deploy/headers.ts +66 -0
- package/src/deploy/redirects.ts +49 -9
- package/src/og/card.ts +98 -33
- package/src/og/index.ts +1 -1
- package/src/search/popular.ts +33 -0
- 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
|
-
* `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* absent
|
|
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
|
|
71
|
+
/** Outcome of {@link surfaceAdapterOutput}, for logging. */
|
|
57
72
|
export type SurfaceResult =
|
|
58
73
|
| { moved: false }
|
|
59
|
-
| { from: 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
|
-
|
|
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
|
+
};
|
package/src/deploy/redirects.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
34
|
+
export const applyBaseToAstroRedirects = (
|
|
20
35
|
redirects: Redirect[],
|
|
21
|
-
basePath: string
|
|
22
|
-
|
|
23
|
-
|
|
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:
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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:
|
|
88
|
-
border:
|
|
89
|
-
faint:
|
|
90
|
-
foreground:
|
|
91
|
-
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 = (
|
|
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
|
-
|
|
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
|
+
}));
|
package/src/theme/entry.ts
CHANGED
|
@@ -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-
|
|
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);
|