erudit 4.3.0-dev.1 → 4.3.1-dev.1
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/app/composables/formatText.ts +9 -100
- package/app/composables/og.ts +58 -16
- package/app/pages/contributor/[contributorId].vue +1 -0
- package/app/pages/contributors.vue +1 -0
- package/app/pages/sponsors.vue +1 -0
- package/app/plugins/prerender.server.ts +1 -0
- package/modules/erudit/setup/elements/appTemplate.ts +5 -3
- package/modules/erudit/setup/elements/globalTemplate.ts +7 -6
- package/modules/erudit/setup/problemChecks/template.ts +7 -3
- package/modules/erudit/setup/toJsSlug.ts +19 -0
- package/nuxt.config.ts +1 -1
- package/package.json +14 -12
- package/server/api/prerender/ogImages.ts +46 -0
- package/server/api/problemScript/[...problemScriptPath].ts +18 -0
- package/server/erudit/importer.ts +72 -14
- package/server/erudit/ogImage/fonts/NotoSans-Bold.ttf +0 -0
- package/server/erudit/ogImage/fonts/NotoSans-Regular.ttf +0 -0
- package/server/erudit/ogImage/formatText.ts +12 -0
- package/server/erudit/ogImage/icons.ts +22 -0
- package/server/erudit/ogImage/logotype.ts +51 -0
- package/server/erudit/ogImage/render.ts +90 -0
- package/server/erudit/ogImage/shared.ts +320 -0
- package/server/erudit/ogImage/templates/content.ts +200 -0
- package/server/erudit/ogImage/templates/index.ts +138 -0
- package/server/erudit/ogImage/templates/sitePage.ts +110 -0
- package/server/erudit/staticFile.ts +3 -0
- package/server/routes/og/content/[...contentTypePath].ts +126 -0
- package/server/routes/og/site/contributor/[contributorId].ts +60 -0
- package/server/routes/og/site/contributors.png.ts +38 -0
- package/server/routes/og/site/index.png.ts +51 -0
- package/server/routes/og/site/sponsors.png.ts +38 -0
- package/shared/utils/formatText.ts +73 -0
- package/app/formatters/ru.ts +0 -14
- package/public/og.png +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
|
|
4
|
+
let cachedLogotypeDataUri: string | undefined | null;
|
|
5
|
+
|
|
6
|
+
export async function loadLogotypeDataUri(
|
|
7
|
+
explicitPath?: string,
|
|
8
|
+
): Promise<string | undefined> {
|
|
9
|
+
if (cachedLogotypeDataUri !== undefined) {
|
|
10
|
+
return cachedLogotypeDataUri ?? undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const logotypeUrl =
|
|
14
|
+
explicitPath || ERUDIT.config.public.asideMajor?.siteInfo?.logotype;
|
|
15
|
+
if (!logotypeUrl || logotypeUrl === '') {
|
|
16
|
+
cachedLogotypeDataUri = null;
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (logotypeUrl.startsWith('http://') || logotypeUrl.startsWith('https://')) {
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(logotypeUrl);
|
|
23
|
+
if (response.ok) {
|
|
24
|
+
const contentType =
|
|
25
|
+
response.headers.get('content-type') || 'image/svg+xml';
|
|
26
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
27
|
+
cachedLogotypeDataUri = `data:${contentType};base64,${buffer.toString('base64')}`;
|
|
28
|
+
return cachedLogotypeDataUri;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Ignore fetch errors
|
|
32
|
+
}
|
|
33
|
+
cachedLogotypeDataUri = null;
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Local path — resolve relative to project root, or use as-is if absolute
|
|
38
|
+
const localPath = isAbsolute(logotypeUrl)
|
|
39
|
+
? logotypeUrl
|
|
40
|
+
: ERUDIT.paths.project(logotypeUrl.replace(/^\/+/, ''));
|
|
41
|
+
if (existsSync(localPath)) {
|
|
42
|
+
const buffer = readFileSync(localPath);
|
|
43
|
+
const ext = logotypeUrl.split('.').pop()?.toLowerCase();
|
|
44
|
+
const mime = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`;
|
|
45
|
+
cachedLogotypeDataUri = `data:${mime};base64,${buffer.toString('base64')}`;
|
|
46
|
+
return cachedLogotypeDataUri;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
cachedLogotypeDataUri = null;
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import satori from 'satori';
|
|
3
|
+
import { Resvg } from '@resvg/resvg-js';
|
|
4
|
+
import { type SatoriNode, OG_WIDTH, OG_HEIGHT } from './shared';
|
|
5
|
+
|
|
6
|
+
let regularFontData: ArrayBuffer | undefined;
|
|
7
|
+
let boldFontData: ArrayBuffer | undefined;
|
|
8
|
+
|
|
9
|
+
function loadFontFile(filename: string): ArrayBuffer {
|
|
10
|
+
const fontPath = ERUDIT.paths.erudit('server/erudit/ogImage/fonts', filename);
|
|
11
|
+
const buffer = readFileSync(fontPath);
|
|
12
|
+
return buffer.buffer.slice(
|
|
13
|
+
buffer.byteOffset,
|
|
14
|
+
buffer.byteOffset + buffer.byteLength,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadFonts(): { regular: ArrayBuffer; bold: ArrayBuffer } {
|
|
19
|
+
if (!regularFontData) {
|
|
20
|
+
regularFontData = loadFontFile('NotoSans-Regular.ttf');
|
|
21
|
+
}
|
|
22
|
+
if (!boldFontData) {
|
|
23
|
+
boldFontData = loadFontFile('NotoSans-Bold.ttf');
|
|
24
|
+
}
|
|
25
|
+
return { regular: regularFontData, bold: boldFontData };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const CACHE_CAPACITY = 50;
|
|
29
|
+
const cacheSlotKeys: (string | undefined)[] = new Array(CACHE_CAPACITY);
|
|
30
|
+
const cacheSlotValues: (Buffer | undefined)[] = new Array(CACHE_CAPACITY);
|
|
31
|
+
const cacheKeyToSlot = new Map<string, number>();
|
|
32
|
+
let cachePointer = 0;
|
|
33
|
+
|
|
34
|
+
export async function renderOgImage(
|
|
35
|
+
cacheKey: string,
|
|
36
|
+
template: SatoriNode,
|
|
37
|
+
): Promise<Buffer> {
|
|
38
|
+
const slot = cacheKeyToSlot.get(cacheKey);
|
|
39
|
+
if (slot !== undefined) return cacheSlotValues[slot]!;
|
|
40
|
+
|
|
41
|
+
const fonts = loadFonts();
|
|
42
|
+
|
|
43
|
+
const svg = await satori(template as any, {
|
|
44
|
+
width: OG_WIDTH,
|
|
45
|
+
height: OG_HEIGHT,
|
|
46
|
+
fonts: [
|
|
47
|
+
{
|
|
48
|
+
name: 'Noto Sans',
|
|
49
|
+
data: fonts.regular,
|
|
50
|
+
weight: 400,
|
|
51
|
+
style: 'normal',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'Noto Sans',
|
|
55
|
+
data: fonts.bold,
|
|
56
|
+
weight: 600,
|
|
57
|
+
style: 'normal',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'Noto Sans',
|
|
61
|
+
data: fonts.bold,
|
|
62
|
+
weight: 700,
|
|
63
|
+
style: 'normal',
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const resvg = new Resvg(svg, {
|
|
69
|
+
fitTo: {
|
|
70
|
+
mode: 'width',
|
|
71
|
+
value: OG_WIDTH,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const pngData = resvg.render();
|
|
76
|
+
const pngBuffer = Buffer.from(pngData.asPng());
|
|
77
|
+
|
|
78
|
+
// Evict oldest entry when the circular buffer is full
|
|
79
|
+
const evictedKey = cacheSlotKeys[cachePointer];
|
|
80
|
+
if (evictedKey !== undefined) {
|
|
81
|
+
cacheKeyToSlot.delete(evictedKey);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
cacheSlotKeys[cachePointer] = cacheKey;
|
|
85
|
+
cacheSlotValues[cachePointer] = pngBuffer;
|
|
86
|
+
cacheKeyToSlot.set(cacheKey, cachePointer);
|
|
87
|
+
cachePointer = (cachePointer + 1) % CACHE_CAPACITY;
|
|
88
|
+
|
|
89
|
+
return pngBuffer;
|
|
90
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
export type SatoriNode =
|
|
2
|
+
| string
|
|
3
|
+
| number
|
|
4
|
+
| {
|
|
5
|
+
type: string;
|
|
6
|
+
props: {
|
|
7
|
+
style?: Record<string, unknown>;
|
|
8
|
+
children?: SatoriNode | SatoriNode[];
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const OG_WIDTH = 1200;
|
|
14
|
+
export const OG_HEIGHT = 630;
|
|
15
|
+
export const DIM_COLOR = '#555555';
|
|
16
|
+
|
|
17
|
+
export function svgToDataUri(svg: string, fill?: string): string {
|
|
18
|
+
let processed = svg;
|
|
19
|
+
if (fill) {
|
|
20
|
+
processed = processed.replace(/<path /g, `<path fill="${fill}" `);
|
|
21
|
+
}
|
|
22
|
+
return 'data:image/svg+xml,' + encodeURIComponent(processed);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mixColors(hex1: string, hex2: string, ratio: number): string {
|
|
26
|
+
const r1 = parseInt(hex1.slice(1, 3), 16);
|
|
27
|
+
const g1 = parseInt(hex1.slice(3, 5), 16);
|
|
28
|
+
const b1 = parseInt(hex1.slice(5, 7), 16);
|
|
29
|
+
const r2 = parseInt(hex2.slice(1, 3), 16);
|
|
30
|
+
const g2 = parseInt(hex2.slice(3, 5), 16);
|
|
31
|
+
const b2 = parseInt(hex2.slice(5, 7), 16);
|
|
32
|
+
const r = Math.round(r1 * ratio + r2 * (1 - ratio));
|
|
33
|
+
const g = Math.round(g1 * ratio + g2 * (1 - ratio));
|
|
34
|
+
const b = Math.round(b1 * ratio + b2 * (1 - ratio));
|
|
35
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mixWithWhite(hex: string, ratio: number): string {
|
|
39
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
40
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
41
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
42
|
+
const mr = Math.round(r + (255 - r) * (1 - ratio));
|
|
43
|
+
const mg = Math.round(g + (255 - g) * (1 - ratio));
|
|
44
|
+
const mb = Math.round(b + (255 - b) * (1 - ratio));
|
|
45
|
+
return `rgb(${mr}, ${mg}, ${mb})`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function truncate(text: string, maxLen: number): string {
|
|
49
|
+
if (text.length <= maxLen) return text;
|
|
50
|
+
return text.slice(0, maxLen - 1).trimEnd() + '\u2026';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ogBrandGradient(brandColor: string): string {
|
|
54
|
+
const brandMixed = mixWithWhite(brandColor, 0.5);
|
|
55
|
+
return `linear-gradient(to bottom right, ${brandMixed}, #ffffff)`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ogTitleColor(brandColor: string): string {
|
|
59
|
+
return mixColors(brandColor, '#1a1a1a', 0.6);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ogRootContainer(
|
|
63
|
+
brandColor: string,
|
|
64
|
+
children: SatoriNode[],
|
|
65
|
+
): SatoriNode {
|
|
66
|
+
return {
|
|
67
|
+
type: 'div',
|
|
68
|
+
props: {
|
|
69
|
+
style: {
|
|
70
|
+
display: 'flex',
|
|
71
|
+
flexDirection: 'column' as const,
|
|
72
|
+
width: OG_WIDTH,
|
|
73
|
+
height: OG_HEIGHT,
|
|
74
|
+
background: ogBrandGradient(brandColor),
|
|
75
|
+
fontFamily: 'Noto Sans',
|
|
76
|
+
position: 'relative' as const,
|
|
77
|
+
},
|
|
78
|
+
children,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ogTopRow(params: {
|
|
84
|
+
logotypeDataUri?: string;
|
|
85
|
+
siteName?: string;
|
|
86
|
+
formatText?: (text: string) => string;
|
|
87
|
+
paddingRight?: number;
|
|
88
|
+
}): SatoriNode | undefined {
|
|
89
|
+
const fmt = params.formatText ?? ((t: string) => t);
|
|
90
|
+
const items: SatoriNode[] = [];
|
|
91
|
+
|
|
92
|
+
if (params.logotypeDataUri) {
|
|
93
|
+
items.push({
|
|
94
|
+
type: 'div',
|
|
95
|
+
props: {
|
|
96
|
+
style: {
|
|
97
|
+
display: 'flex',
|
|
98
|
+
alignItems: 'center',
|
|
99
|
+
justifyContent: 'center',
|
|
100
|
+
width: 64,
|
|
101
|
+
height: 64,
|
|
102
|
+
borderRadius: '50%',
|
|
103
|
+
backgroundColor: 'white',
|
|
104
|
+
border: '2px solid white',
|
|
105
|
+
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
|
106
|
+
overflow: 'hidden',
|
|
107
|
+
},
|
|
108
|
+
children: [
|
|
109
|
+
{
|
|
110
|
+
type: 'img',
|
|
111
|
+
props: {
|
|
112
|
+
src: params.logotypeDataUri,
|
|
113
|
+
width: 54,
|
|
114
|
+
height: 54,
|
|
115
|
+
style: { objectFit: 'contain' as const },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (params.siteName) {
|
|
124
|
+
items.push({
|
|
125
|
+
type: 'span',
|
|
126
|
+
props: {
|
|
127
|
+
style: {
|
|
128
|
+
fontSize: 36,
|
|
129
|
+
color: DIM_COLOR,
|
|
130
|
+
fontWeight: 600,
|
|
131
|
+
},
|
|
132
|
+
children: fmt(params.siteName),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (items.length === 0) return undefined;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
type: 'div',
|
|
141
|
+
props: {
|
|
142
|
+
style: {
|
|
143
|
+
display: 'flex',
|
|
144
|
+
alignItems: 'center',
|
|
145
|
+
gap: 14,
|
|
146
|
+
paddingLeft: 56,
|
|
147
|
+
paddingRight: params.paddingRight ?? 60,
|
|
148
|
+
paddingTop: 48,
|
|
149
|
+
},
|
|
150
|
+
children: items,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function ogDescription(params: {
|
|
156
|
+
description: string;
|
|
157
|
+
formatText?: (text: string) => string;
|
|
158
|
+
maxLen?: number;
|
|
159
|
+
paddingRight?: number;
|
|
160
|
+
}): SatoriNode {
|
|
161
|
+
const fmt = params.formatText ?? ((t: string) => t);
|
|
162
|
+
const trimmed = truncate(params.description, params.maxLen ?? 360);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
type: 'div',
|
|
166
|
+
props: {
|
|
167
|
+
style: {
|
|
168
|
+
display: 'flex',
|
|
169
|
+
paddingLeft: 60,
|
|
170
|
+
paddingRight: params.paddingRight ?? 60,
|
|
171
|
+
paddingBottom: 48,
|
|
172
|
+
},
|
|
173
|
+
children: [
|
|
174
|
+
{
|
|
175
|
+
type: 'div',
|
|
176
|
+
props: {
|
|
177
|
+
style: {
|
|
178
|
+
fontSize: 24,
|
|
179
|
+
fontWeight: 400,
|
|
180
|
+
color: DIM_COLOR,
|
|
181
|
+
textAlign: 'left' as const,
|
|
182
|
+
lineHeight: 1.4,
|
|
183
|
+
wordBreak: 'break-word' as const,
|
|
184
|
+
},
|
|
185
|
+
children: fmt(trimmed),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const ARROW_RIGHT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>`;
|
|
194
|
+
|
|
195
|
+
export function ogActionButton(brandColor: string, text: string): SatoriNode {
|
|
196
|
+
return {
|
|
197
|
+
type: 'div',
|
|
198
|
+
props: {
|
|
199
|
+
style: {
|
|
200
|
+
display: 'flex',
|
|
201
|
+
alignItems: 'center',
|
|
202
|
+
gap: 10,
|
|
203
|
+
marginTop: 24,
|
|
204
|
+
backgroundColor: brandColor,
|
|
205
|
+
borderRadius: 32,
|
|
206
|
+
paddingLeft: 32,
|
|
207
|
+
paddingRight: 26,
|
|
208
|
+
paddingTop: 14,
|
|
209
|
+
paddingBottom: 14,
|
|
210
|
+
boxShadow: `0 6px 20px rgba(0,0,0,0.25), 0 2px 6px rgba(0,0,0,0.1)`,
|
|
211
|
+
alignSelf: 'flex-start' as const,
|
|
212
|
+
border: '3px solid rgba(255,255,255,0.4)',
|
|
213
|
+
},
|
|
214
|
+
children: [
|
|
215
|
+
{
|
|
216
|
+
type: 'span',
|
|
217
|
+
props: {
|
|
218
|
+
style: {
|
|
219
|
+
fontSize: 24,
|
|
220
|
+
fontWeight: 700,
|
|
221
|
+
color: '#ffffff',
|
|
222
|
+
letterSpacing: 0.5,
|
|
223
|
+
},
|
|
224
|
+
children: text,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
type: 'img',
|
|
229
|
+
props: {
|
|
230
|
+
src: 'data:image/svg+xml,' + encodeURIComponent(ARROW_RIGHT_SVG),
|
|
231
|
+
width: 22,
|
|
232
|
+
height: 22,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function ogBottomSpacer(): SatoriNode {
|
|
241
|
+
return {
|
|
242
|
+
type: 'div',
|
|
243
|
+
props: {
|
|
244
|
+
style: { display: 'flex', paddingTop: 80 },
|
|
245
|
+
children: [],
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function sendOgPng(event: Parameters<typeof setHeader>[0], png: Buffer) {
|
|
251
|
+
setHeader(event, 'Content-Type', 'image/png');
|
|
252
|
+
setHeader(event, 'Cache-Control', 'public, max-age=86400, s-maxage=86400');
|
|
253
|
+
return png;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getOgImageConfig() {
|
|
257
|
+
return ERUDIT.config.public.seo?.ogImage;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function checkOgEnabled() {
|
|
261
|
+
const ogImageConfig = getOgImageConfig();
|
|
262
|
+
if (ogImageConfig?.type !== 'auto') {
|
|
263
|
+
throw createError({
|
|
264
|
+
statusCode: 404,
|
|
265
|
+
statusMessage: 'OG image generation is disabled',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function getOgBrandColor(): string {
|
|
271
|
+
const ogImageConfig = getOgImageConfig();
|
|
272
|
+
if (ogImageConfig?.type === 'auto') {
|
|
273
|
+
return ogImageConfig.siteColor;
|
|
274
|
+
}
|
|
275
|
+
return '#4aa44c';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function getOgSiteName(): string | undefined {
|
|
279
|
+
const ogImageConfig = getOgImageConfig();
|
|
280
|
+
if (ogImageConfig?.type === 'auto') {
|
|
281
|
+
return ogImageConfig.siteName;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function getOgSiteShort(): string | undefined {
|
|
287
|
+
const ogImageConfig = getOgImageConfig();
|
|
288
|
+
if (ogImageConfig?.type === 'auto') {
|
|
289
|
+
return ogImageConfig.siteShort;
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function getOgLearnPhrase(): string {
|
|
295
|
+
const ogImageConfig = getOgImageConfig();
|
|
296
|
+
if (ogImageConfig?.type === 'auto') {
|
|
297
|
+
return typeof ogImageConfig.phrases === 'string'
|
|
298
|
+
? ogImageConfig.phrases
|
|
299
|
+
: ogImageConfig.phrases.learn;
|
|
300
|
+
}
|
|
301
|
+
return 'Learn';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function getOgOpenPhrase(): string {
|
|
305
|
+
const ogImageConfig = getOgImageConfig();
|
|
306
|
+
if (ogImageConfig?.type === 'auto') {
|
|
307
|
+
return typeof ogImageConfig.phrases === 'string'
|
|
308
|
+
? ogImageConfig.phrases
|
|
309
|
+
: ogImageConfig.phrases.open;
|
|
310
|
+
}
|
|
311
|
+
return 'Open';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function getOgLogotypePath(): string | undefined {
|
|
315
|
+
const ogImageConfig = getOgImageConfig();
|
|
316
|
+
if (ogImageConfig?.type === 'auto') {
|
|
317
|
+
return ogImageConfig.logotype;
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SatoriNode,
|
|
3
|
+
DIM_COLOR,
|
|
4
|
+
svgToDataUri,
|
|
5
|
+
truncate,
|
|
6
|
+
ogTitleColor,
|
|
7
|
+
ogRootContainer,
|
|
8
|
+
ogTopRow,
|
|
9
|
+
ogDescription,
|
|
10
|
+
ogBottomSpacer,
|
|
11
|
+
ogActionButton,
|
|
12
|
+
} from '../shared';
|
|
13
|
+
|
|
14
|
+
export interface ContentOgParams {
|
|
15
|
+
title: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
contentLabel: string;
|
|
18
|
+
contentIconSvg: string;
|
|
19
|
+
bookTitle?: string;
|
|
20
|
+
bookIconSvg?: string;
|
|
21
|
+
isBook: boolean;
|
|
22
|
+
decorationDataUri?: string;
|
|
23
|
+
logotypeDataUri?: string;
|
|
24
|
+
siteName?: string;
|
|
25
|
+
brandColor: string;
|
|
26
|
+
formatText?: (text: string) => string;
|
|
27
|
+
learnButtonText?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildContentOgTemplate(params: ContentOgParams): SatoriNode {
|
|
31
|
+
const titleColor = ogTitleColor(params.brandColor);
|
|
32
|
+
const fmt = params.formatText ?? ((t: string) => t);
|
|
33
|
+
|
|
34
|
+
const children: SatoriNode[] = [];
|
|
35
|
+
|
|
36
|
+
// Top-right: decoration image
|
|
37
|
+
if (params.decorationDataUri) {
|
|
38
|
+
children.push({
|
|
39
|
+
type: 'div',
|
|
40
|
+
props: {
|
|
41
|
+
style: {
|
|
42
|
+
display: 'flex',
|
|
43
|
+
position: 'absolute',
|
|
44
|
+
top: 24,
|
|
45
|
+
right: 32,
|
|
46
|
+
},
|
|
47
|
+
children: [
|
|
48
|
+
{
|
|
49
|
+
type: 'img',
|
|
50
|
+
props: {
|
|
51
|
+
src: params.decorationDataUri,
|
|
52
|
+
width: 180,
|
|
53
|
+
height: 180,
|
|
54
|
+
style: { objectFit: 'contain' as const, opacity: 0.6 },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Top row: site logo + name
|
|
63
|
+
const top = ogTopRow({
|
|
64
|
+
logotypeDataUri: params.logotypeDataUri,
|
|
65
|
+
siteName: params.siteName,
|
|
66
|
+
formatText: params.formatText,
|
|
67
|
+
paddingRight: 240,
|
|
68
|
+
});
|
|
69
|
+
if (top) children.push(top);
|
|
70
|
+
|
|
71
|
+
// Center section: content info + title + learn button
|
|
72
|
+
const centerContent: SatoriNode[] = [];
|
|
73
|
+
|
|
74
|
+
// Info row: book icon+title / separator / content icon+label
|
|
75
|
+
const infoRow: SatoriNode[] = [];
|
|
76
|
+
|
|
77
|
+
if (params.bookTitle && params.bookIconSvg && !params.isBook) {
|
|
78
|
+
const bookTitleTruncated = truncate(params.bookTitle, 40);
|
|
79
|
+
infoRow.push({
|
|
80
|
+
type: 'img',
|
|
81
|
+
props: {
|
|
82
|
+
src: svgToDataUri(params.bookIconSvg, DIM_COLOR),
|
|
83
|
+
width: 26,
|
|
84
|
+
height: 26,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
infoRow.push({
|
|
88
|
+
type: 'span',
|
|
89
|
+
props: {
|
|
90
|
+
style: {
|
|
91
|
+
fontSize: 24,
|
|
92
|
+
color: DIM_COLOR,
|
|
93
|
+
fontWeight: 600,
|
|
94
|
+
maxWidth: 500,
|
|
95
|
+
overflow: 'hidden',
|
|
96
|
+
whiteSpace: 'nowrap' as const,
|
|
97
|
+
},
|
|
98
|
+
children: fmt(bookTitleTruncated),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
infoRow.push({
|
|
102
|
+
type: 'span',
|
|
103
|
+
props: {
|
|
104
|
+
style: {
|
|
105
|
+
fontSize: 24,
|
|
106
|
+
color: DIM_COLOR,
|
|
107
|
+
fontWeight: 400,
|
|
108
|
+
marginLeft: 6,
|
|
109
|
+
marginRight: 6,
|
|
110
|
+
},
|
|
111
|
+
children: '/',
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
infoRow.push({
|
|
117
|
+
type: 'img',
|
|
118
|
+
props: {
|
|
119
|
+
src: svgToDataUri(params.contentIconSvg, DIM_COLOR),
|
|
120
|
+
width: 24,
|
|
121
|
+
height: 24,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
infoRow.push({
|
|
125
|
+
type: 'span',
|
|
126
|
+
props: {
|
|
127
|
+
style: {
|
|
128
|
+
fontSize: 24,
|
|
129
|
+
color: DIM_COLOR,
|
|
130
|
+
fontWeight: 600,
|
|
131
|
+
},
|
|
132
|
+
children: fmt(params.contentLabel),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
centerContent.push({
|
|
137
|
+
type: 'div',
|
|
138
|
+
props: {
|
|
139
|
+
style: { display: 'flex', alignItems: 'center', gap: 8 },
|
|
140
|
+
children: infoRow,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Content title
|
|
145
|
+
const titleTruncated = truncate(params.title, 80);
|
|
146
|
+
centerContent.push({
|
|
147
|
+
type: 'div',
|
|
148
|
+
props: {
|
|
149
|
+
style: {
|
|
150
|
+
fontSize: 52,
|
|
151
|
+
fontWeight: 700,
|
|
152
|
+
color: titleColor,
|
|
153
|
+
textAlign: 'left' as const,
|
|
154
|
+
lineHeight: 1.25,
|
|
155
|
+
maxHeight: 200,
|
|
156
|
+
overflow: 'hidden',
|
|
157
|
+
wordBreak: 'break-word' as const,
|
|
158
|
+
marginTop: 12,
|
|
159
|
+
},
|
|
160
|
+
children: fmt(titleTruncated),
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Learn button
|
|
165
|
+
if (params.learnButtonText) {
|
|
166
|
+
centerContent.push(
|
|
167
|
+
ogActionButton(params.brandColor, params.learnButtonText),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
children.push({
|
|
172
|
+
type: 'div',
|
|
173
|
+
props: {
|
|
174
|
+
style: {
|
|
175
|
+
display: 'flex',
|
|
176
|
+
flexDirection: 'column' as const,
|
|
177
|
+
justifyContent: 'center',
|
|
178
|
+
flex: 1,
|
|
179
|
+
paddingLeft: 60,
|
|
180
|
+
paddingRight: 240,
|
|
181
|
+
},
|
|
182
|
+
children: centerContent,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Bottom: description or spacer
|
|
187
|
+
if (params.description) {
|
|
188
|
+
children.push(
|
|
189
|
+
ogDescription({
|
|
190
|
+
description: params.description,
|
|
191
|
+
formatText: params.formatText,
|
|
192
|
+
paddingRight: 240,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
} else {
|
|
196
|
+
children.push(ogBottomSpacer());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return ogRootContainer(params.brandColor, children);
|
|
200
|
+
}
|