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