@nu-appdev/northwestern-starlight-theme 1.4.0 → 1.5.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 +28 -1
- package/README.md +8 -12
- package/config.ts +308 -0
- package/dist/config.d.ts +82 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/expressive-code.d.ts +21 -0
- package/dist/expressive-code.d.ts.map +1 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/mermaid.d.ts +97 -0
- package/dist/mermaid.d.ts.map +1 -0
- package/dist/src/config-schema.d.ts +141 -0
- package/dist/src/config-schema.d.ts.map +1 -0
- package/dist/src/rehype-table-scroll.d.ts +10 -0
- package/dist/src/rehype-table-scroll.d.ts.map +1 -0
- package/expressive-code.ts +35 -0
- package/index.ts +55 -44
- package/mermaid.ts +22 -12
- package/package.json +21 -8
- package/src/config-schema.ts +250 -0
- package/src/og/endpoint.ts +49 -39
- package/src/og/render.ts +260 -0
- package/src/rehype-table-scroll.ts +7 -0
- package/src/styles/content.css +18 -17
- package/src/styles/typography.css +2 -2
- package/src/virtual.d.ts +1 -0
- package/expressive-code.mjs +0 -18
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-empty string helper used when reading titles from mixed Starlight config shapes.
|
|
5
|
+
*
|
|
6
|
+
* Trims surrounding whitespace and rejects empty strings after trimming.
|
|
7
|
+
*/
|
|
8
|
+
export const nonEmptyStringSchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.trim()
|
|
11
|
+
.min(1)
|
|
12
|
+
.meta({ description: "A non-empty string with surrounding whitespace removed." });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* CSS pixel length helper used for hero image sizing.
|
|
16
|
+
*
|
|
17
|
+
* Restricts values to explicit pixel lengths so consumers get deterministic
|
|
18
|
+
* hero sizing and friendly validation errors for malformed values.
|
|
19
|
+
*/
|
|
20
|
+
const pixelLengthSchema = z
|
|
21
|
+
.string()
|
|
22
|
+
.trim()
|
|
23
|
+
.regex(/^\d+px$/, 'Expected a pixel value like "500px".')
|
|
24
|
+
.meta({
|
|
25
|
+
description: 'A CSS pixel length such as "500px" or "750px".',
|
|
26
|
+
examples: ["500px", "750px", "1000px"],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configuration for the homepage hero section.
|
|
31
|
+
*
|
|
32
|
+
* Controls hero layout, title visibility, and image sizing. This is used as
|
|
33
|
+
* the `homepage` property of the top-level theme config.
|
|
34
|
+
*/
|
|
35
|
+
const northwesternHomepageConfigSchema = z
|
|
36
|
+
.strictObject({
|
|
37
|
+
/**
|
|
38
|
+
* Hero layout style.
|
|
39
|
+
*
|
|
40
|
+
* - `"centered"`: image above title, all content centered
|
|
41
|
+
* - `"split"`: text and actions on the left, image on the right
|
|
42
|
+
*/
|
|
43
|
+
layout: z.enum(["centered", "split"]).meta({
|
|
44
|
+
description: 'Hero layout style. Use "centered" for a stacked hero or "split" for a two-column layout.',
|
|
45
|
+
examples: ["centered", "split"],
|
|
46
|
+
}),
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether to display the page title inside the hero.
|
|
50
|
+
*
|
|
51
|
+
* Set this to `false` when the hero image already contains the title
|
|
52
|
+
* as part of a branded lockup.
|
|
53
|
+
*/
|
|
54
|
+
showTitle: z.boolean().meta({
|
|
55
|
+
description: "Whether to render the page title inside the hero section.",
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Maximum width of the hero image in the centered layout.
|
|
60
|
+
*
|
|
61
|
+
* Use a larger value for wide lockup images that would otherwise feel cramped.
|
|
62
|
+
*/
|
|
63
|
+
imageWidth: pixelLengthSchema.meta({
|
|
64
|
+
description: "Maximum width of the hero image in the centered layout, expressed in pixels.",
|
|
65
|
+
examples: ["500px", "750px"],
|
|
66
|
+
}),
|
|
67
|
+
})
|
|
68
|
+
.partial()
|
|
69
|
+
.meta({
|
|
70
|
+
description: "Homepage hero configuration. Controls layout, title visibility, and hero image sizing.",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Top-level configuration for the Northwestern Starlight theme plugin.
|
|
75
|
+
*
|
|
76
|
+
* With `defineNorthwesternConfig`, pass this object as the `theme` key.
|
|
77
|
+
* With `northwesternTheme()` directly, pass it as the function argument.
|
|
78
|
+
*/
|
|
79
|
+
export const northwesternThemeConfigSchema = z
|
|
80
|
+
.strictObject({
|
|
81
|
+
/**
|
|
82
|
+
* Homepage hero layout configuration.
|
|
83
|
+
*/
|
|
84
|
+
homepage: northwesternHomepageConfigSchema.optional().meta({
|
|
85
|
+
description: "Homepage hero configuration.",
|
|
86
|
+
}),
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Mermaid diagram support.
|
|
90
|
+
*
|
|
91
|
+
* - `true`: auto-detect Mermaid packages and enable support when available
|
|
92
|
+
* - `false`: disable Mermaid integration entirely
|
|
93
|
+
* - `object`: merge custom Mermaid options with Northwestern defaults
|
|
94
|
+
*/
|
|
95
|
+
mermaid: z
|
|
96
|
+
.union([z.boolean(), z.record(z.string(), z.unknown())])
|
|
97
|
+
.optional()
|
|
98
|
+
.meta({
|
|
99
|
+
description:
|
|
100
|
+
"Mermaid support toggle or override object. Use true to auto-detect, false to disable, or an object to merge custom Mermaid options.",
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Open Graph image generation.
|
|
105
|
+
*
|
|
106
|
+
* Generates a branded 1200x630 image for each docs page when `site`
|
|
107
|
+
* is configured in `astro.config.ts`.
|
|
108
|
+
*/
|
|
109
|
+
ogImage: z.boolean().optional().meta({
|
|
110
|
+
description: "Whether to generate branded Open Graph images for docs pages.",
|
|
111
|
+
}),
|
|
112
|
+
})
|
|
113
|
+
.meta({
|
|
114
|
+
description: "Top-level Northwestern theme plugin configuration.",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Configuration options for the standalone Northwestern Mermaid integration.
|
|
119
|
+
*
|
|
120
|
+
* This intentionally validates only the Northwestern-owned wrapper surface and
|
|
121
|
+
* allows additional upstream `astro-mermaid` options to pass through unchanged.
|
|
122
|
+
*/
|
|
123
|
+
export const northwesternMermaidOptionsSchema = z
|
|
124
|
+
.looseObject({
|
|
125
|
+
/**
|
|
126
|
+
* Show the hover toolbar on rendered diagrams.
|
|
127
|
+
*
|
|
128
|
+
* The toolbar provides fullscreen, download, and copy-source actions.
|
|
129
|
+
*/
|
|
130
|
+
toolbar: z.boolean().optional().meta({
|
|
131
|
+
description: "Whether to show the Mermaid hover toolbar with fullscreen, download, and copy actions.",
|
|
132
|
+
}),
|
|
133
|
+
})
|
|
134
|
+
.meta({
|
|
135
|
+
description:
|
|
136
|
+
"Northwestern Mermaid integration options. Supports the `toolbar` toggle plus passthrough astro-mermaid options.",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Configuration options for `defineNorthwesternConfig()`.
|
|
141
|
+
*
|
|
142
|
+
* This schema validates the Northwestern-owned wrapper surface while allowing
|
|
143
|
+
* the rest of Astro's top-level config to pass through untouched.
|
|
144
|
+
*/
|
|
145
|
+
export const northwesternConfigOptionsSchema = z
|
|
146
|
+
.looseObject({
|
|
147
|
+
/**
|
|
148
|
+
* Full Starlight configuration object.
|
|
149
|
+
*
|
|
150
|
+
* This is forwarded to `starlight()` after Northwestern defaults and
|
|
151
|
+
* helper-managed integration ordering are applied.
|
|
152
|
+
*/
|
|
153
|
+
starlight: z.looseObject({}).meta({
|
|
154
|
+
description: "Full Starlight configuration object passed through to the Starlight integration.",
|
|
155
|
+
}),
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Northwestern theme plugin options.
|
|
159
|
+
*/
|
|
160
|
+
theme: northwesternThemeConfigSchema.optional().meta({
|
|
161
|
+
description: "Northwestern theme plugin options.",
|
|
162
|
+
}),
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Mermaid diagram support.
|
|
166
|
+
*
|
|
167
|
+
* - `true`: add Northwestern Mermaid with defaults
|
|
168
|
+
* - `false`: disable Mermaid
|
|
169
|
+
* - `object`: add Northwestern Mermaid with custom options
|
|
170
|
+
*/
|
|
171
|
+
mermaid: z.union([z.boolean(), northwesternMermaidOptionsSchema]).optional().meta({
|
|
172
|
+
description:
|
|
173
|
+
"Mermaid integration toggle or options object. Use true for defaults, false to disable, or an object for custom Mermaid behavior.",
|
|
174
|
+
}),
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Additional Starlight plugins to register after the Northwestern theme.
|
|
178
|
+
*/
|
|
179
|
+
plugins: z.array(z.unknown()).optional().meta({
|
|
180
|
+
description: "Additional Starlight plugins to append after the Northwestern theme plugin.",
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Escape hatch for advanced integration ordering.
|
|
185
|
+
*
|
|
186
|
+
* Use `before` for integrations that must run before Mermaid/Starlight,
|
|
187
|
+
* and `after` for integrations that should be appended after Starlight.
|
|
188
|
+
*/
|
|
189
|
+
integrations: z
|
|
190
|
+
.strictObject({
|
|
191
|
+
/**
|
|
192
|
+
* Integrations added before Mermaid and Starlight.
|
|
193
|
+
*/
|
|
194
|
+
before: z.array(z.unknown()).optional().meta({
|
|
195
|
+
description: "Astro integrations to register before Mermaid and Starlight.",
|
|
196
|
+
}),
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Integrations added after Starlight.
|
|
200
|
+
*/
|
|
201
|
+
after: z.array(z.unknown()).optional().meta({
|
|
202
|
+
description: "Astro integrations to register after Starlight.",
|
|
203
|
+
}),
|
|
204
|
+
})
|
|
205
|
+
.optional()
|
|
206
|
+
.meta({
|
|
207
|
+
description: "Advanced integration ordering overrides.",
|
|
208
|
+
}),
|
|
209
|
+
})
|
|
210
|
+
.meta({
|
|
211
|
+
description: "Options for defineNorthwesternConfig(), combining Astro config with Northwestern-specific keys.",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
function formatIssuePath(path: PropertyKey[]): string {
|
|
215
|
+
return path.length > 0 ? path.join(".") : "config";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Format Zod validation errors as stable, package-prefixed user-facing messages.
|
|
220
|
+
*
|
|
221
|
+
* The goal is to fail fast with errors that point directly at the bad config
|
|
222
|
+
* path instead of surfacing cryptic downstream behavior.
|
|
223
|
+
*/
|
|
224
|
+
function formatValidationError(scope: string, error: z.ZodError): string {
|
|
225
|
+
const lines = error.issues.map((issue) => {
|
|
226
|
+
if (issue.code === "unrecognized_keys") {
|
|
227
|
+
const basePath = formatIssuePath(issue.path);
|
|
228
|
+
return issue.keys
|
|
229
|
+
.map((key) => `${basePath === "config" ? key : `${basePath}.${key}`}: Unrecognized key.`)
|
|
230
|
+
.join("\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return `${formatIssuePath(issue.path)}: ${issue.message}`;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return [`[northwestern-starlight-theme] Invalid ${scope}.`, ...lines.map((line) => ` - ${line}`)].join("\n");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Validate a public config object and throw a friendly package-scoped error on failure.
|
|
241
|
+
*/
|
|
242
|
+
export function validateSchema<T>(schema: z.ZodType<T>, value: unknown, scope: string): T {
|
|
243
|
+
const parsed = schema.safeParse(value);
|
|
244
|
+
|
|
245
|
+
if (!parsed.success) {
|
|
246
|
+
throw new Error(formatValidationError(scope, parsed.error));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return parsed.data;
|
|
250
|
+
}
|
package/src/og/endpoint.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { getCollection } from "astro:content";
|
|
2
2
|
import config from "virtual:northwestern-theme/config";
|
|
3
|
-
import {
|
|
3
|
+
import { renderOGImage } from "./render";
|
|
4
4
|
|
|
5
5
|
const siteTitle = config.ogImage.siteTitle;
|
|
6
6
|
const logoPath = config.ogImage.logoPath;
|
|
7
|
+
const resvgWasmPath = config.ogImage.resvgWasmPath;
|
|
7
8
|
const docs = await getCollection("docs");
|
|
8
9
|
let changelogs: typeof docs = [];
|
|
9
10
|
try {
|
|
@@ -11,43 +12,52 @@ try {
|
|
|
11
12
|
} catch {}
|
|
12
13
|
const pages = Object.fromEntries([...docs, ...changelogs].map((entry) => [entry.id, entry]));
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
15
|
+
function getImageOptions(path: string, page: (typeof docs)[number]) {
|
|
16
|
+
const isIndex = path === "index";
|
|
17
|
+
const isChangelogVersion = path.startsWith("changelog/version/");
|
|
18
|
+
const title = isIndex ? siteTitle : isChangelogVersion ? `Changelog\n${page.data.title}` : page.data.title;
|
|
19
|
+
return {
|
|
20
|
+
resvgWasmPath,
|
|
21
|
+
title,
|
|
22
|
+
description: page.data.description,
|
|
23
|
+
logo: { path: logoPath, size: [80] as [number] },
|
|
24
|
+
bgGradient: [[64, 31, 104]] as [number, number, number][],
|
|
25
|
+
padding: [60, 220] as [number, number],
|
|
26
|
+
border: {
|
|
27
|
+
color: [164, 149, 195] as [number, number, number],
|
|
28
|
+
width: 12,
|
|
29
|
+
},
|
|
30
|
+
font: {
|
|
31
|
+
title: {
|
|
32
|
+
families: ["Poppins"],
|
|
33
|
+
weight: "Bold" as const,
|
|
34
|
+
size: isChangelogVersion ? 64 : 56,
|
|
35
|
+
lineHeight: 1.3,
|
|
36
|
+
color: [255, 255, 255] as [number, number, number],
|
|
31
37
|
},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
lineHeight: 1.3,
|
|
38
|
-
color: [255, 255, 255],
|
|
39
|
-
},
|
|
40
|
-
description: {
|
|
41
|
-
families: ["Akkurat Pro"],
|
|
42
|
-
size: 28,
|
|
43
|
-
lineHeight: 1.5,
|
|
44
|
-
color: [182, 172, 209],
|
|
45
|
-
},
|
|
38
|
+
description: {
|
|
39
|
+
families: ["Akkurat Pro"],
|
|
40
|
+
size: 32,
|
|
41
|
+
lineHeight: 1.5,
|
|
42
|
+
color: [182, 172, 209] as [number, number, number],
|
|
46
43
|
},
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
}
|
|
44
|
+
},
|
|
45
|
+
fonts: [
|
|
46
|
+
"https://common.northwestern.edu/v8/css/fonts/Poppins-Bold.woff",
|
|
47
|
+
"https://common.northwestern.edu/v8/css/fonts/AkkuratProRegular.woff",
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getStaticPaths() {
|
|
53
|
+
return Object.entries(pages).map(([pagePath, page]) => ({
|
|
54
|
+
params: { slug: `${pagePath}.png` },
|
|
55
|
+
props: { imageOptions: getImageOptions(pagePath, page) },
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function GET({ props }: { props: { imageOptions: ReturnType<typeof getImageOptions> } }) {
|
|
60
|
+
return new Response(await renderOGImage(props.imageOptions), {
|
|
61
|
+
headers: { "Content-Type": "image/png" },
|
|
62
|
+
});
|
|
63
|
+
}
|
package/src/og/render.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { initWasm, Resvg } from "@resvg/resvg-wasm";
|
|
3
|
+
import satori from "satori";
|
|
4
|
+
|
|
5
|
+
type RGBColor = [r: number, g: number, b: number];
|
|
6
|
+
type FontWeight = string;
|
|
7
|
+
|
|
8
|
+
interface FontConfig {
|
|
9
|
+
color?: RGBColor;
|
|
10
|
+
size?: number;
|
|
11
|
+
weight?: FontWeight;
|
|
12
|
+
lineHeight?: number;
|
|
13
|
+
families?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface OGImageOptions {
|
|
17
|
+
resvgWasmPath: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
logo?: {
|
|
21
|
+
path: string;
|
|
22
|
+
size?: [width?: number, height?: number];
|
|
23
|
+
};
|
|
24
|
+
bgGradient?: RGBColor[];
|
|
25
|
+
border?: {
|
|
26
|
+
color?: RGBColor;
|
|
27
|
+
width?: number;
|
|
28
|
+
};
|
|
29
|
+
padding?: number | [vertical: number, horizontal: number];
|
|
30
|
+
font?: {
|
|
31
|
+
title?: FontConfig;
|
|
32
|
+
description?: FontConfig;
|
|
33
|
+
};
|
|
34
|
+
fonts?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [width, height] = [1200, 630];
|
|
38
|
+
|
|
39
|
+
let wasmInitialized = false;
|
|
40
|
+
async function ensureWasm(wasmPath: string) {
|
|
41
|
+
if (wasmInitialized) return;
|
|
42
|
+
await initWasm(fs.readFile(wasmPath));
|
|
43
|
+
wasmInitialized = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const fontWeightMap: Record<string, number> = {
|
|
47
|
+
Normal: 400,
|
|
48
|
+
Bold: 700,
|
|
49
|
+
ExtraBold: 800,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function toFontWeight(weight: string): number {
|
|
53
|
+
return fontWeightMap[weight] ?? 400;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function decodeText(text: string) {
|
|
57
|
+
return text
|
|
58
|
+
.replaceAll("&", "&")
|
|
59
|
+
.replaceAll("<", "<")
|
|
60
|
+
.replaceAll(">", ">")
|
|
61
|
+
.replaceAll(""", '"')
|
|
62
|
+
.replaceAll("'", "'");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function rgbToCSS(rgb: RGBColor): string {
|
|
66
|
+
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fontCache = new Map<string, ArrayBuffer>();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Load a font from disk or a remote URL for OG image rendering.
|
|
73
|
+
*
|
|
74
|
+
* @internal
|
|
75
|
+
*/
|
|
76
|
+
export async function loadFont(url: string): Promise<ArrayBuffer> {
|
|
77
|
+
const cached = fontCache.get(url);
|
|
78
|
+
if (cached) return cached;
|
|
79
|
+
let buffer: ArrayBuffer;
|
|
80
|
+
if (/^https?:\/\//.test(url)) {
|
|
81
|
+
let response: Response;
|
|
82
|
+
try {
|
|
83
|
+
response = await fetch(url);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
86
|
+
throw new Error(`[northwestern-starlight-theme] Failed to fetch OG font from ${url}: ${message}`, {
|
|
87
|
+
cause: error,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
93
|
+
throw new Error(
|
|
94
|
+
`[northwestern-starlight-theme] Failed to fetch OG font from ${url}: HTTP ${response.status}${statusText}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
buffer = await response.arrayBuffer();
|
|
99
|
+
} else {
|
|
100
|
+
const file = await fs.readFile(url);
|
|
101
|
+
buffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
|
|
102
|
+
}
|
|
103
|
+
fontCache.set(url, buffer);
|
|
104
|
+
return buffer;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const logoCache = new Map<string, string>();
|
|
108
|
+
|
|
109
|
+
async function loadLogoDataURL(filePath: string): Promise<string> {
|
|
110
|
+
const cached = logoCache.get(filePath);
|
|
111
|
+
if (cached) return cached;
|
|
112
|
+
const buffer = await fs.readFile(filePath);
|
|
113
|
+
const ext = filePath.split(".").pop()?.toLowerCase() ?? "png";
|
|
114
|
+
const mime = ext === "svg" ? "image/svg+xml" : `image/${ext}`;
|
|
115
|
+
const dataURL = `data:${mime};base64,${buffer.toString("base64")}`;
|
|
116
|
+
logoCache.set(filePath, dataURL);
|
|
117
|
+
return dataURL;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function renderOGImage({
|
|
121
|
+
resvgWasmPath,
|
|
122
|
+
title,
|
|
123
|
+
description = "",
|
|
124
|
+
bgGradient = [[0, 0, 0]],
|
|
125
|
+
border: borderConfig = {},
|
|
126
|
+
padding: rawPadding = 80,
|
|
127
|
+
logo,
|
|
128
|
+
font: fontConfig = {},
|
|
129
|
+
fonts: fontUrls = ["https://api.fontsource.org/v1/fonts/noto-sans/latin-400-normal.ttf"],
|
|
130
|
+
}: OGImageOptions) {
|
|
131
|
+
const decodedTitle = decodeText(title);
|
|
132
|
+
const decodedDescription = description ? decodeText(description) : "";
|
|
133
|
+
|
|
134
|
+
const [vPad, hPad] = Array.isArray(rawPadding) ? rawPadding : [rawPadding, rawPadding];
|
|
135
|
+
const borderColor = borderConfig.color ?? [255, 255, 255];
|
|
136
|
+
const borderWidth = borderConfig.width ?? 0;
|
|
137
|
+
|
|
138
|
+
const titleFont = {
|
|
139
|
+
families: fontConfig.title?.families ?? ["Noto Sans"],
|
|
140
|
+
size: fontConfig.title?.size ?? 70,
|
|
141
|
+
weight: fontConfig.title?.weight ?? "Normal",
|
|
142
|
+
lineHeight: fontConfig.title?.lineHeight ?? 1,
|
|
143
|
+
color: fontConfig.title?.color ?? ([255, 255, 255] as RGBColor),
|
|
144
|
+
};
|
|
145
|
+
const descFont = {
|
|
146
|
+
families: fontConfig.description?.families ?? ["Noto Sans"],
|
|
147
|
+
size: fontConfig.description?.size ?? 40,
|
|
148
|
+
weight: fontConfig.description?.weight ?? "Normal",
|
|
149
|
+
lineHeight: fontConfig.description?.lineHeight ?? 1.3,
|
|
150
|
+
color: fontConfig.description?.color ?? ([255, 255, 255] as RGBColor),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const fontData = await Promise.all(fontUrls.map(loadFont));
|
|
154
|
+
const satoriFont = fontUrls.map((url, i) => {
|
|
155
|
+
const name = url.includes("Poppins") ? "Poppins" : url.includes("Akkurat") ? "Akkurat Pro" : "Noto Sans";
|
|
156
|
+
return { name, data: fontData[i], weight: 400 as const };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const logoDataURL = logo ? await loadLogoDataURL(logo.path) : undefined;
|
|
160
|
+
const logoW = logo?.size?.[0] ?? 60;
|
|
161
|
+
const logoH = logo?.size?.[1] ?? logoW;
|
|
162
|
+
|
|
163
|
+
const bgStart = rgbToCSS(bgGradient[0]);
|
|
164
|
+
const bgEnd = bgGradient.length > 1 ? rgbToCSS(bgGradient[bgGradient.length - 1]) : bgStart;
|
|
165
|
+
|
|
166
|
+
const element = {
|
|
167
|
+
type: "div",
|
|
168
|
+
props: {
|
|
169
|
+
style: {
|
|
170
|
+
display: "flex",
|
|
171
|
+
flexDirection: "column",
|
|
172
|
+
alignItems: "center",
|
|
173
|
+
justifyContent: "center",
|
|
174
|
+
width: "100%",
|
|
175
|
+
height: "100%",
|
|
176
|
+
background: `linear-gradient(to bottom, ${bgStart}, ${bgEnd})`,
|
|
177
|
+
padding: `${vPad}px ${hPad}px`,
|
|
178
|
+
paddingLeft: `${hPad + borderWidth}px`,
|
|
179
|
+
borderLeft: borderWidth ? `${borderWidth}px solid ${rgbToCSS(borderColor)}` : undefined,
|
|
180
|
+
},
|
|
181
|
+
children: [
|
|
182
|
+
...(logoDataURL
|
|
183
|
+
? [
|
|
184
|
+
{
|
|
185
|
+
type: "img",
|
|
186
|
+
props: {
|
|
187
|
+
src: logoDataURL,
|
|
188
|
+
width: logoW,
|
|
189
|
+
height: logoH,
|
|
190
|
+
style: { marginBottom: "32px" },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
]
|
|
194
|
+
: []),
|
|
195
|
+
{
|
|
196
|
+
type: "div",
|
|
197
|
+
props: {
|
|
198
|
+
style: {
|
|
199
|
+
display: "flex",
|
|
200
|
+
flexDirection: "column",
|
|
201
|
+
alignItems: "center",
|
|
202
|
+
justifyContent: "center",
|
|
203
|
+
textAlign: "center",
|
|
204
|
+
width: "100%",
|
|
205
|
+
},
|
|
206
|
+
children: [
|
|
207
|
+
{
|
|
208
|
+
type: "div",
|
|
209
|
+
props: {
|
|
210
|
+
style: {
|
|
211
|
+
fontFamily: titleFont.families[0],
|
|
212
|
+
fontSize: `${titleFont.size}px`,
|
|
213
|
+
fontWeight: toFontWeight(titleFont.weight),
|
|
214
|
+
lineHeight: titleFont.lineHeight,
|
|
215
|
+
color: rgbToCSS(titleFont.color),
|
|
216
|
+
whiteSpace: "pre-wrap",
|
|
217
|
+
textAlign: "center",
|
|
218
|
+
},
|
|
219
|
+
children: decodedTitle,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
...(decodedDescription
|
|
223
|
+
? [
|
|
224
|
+
{
|
|
225
|
+
type: "div",
|
|
226
|
+
props: {
|
|
227
|
+
style: {
|
|
228
|
+
fontFamily: descFont.families[0],
|
|
229
|
+
fontSize: `${descFont.size}px`,
|
|
230
|
+
fontWeight: toFontWeight(descFont.weight),
|
|
231
|
+
lineHeight: descFont.lineHeight,
|
|
232
|
+
color: rgbToCSS(descFont.color),
|
|
233
|
+
marginTop: "24px",
|
|
234
|
+
textAlign: "center",
|
|
235
|
+
maxWidth: "560px",
|
|
236
|
+
},
|
|
237
|
+
children: decodedDescription,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
]
|
|
241
|
+
: []),
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const svg = await satori(element as any, {
|
|
250
|
+
width,
|
|
251
|
+
height,
|
|
252
|
+
fonts: satoriFont,
|
|
253
|
+
});
|
|
254
|
+
await ensureWasm(resvgWasmPath);
|
|
255
|
+
const resvg = new Resvg(svg, {
|
|
256
|
+
fitTo: { mode: "width", value: width },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return Buffer.from(resvg.render().asPng());
|
|
260
|
+
}
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { Element, Nodes, Root } from "hast";
|
|
9
9
|
|
|
10
|
+
function isScrollWrapper(node: Element): boolean {
|
|
11
|
+
const classes = node.properties?.className;
|
|
12
|
+
return Array.isArray(classes) && classes.includes("nu-table-scroll");
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
function walk(node: Nodes) {
|
|
11
16
|
if (!("children" in node)) return;
|
|
12
17
|
for (let i = 0; i < node.children.length; i++) {
|
|
@@ -24,6 +29,8 @@ function walk(node: Nodes) {
|
|
|
24
29
|
children: [child as Element],
|
|
25
30
|
};
|
|
26
31
|
node.children[i] = wrapper;
|
|
32
|
+
} else if (child.type === "element" && isScrollWrapper(child as Element)) {
|
|
33
|
+
// Already wrapped — skip to avoid double-wrapping
|
|
27
34
|
} else {
|
|
28
35
|
walk(child as Nodes);
|
|
29
36
|
}
|