blume 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/dist/cli/index.js +13784 -10579
- package/dist/cli/index.js.map +93 -61
- package/dist/types/core/config-input.d.ts +87 -8
- package/dist/types/core/data.d.ts +21 -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 +140 -140
- package/dist/types/core/schema.d.ts +549 -370
- 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 +23 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/dist/types/openapi/references.d.ts +12 -7
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +22 -3
- 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 +40 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +15 -2
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +11 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +116 -4
- 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 +170 -0
- package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
- package/skills/blume-migrate/references/docusaurus.md +95 -0
- package/skills/blume-migrate/references/fumadocs.md +95 -0
- package/skills/blume-migrate/references/mintlify.md +156 -0
- package/skills/blume-migrate/references/monorepo.md +224 -0
- package/skills/blume-migrate/references/nextra.md +76 -0
- package/skills/blume-migrate/references/starlight.md +116 -0
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +478 -0
- package/src/ai/llms.ts +15 -0
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/component-slots.ts +3 -2
- package/src/astro/generate.ts +132 -42
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +18 -3
- package/src/astro/templates.ts +158 -56
- 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/Callout.astro +8 -2
- package/src/components/content/Prompt.astro +25 -13
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +5 -8
- package/src/components/layout/Logo.astro +13 -1
- package/src/components/layout/PageFeedback.astro +2 -2
- package/src/components/layout/PageLayout.astro +9 -9
- package/src/components/layout/Pagination.astro +7 -7
- package/src/components/layout/RootLayout.astro +9 -11
- package/src/components/layout/Search.astro +36 -7
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/layout/nav-utils.ts +9 -7
- 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 +94 -8
- package/src/core/data.ts +18 -2
- 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/navigation.ts +55 -13
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +8 -0
- package/src/core/schema.ts +100 -1
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/sources/watch.ts +5 -0
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +23 -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/markdown/index.ts +2 -0
- package/src/markdown/language-icon.ts +2 -1
- package/src/markdown/table-wrap.ts +43 -0
- package/src/og/card.ts +128 -36
- package/src/og/index.ts +1 -1
- package/src/og/logo.ts +21 -0
- package/src/openapi/references.ts +19 -16
- package/src/search/popular.ts +33 -0
- package/src/theme/entry.ts +56 -6
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { normalizeBasePath } from "../core/base-path.ts";
|
|
2
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `_headers` file for a static build (Netlify + Cloudflare Pages/Workers
|
|
6
|
+
* static assets). Blume's raw AI-ready endpoints — `/<route>.md`, `/<route>.mdx`,
|
|
7
|
+
* and the `.txt` files (`llms.txt`, `llms-full.txt`) — are valid UTF-8, but a
|
|
8
|
+
* static host serves them from the file extension alone, and common hosts send
|
|
9
|
+
* `text/markdown` / `text/plain` with **no** `charset`. Browsers then fall back
|
|
10
|
+
* to Windows-1252 for non-HTML text, so any non-ASCII docs (Japanese, accented
|
|
11
|
+
* Latin, …) render as mojibake when the raw URL is opened directly. HTML pages
|
|
12
|
+
* escape this because they carry `<meta charset>`; the raw endpoints have only
|
|
13
|
+
* the HTTP header. Pinning `charset=utf-8` here matches the Content-Type these
|
|
14
|
+
* same routes already send from the dev/server runtime (see
|
|
15
|
+
* `astro/templates.ts`). Hosts that don't read `_headers` (Vercel, S3) ignore
|
|
16
|
+
* the file harmlessly.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One rule per served extension. `.mdx` uses `text/markdown` to match the
|
|
21
|
+
* runtime endpoint, which serves both variants as `text/markdown`. The raw
|
|
22
|
+
* Markdown mirrors live under the page routes (which carry `basePath`), while
|
|
23
|
+
* the `.txt` files (`llms.txt`, `llms-full.txt`) are written to the dist root
|
|
24
|
+
* and served at the deployment base — so only the `.md`/`.mdx` rules take the
|
|
25
|
+
* `basePath` layer.
|
|
26
|
+
*/
|
|
27
|
+
const HEADER_RULES: readonly {
|
|
28
|
+
contentType: string;
|
|
29
|
+
ext: string;
|
|
30
|
+
underBasePath: boolean;
|
|
31
|
+
}[] = [
|
|
32
|
+
{
|
|
33
|
+
contentType: "text/markdown; charset=utf-8",
|
|
34
|
+
ext: "md",
|
|
35
|
+
underBasePath: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
contentType: "text/markdown; charset=utf-8",
|
|
39
|
+
ext: "mdx",
|
|
40
|
+
underBasePath: true,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
contentType: "text/plain; charset=utf-8",
|
|
44
|
+
ext: "txt",
|
|
45
|
+
underBasePath: false,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `_headers` contents: a `/*.<ext>` glob per rule with an indented
|
|
51
|
+
* `Content-Type` line, in the two-space format Netlify and Cloudflare read. The
|
|
52
|
+
* glob carries the served prefix (`{deployment.base}{basePath}` for the
|
|
53
|
+
* Markdown mirrors, `{deployment.base}` for the root `.txt` files) so the rules
|
|
54
|
+
* still match once the site is mounted under a subpath (`/docs/*.md`); the
|
|
55
|
+
* wildcard spans path segments, so a nested route like `/docs/ja/intro.md`
|
|
56
|
+
* matches too.
|
|
57
|
+
*/
|
|
58
|
+
export const buildNetlifyHeaders = (config: ResolvedConfig): string => {
|
|
59
|
+
const deployBase = normalizeBasePath(config.deployment.base);
|
|
60
|
+
return `${HEADER_RULES.map((rule) => {
|
|
61
|
+
const prefix = rule.underBasePath
|
|
62
|
+
? `${deployBase}${config.basePath}`
|
|
63
|
+
: deployBase;
|
|
64
|
+
return `${prefix}/*.${rule.ext}\n Content-Type: ${rule.contentType}`;
|
|
65
|
+
}).join("\n")}\n`;
|
|
66
|
+
};
|
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/markdown/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { languageIconTransformer } from "./language-icon.ts";
|
|
|
17
17
|
import { mathPlugin } from "./math.ts";
|
|
18
18
|
import { mermaidPlugin } from "./mermaid.ts";
|
|
19
19
|
import { packageInstallPlugin } from "./package-install.ts";
|
|
20
|
+
import { tableWrapPlugin } from "./table-wrap.ts";
|
|
20
21
|
import { DEFAULT_CODE_THEMES } from "./themes.ts";
|
|
21
22
|
import type { CodeThemes } from "./themes.ts";
|
|
22
23
|
|
|
@@ -58,6 +59,7 @@ type HastPlugin = NonNullable<
|
|
|
58
59
|
const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
|
|
59
60
|
const plugins: HastPlugin[] = [
|
|
60
61
|
inlineCodeHighlightPlugin(options.codeThemes) as unknown as HastPlugin,
|
|
62
|
+
tableWrapPlugin() as unknown as HastPlugin,
|
|
61
63
|
];
|
|
62
64
|
if (options.headingAnchors !== false) {
|
|
63
65
|
plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* hex colors are skipped because dark-on-dark logos (Next.js, Rust…) vanish.
|
|
8
8
|
*
|
|
9
9
|
* The theme styles `.blume-lang-icon` and shifts the language label
|
|
10
|
-
* (`pre[data-icon]::before`) to make room
|
|
10
|
+
* (`pre[data-language][data-icon]::before`) to make room — gated on
|
|
11
|
+
* `data-language` so the icon only shows when a header bar exists to hold it.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps each `<table>` in a scroll container. Satteri does not re-descend into a
|
|
3
|
+
* visitor's returned replacement, so the wrapped table is not re-visited.
|
|
4
|
+
*
|
|
5
|
+
* The wrapper carries `tabindex="0"` so a horizontally scrolling table is
|
|
6
|
+
* reachable and scrollable by keyboard, not just pointer (WCAG 2.1.1; axe's
|
|
7
|
+
* `scrollable-region-focusable`). It's added unconditionally — whether a given
|
|
8
|
+
* table overflows isn't known at build time — which costs a tab stop on tables
|
|
9
|
+
* that happen to fit; no ARIA label is set to avoid an untranslated string.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A minimal hast node (avoids a hast type dependency). */
|
|
13
|
+
interface HastNode {
|
|
14
|
+
children?: HastNode[];
|
|
15
|
+
properties?: Record<string, unknown>;
|
|
16
|
+
tagName?: string;
|
|
17
|
+
type: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A Satteri hast plugin, typed structurally to avoid a Satteri dep. */
|
|
22
|
+
export interface TableWrapPlugin {
|
|
23
|
+
name: string;
|
|
24
|
+
element: {
|
|
25
|
+
filter: string[];
|
|
26
|
+
visit: (node: HastNode) => HastNode;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const tableWrapPlugin = (): TableWrapPlugin => ({
|
|
31
|
+
element: {
|
|
32
|
+
filter: ["table"],
|
|
33
|
+
visit(node) {
|
|
34
|
+
return {
|
|
35
|
+
children: [node],
|
|
36
|
+
properties: { className: ["blume-table-scroll"], tabIndex: 0 },
|
|
37
|
+
tagName: "div",
|
|
38
|
+
type: "element",
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
name: "blume:table-wrap",
|
|
43
|
+
});
|
package/src/og/card.ts
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
import {
|
|
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,43 +32,101 @@ const ACCENT_HEX: Record<string, string> = {
|
|
|
21
32
|
teal: "#14b8a6",
|
|
22
33
|
};
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
// Named presets map to Blume's palette hex (the preset "blue" is not CSS
|
|
36
|
+
// blue); anything else is handed to Takumi as-is — it parses the full CSS
|
|
37
|
+
// color grammar, and a genuinely malformed value fails the build with a
|
|
38
|
+
// parse error naming it. `hasOwn` keeps a preset name like "constructor"
|
|
39
|
+
// from resolving up the prototype chain.
|
|
40
|
+
const resolveAccent = (accent: string): string =>
|
|
41
|
+
Object.hasOwn(ACCENT_HEX, accent) ? (ACCENT_HEX[accent] as string) : accent;
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return ACCENT_HEX[accent] as string;
|
|
34
|
-
}
|
|
35
|
-
return HEX_COLOR.test(accent) ? accent : "#3b82f6";
|
|
36
|
-
};
|
|
43
|
+
export interface OgCardPalette {
|
|
44
|
+
accent?: string;
|
|
45
|
+
background?: string;
|
|
46
|
+
border?: string;
|
|
47
|
+
foreground?: string;
|
|
48
|
+
muted?: string;
|
|
49
|
+
}
|
|
37
50
|
|
|
38
51
|
export interface OgCardOptions {
|
|
39
52
|
/** Large headline — the page title. */
|
|
40
53
|
title: string;
|
|
41
|
-
/** Accent color (named preset or
|
|
54
|
+
/** Accent color (named preset or any CSS color) for the fallback brand mark. */
|
|
42
55
|
accent?: string;
|
|
43
56
|
/** Brand/site name shown in the top-left lockup. */
|
|
44
57
|
brand?: string;
|
|
45
58
|
/** Muted subtitle under the headline (usually the site description). */
|
|
46
59
|
description?: string;
|
|
47
60
|
/**
|
|
48
|
-
* Inlined SVG markup of the configured logo
|
|
61
|
+
* Inlined SVG markup of the configured logo, painted into
|
|
49
62
|
* the brand lockup. Falls back to an accent mark when absent.
|
|
50
63
|
*/
|
|
51
64
|
logo?: string;
|
|
65
|
+
/** Optional colors for the generated card. */
|
|
66
|
+
palette?: OgCardPalette;
|
|
52
67
|
/** Footer-left repository slug, e.g. `owner/repo`. */
|
|
53
68
|
repo?: string;
|
|
54
69
|
/** Footer-right site host, e.g. `docs.acme.com`. */
|
|
55
70
|
site?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Pre-fetched image entries, or a group controlling how remote images (and
|
|
73
|
+
* emoji glyphs) are fetched. Blume merges in a shared glyph cache; see
|
|
74
|
+
* {@link resolveImages}.
|
|
75
|
+
*/
|
|
76
|
+
images?: RenderOptions["images"];
|
|
77
|
+
/**
|
|
78
|
+
* Google Font families for non-Latin titles. Takumi's built-in font covers
|
|
79
|
+
* only Latin, so a CJK (etc.) title renders as tofu without a family that
|
|
80
|
+
* covers its script — see {@link loadFonts}.
|
|
81
|
+
*/
|
|
82
|
+
fonts?: OgFont[];
|
|
56
83
|
}
|
|
57
84
|
|
|
58
85
|
const WIDTH = OG_IMAGE_WIDTH;
|
|
59
86
|
const HEIGHT = OG_IMAGE_HEIGHT;
|
|
60
87
|
|
|
88
|
+
// Emoji in a title render as Twemoji glyphs Takumi fetches from a CDN, once per
|
|
89
|
+
// render. A build prerenders one card per page, so an emoji in the site title
|
|
90
|
+
// would otherwise refetch the same glyph for every page. This cache is keyed by
|
|
91
|
+
// URL and holds the in-flight promise, so concurrent renders share one request
|
|
92
|
+
// and a build fetches each glyph once. Unbounded on purpose: it is scoped to the
|
|
93
|
+
// glyphs a site's own titles reference, which is a handful.
|
|
94
|
+
const imageFetchCache = new Map<string, Promise<ArrayBuffer>>();
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Merge the shared glyph cache into the caller's `images`. An explicit
|
|
98
|
+
* `fetchCache` wins, so a caller can scope or opt out of the cache.
|
|
99
|
+
*/
|
|
100
|
+
const resolveImages = (
|
|
101
|
+
images: OgCardOptions["images"]
|
|
102
|
+
): OgCardOptions["images"] =>
|
|
103
|
+
Array.isArray(images)
|
|
104
|
+
? { fetchCache: imageFetchCache, sources: images }
|
|
105
|
+
: { fetchCache: imageFetchCache, ...images };
|
|
106
|
+
|
|
107
|
+
// Google Fonts subsets keyed by the family set, so a build's identical per-page
|
|
108
|
+
// renders build the subset list once. `googleFonts` also caches the css2 request
|
|
109
|
+
// process-wide and the shared renderer skips subset files it has already loaded,
|
|
110
|
+
// so this only avoids rebuilding the list per card.
|
|
111
|
+
const fontSubsetCache = new Map<string, Promise<FontSubset[]>>();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Load the configured Google Font families as coverage subsets for `render`'s
|
|
115
|
+
* `fonts` option. `googleFonts` fetches the families from Google Fonts in one
|
|
116
|
+
* css2 request; `render` then registers only the subsets a card's text uses. A
|
|
117
|
+
* fetch failure rejects, failing the build with the cause rather than silently
|
|
118
|
+
* shipping tofu — the same fail-fast the OG accent relies on.
|
|
119
|
+
*/
|
|
120
|
+
const loadFonts = (fonts: OgFont[]): Promise<FontSubset[]> => {
|
|
121
|
+
const key = JSON.stringify(fonts);
|
|
122
|
+
let pending = fontSubsetCache.get(key);
|
|
123
|
+
if (!pending) {
|
|
124
|
+
pending = googleFonts(fonts as GoogleFontFamily[]);
|
|
125
|
+
fontSubsetCache.set(key, pending);
|
|
126
|
+
}
|
|
127
|
+
return pending;
|
|
128
|
+
};
|
|
129
|
+
|
|
61
130
|
// Light neutral scale mirrored from the docs homepage theme tokens:
|
|
62
131
|
// FOREGROUND = --foreground, MUTED = --muted-foreground, FAINT = that lighter,
|
|
63
132
|
// BORDER = --border.
|
|
@@ -67,6 +136,17 @@ const MUTED = "#737373";
|
|
|
67
136
|
const FAINT = "#a3a3a3";
|
|
68
137
|
const BORDER = "#e5e5e5";
|
|
69
138
|
|
|
139
|
+
const resolvePalette = (
|
|
140
|
+
options: OgCardOptions
|
|
141
|
+
): Required<OgCardPalette> & { faint: string } => ({
|
|
142
|
+
accent: resolveAccent(options.palette?.accent ?? options.accent ?? "blue"),
|
|
143
|
+
background: options.palette?.background ?? BG,
|
|
144
|
+
border: options.palette?.border ?? BORDER,
|
|
145
|
+
faint: options.palette?.muted ?? FAINT,
|
|
146
|
+
foreground: options.palette?.foreground ?? FOREGROUND,
|
|
147
|
+
muted: options.palette?.muted ?? MUTED,
|
|
148
|
+
});
|
|
149
|
+
|
|
70
150
|
/**
|
|
71
151
|
* Truncate to `max` code points with an ellipsis. Slices by code points, not
|
|
72
152
|
* UTF-16 units, so cutting mid-emoji doesn't leave a lone surrogate (a broken
|
|
@@ -103,8 +183,8 @@ const logoAspect = (svg: string): number | null => {
|
|
|
103
183
|
// Render the configured logo as the brand mark. A `currentColor` logo carries
|
|
104
184
|
// no intrinsic color, so it is painted in the foreground to read on the light
|
|
105
185
|
// card, then handed to Takumi as a data URI sized from the SVG's aspect ratio.
|
|
106
|
-
const logoMark = (svg: string): Node => {
|
|
107
|
-
const painted = svg.replaceAll("currentColor",
|
|
186
|
+
const logoMark = (svg: string, foreground: string): Node => {
|
|
187
|
+
const painted = svg.replaceAll("currentColor", foreground);
|
|
108
188
|
const aspect = logoAspect(painted);
|
|
109
189
|
let height = MARK_HEIGHT;
|
|
110
190
|
let width = aspect ? MARK_HEIGHT * aspect : MARK_HEIGHT;
|
|
@@ -150,8 +230,11 @@ const titleSize = (title: string): number => {
|
|
|
150
230
|
};
|
|
151
231
|
|
|
152
232
|
/** Render a 1200x630 Open Graph card to a PNG buffer. */
|
|
153
|
-
export const renderOgImage = (
|
|
154
|
-
|
|
233
|
+
export const renderOgImage = async (
|
|
234
|
+
options: OgCardOptions
|
|
235
|
+
): Promise<Uint8Array> => {
|
|
236
|
+
const { accent, background, border, faint, foreground, muted } =
|
|
237
|
+
resolvePalette(options);
|
|
155
238
|
const brand = options.brand?.trim();
|
|
156
239
|
const logo = options.logo?.trim();
|
|
157
240
|
// Slice by code point, not code unit — `charAt(0)` would split a leading
|
|
@@ -166,14 +249,16 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
|
166
249
|
// ("Ultracite Ultracite"). Without a logo, the accent tile with the brand
|
|
167
250
|
// initial stands in.
|
|
168
251
|
const header = container({
|
|
169
|
-
children: [
|
|
252
|
+
children: [
|
|
253
|
+
logo ? logoMark(logo, foreground) : initialMark(accent, initial),
|
|
254
|
+
],
|
|
170
255
|
style: { alignItems: "center", display: "flex" },
|
|
171
256
|
});
|
|
172
257
|
|
|
173
258
|
const body = container({
|
|
174
259
|
children: [
|
|
175
260
|
text(truncate(options.title, 64), {
|
|
176
|
-
color:
|
|
261
|
+
color: foreground,
|
|
177
262
|
fontSize: titleSize(options.title),
|
|
178
263
|
fontWeight: 600,
|
|
179
264
|
letterSpacing: "-0.03em",
|
|
@@ -183,7 +268,7 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
|
183
268
|
}),
|
|
184
269
|
description
|
|
185
270
|
? text(truncate(description, 140), {
|
|
186
|
-
color:
|
|
271
|
+
color: muted,
|
|
187
272
|
fontSize: 30,
|
|
188
273
|
lineHeight: 1.4,
|
|
189
274
|
marginTop: 28,
|
|
@@ -200,15 +285,15 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
|
200
285
|
? container({
|
|
201
286
|
children: [
|
|
202
287
|
container({
|
|
203
|
-
style: { backgroundColor:
|
|
288
|
+
style: { backgroundColor: border, height: 1, width: "100%" },
|
|
204
289
|
}),
|
|
205
290
|
container({
|
|
206
291
|
children: [
|
|
207
292
|
repo
|
|
208
|
-
? text(repo, { color:
|
|
293
|
+
? text(repo, { color: muted, fontSize: 22 })
|
|
209
294
|
: container({}),
|
|
210
295
|
site
|
|
211
|
-
? text(site, { color:
|
|
296
|
+
? text(site, { color: faint, fontSize: 22 })
|
|
212
297
|
: container({}),
|
|
213
298
|
],
|
|
214
299
|
style: {
|
|
@@ -227,8 +312,8 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
|
227
312
|
const node = container({
|
|
228
313
|
children: [header, body, footer],
|
|
229
314
|
style: {
|
|
230
|
-
backgroundColor:
|
|
231
|
-
color:
|
|
315
|
+
backgroundColor: background,
|
|
316
|
+
color: foreground,
|
|
232
317
|
display: "flex",
|
|
233
318
|
flexDirection: "column",
|
|
234
319
|
height: HEIGHT,
|
|
@@ -238,9 +323,16 @@ export const renderOgImage = (options: OgCardOptions): Promise<Buffer> => {
|
|
|
238
323
|
},
|
|
239
324
|
});
|
|
240
325
|
|
|
241
|
-
|
|
326
|
+
const fonts = options.fonts ?? [];
|
|
327
|
+
// `render` registers only the subsets a card's text uses and skips files it
|
|
328
|
+
// has already loaded, so passing the full family list per page is cheap.
|
|
329
|
+
const fontSubsets = fonts.length ? await loadFonts(fonts) : undefined;
|
|
330
|
+
|
|
331
|
+
return render(node, {
|
|
332
|
+
fonts: fontSubsets,
|
|
242
333
|
format: "png",
|
|
243
334
|
height: HEIGHT,
|
|
335
|
+
images: resolveImages(options.images),
|
|
244
336
|
width: WIDTH,
|
|
245
337
|
});
|
|
246
338
|
};
|
package/src/og/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { renderOgImage } from "./card.ts";
|
|
2
|
-
export type { OgCardOptions } from "./card.ts";
|
|
2
|
+
export type { OgCardOptions, OgCardPalette, OgFont } from "./card.ts";
|
package/src/og/logo.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { join } from "pathe";
|
|
4
|
+
|
|
5
|
+
import type { BlumeProject } from "../core/project-graph.ts";
|
|
6
|
+
|
|
7
|
+
/** Resolve a configured local SVG for use in generated Open Graph cards. */
|
|
8
|
+
export const resolveOgLogo = (
|
|
9
|
+
project: BlumeProject,
|
|
10
|
+
source: string | undefined
|
|
11
|
+
): string | undefined => {
|
|
12
|
+
if (!source?.toLowerCase().endsWith(".svg")) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const relative = source.replace(/^\//u, "");
|
|
16
|
+
const file = [
|
|
17
|
+
join(project.context.root, "public", relative),
|
|
18
|
+
join(project.context.root, relative),
|
|
19
|
+
].find((path) => existsSync(path));
|
|
20
|
+
return file ? readFileSync(file, "utf-8") : undefined;
|
|
21
|
+
};
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { withBasePath } from "../core/base-path.ts";
|
|
2
2
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
3
3
|
import { trimChar, trimEnd } from "../core/trim.ts";
|
|
4
|
-
import type { NavTab } from "../core/types.ts";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Pure resolution of the configured API reference blocks into concrete routes,
|
|
8
|
-
* labels, and a renderer choice — no file IO, so the content source, the
|
|
9
|
-
*
|
|
10
|
-
* one source of truth. Kept free of any Astro/template
|
|
11
|
-
* depend on it without a cycle.
|
|
7
|
+
* labels, and a renderer choice — no file IO, so the content source, the
|
|
8
|
+
* nav-target validation, the Scalar page generator, and the `blume:openapi`
|
|
9
|
+
* data module all share one source of truth. Kept free of any Astro/template
|
|
10
|
+
* imports so `core` can depend on it without a cycle.
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
export type ReferenceKind = "openapi" | "asyncapi";
|
|
@@ -155,18 +154,22 @@ export const resolveReferences = (
|
|
|
155
154
|
),
|
|
156
155
|
];
|
|
157
156
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Mounted route for every reference, regardless of renderer. References no
|
|
159
|
+
* longer add a header tab automatically — authors point a `navigation.tabs`
|
|
160
|
+
* entry at one of these routes to surface it (and, for Blume-rendered specs, to
|
|
161
|
+
* scope its operations sidebar). These routes are whitelisted as valid nav
|
|
162
|
+
* targets so such a tab doesn't read as a broken link.
|
|
163
|
+
*/
|
|
164
|
+
export const referenceRoutes = (config: ResolvedConfig): string[] =>
|
|
165
|
+
resolveReferences(config).map((ref) =>
|
|
162
166
|
// Blume-rendered operation pages flow through the content pipeline and are
|
|
163
|
-
// mounted under `basePath
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
ref.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}));
|
|
167
|
+
// mounted under `basePath`. Scalar references are a single embedded page
|
|
168
|
+
// injected at the raw `route`, left root-anchored.
|
|
169
|
+
ref.renderer === "blume"
|
|
170
|
+
? withBasePath(config.basePath, ref.route)
|
|
171
|
+
: ref.route
|
|
172
|
+
);
|
|
170
173
|
|
|
171
174
|
/**
|
|
172
175
|
* Accept one resolved reference into the deduped Blume-rendered set, or return
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { withBasePath } from "../core/base-path.ts";
|
|
2
|
+
|
|
3
|
+
/** A resolved search empty-state link passed to the Search dialog. */
|
|
4
|
+
export interface SearchPopularPage {
|
|
5
|
+
/** Built-in icon name; `Search.astro` resolves it to markup at render time. */
|
|
6
|
+
icon?: string;
|
|
7
|
+
label: string;
|
|
8
|
+
route: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map configured `search.popular` entries to `{ route, label }` for Search.
|
|
13
|
+
*
|
|
14
|
+
* Curated hrefs are authored as if mounted at root, so `basePath` is applied
|
|
15
|
+
* here — matching `navigation.featured` and tab paths, and agreeing with the
|
|
16
|
+
* sidebar fallback these entries replace (whose routes are already based via
|
|
17
|
+
* `page.route`). `withBasePath` is idempotent and skips external URLs.
|
|
18
|
+
*
|
|
19
|
+
* `deployment.base` is deliberately *not* applied: routes stay deploy-base-less
|
|
20
|
+
* so `createLinkRow` can prefix it at click time, same as sidebar-derived pages.
|
|
21
|
+
*
|
|
22
|
+
* `icon` stays a name here (like `navigation.featured`): the icon set is a
|
|
23
|
+
* server-only module, so `Search.astro` resolves it to inline SVG at render.
|
|
24
|
+
*/
|
|
25
|
+
export const resolveSearchPopular = (
|
|
26
|
+
popular: { href: string; icon?: string; label: string }[],
|
|
27
|
+
basePath: string
|
|
28
|
+
): SearchPopularPage[] =>
|
|
29
|
+
popular.map(({ href, icon, label }) => ({
|
|
30
|
+
...(icon ? { icon } : {}),
|
|
31
|
+
label,
|
|
32
|
+
route: withBasePath(basePath, href),
|
|
33
|
+
}));
|