Skip to content

Commit 993af5b

Browse files
author
Miriad
committed
fix: rewrite OG templates as single-line HTML with composable blocks
Completely rewrote og-utils.ts HTML generation: - All HTML built as single-line strings with ZERO whitespace between tags - Extracted reusable blocks: logo, badge(), authorBlock(), urlBlock() - No template literal formatting (no newlines/indentation in output) - Removed minifyHtml() — not needed when HTML is built correctly Also added ?mode=dump to default.png for HTML inspection. Build: 18.84s
1 parent fb8babb commit 993af5b

File tree

2 files changed

+55
-191
lines changed

2 files changed

+55
-191
lines changed

apps/web/src/lib/og-utils.ts

Lines changed: 30 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
*
44
* Extracts brand tokens, type colors, font loading, and HTML generation
55
* into a single module so all OG endpoints stay DRY.
6+
*
7+
* IMPORTANT: Satori (via workers-og's HTMLRewriter parser) is extremely
8+
* strict about HTML structure:
9+
* - Every element with multiple children MUST have display:flex
10+
* - No HTML comments
11+
* - No whitespace between elements (parsed as text nodes)
12+
* - No mixed text + element content (wrap text in <span>)
13+
* All HTML must be fully minified — no newlines, no indentation.
614
*/
715

816
// @ts-ignore — loaded by rawFonts vite plugin as Uint8Array
@@ -65,17 +73,6 @@ export function loadFonts() {
6573
];
6674
}
6775

68-
// ── Minify HTML for Satori ───────────────────────────────────────────
69-
// Satori's HTML parser (via HTMLRewriter) treats whitespace between
70-
// elements as text nodes. Template literal formatting creates phantom
71-
// text nodes that violate Satori's strict "display: flex" requirement.
72-
function minifyHtml(html: string): string {
73-
return html
74-
.replace(/>\s+</g, "><") // Remove whitespace between tags
75-
.replace(/\s+/g, " ") // Collapse all remaining whitespace (including inside style attrs)
76-
.trim();
77-
}
78-
7976
// ── Adaptive title font size ─────────────────────────────────────────
8077
export function titleFontSize(title: string): number {
8178
if (title.length > 60) return 42;
@@ -94,6 +91,8 @@ function authorInitials(author: string): string {
9491
}
9592

9693
// ── HTML generators ──────────────────────────────────────────────────
94+
// All HTML is built as single-line strings with NO whitespace between tags.
95+
// This prevents HTMLRewriter from creating phantom text nodes.
9796

9897
export interface OgHtmlOptions {
9998
title: string;
@@ -103,126 +102,39 @@ export interface OgHtmlOptions {
103102
episodeNumber?: string;
104103
}
105104

105+
// Shared building blocks
106+
const logo = `<div style="display:flex;align-items:center;gap:12px"><div style="font-size:40px">🐱</div><div style="display:flex;font-size:24px;font-weight:700;color:${BRAND.text}"><span>CodingCat</span><span style="color:${BRAND.primary}">.dev</span></div></div>`;
107+
108+
function badge(type: string, typeColor: string, episodeNumber?: string): string {
109+
const ep = episodeNumber ? `<div style="display:flex;font-size:14px;font-weight:600;color:${typeColor};margin-top:4px">Episode ${episodeNumber}</div>` : "";
110+
return `<div style="display:flex;flex-direction:column;align-items:flex-end"><div style="display:flex;padding:6px 16px;border-radius:6px;font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:${typeColor};background:${typeColor}26;border:1px solid ${typeColor}40">${type}</div>${ep}</div>`;
111+
}
112+
113+
function authorBlock(author: string): string {
114+
return `<div style="display:flex;align-items:center;gap:12px"><div style="display:flex;width:44px;height:44px;background:linear-gradient(135deg,${BRAND.primary},${BRAND.primaryLight});border-radius:50%;align-items:center;justify-content:center;font-weight:700;font-size:16px;color:white">${authorInitials(author)}</div><div style="font-size:18px;font-weight:500;color:${BRAND.textSecondary}">${author}</div></div>`;
115+
}
116+
117+
function urlBlock(): string {
118+
return `<div style="font-size:16px;color:${BRAND.textTertiary}">codingcat.dev</div>`;
119+
}
120+
106121
/**
107122
* Generates the full-featured OG HTML (blog, podcast, course).
108-
* Includes logo, type badge, title, author avatar, and optional episode number.
109123
*/
110124
export function generateOgHtml({
111125
title,
112126
author = "CodingCat.dev",
113127
type = "Blog",
114-
subtitle,
115128
episodeNumber,
116129
}: OgHtmlOptions): string {
117130
const typeColor = TYPE_COLORS[type] || BRAND.typeBlog;
118131
const fontSize = titleFontSize(title);
119132

120-
const episodeLine = episodeNumber
121-
? `<div style="
122-
font-size: 14px;
123-
font-weight: 600;
124-
color: ${typeColor};
125-
margin-top: 4px;
126-
">Episode ${episodeNumber}</div>`
127-
: "";
128-
129-
const badgeBlock = `
130-
<div style="display: flex; flex-direction: column; align-items: flex-end;">
131-
<div style="
132-
display: flex;
133-
padding: 6px 16px;
134-
border-radius: 6px;
135-
font-size: 14px;
136-
font-weight: 600;
137-
text-transform: uppercase;
138-
letter-spacing: 0.05em;
139-
color: ${typeColor};
140-
background: ${typeColor}26;
141-
border: 1px solid ${typeColor}40;
142-
">${type}</div>
143-
${episodeLine}
144-
</div>
145-
`;
146-
147-
return minifyHtml(`
148-
<div style="
149-
display: flex;
150-
width: 1200px;
151-
height: 630px;
152-
background: ${BRAND.bgGradient};
153-
padding: 60px;
154-
box-sizing: border-box;
155-
font-family: 'Inter', sans-serif;
156-
">
157-
<div style="
158-
display: flex;
159-
flex-direction: column;
160-
width: 100%;
161-
height: 100%;
162-
justify-content: space-between;
163-
">
164-
<div style="display: flex; align-items: center; justify-content: space-between;">
165-
<div style="display: flex; align-items: center; gap: 12px;">
166-
<div style="font-size: 40px;">🐱</div>
167-
<div style="
168-
font-size: 24px;
169-
font-weight: 700;
170-
color: ${BRAND.text};
171-
display: flex;
172-
"><span>CodingCat</span><span style="color: ${BRAND.primary};">.dev</span></div>
173-
</div>
174-
${badgeBlock}
175-
</div>
176-
177-
<div style="
178-
display: flex;
179-
flex: 1;
180-
align-items: center;
181-
padding: 20px 0;
182-
">
183-
<div style="
184-
font-size: ${fontSize}px;
185-
font-weight: 700;
186-
line-height: 1.15;
187-
color: ${BRAND.text};
188-
max-width: 100%;
189-
overflow: hidden;
190-
">${title}</div>
191-
</div>
192-
193-
<div style="display: flex; align-items: center; justify-content: space-between;">
194-
<div style="display: flex; align-items: center; gap: 12px;">
195-
<div style="
196-
display: flex;
197-
width: 44px;
198-
height: 44px;
199-
background: linear-gradient(135deg, ${BRAND.primary}, ${BRAND.primaryLight});
200-
border-radius: 50%;
201-
align-items: center;
202-
justify-content: center;
203-
font-weight: 700;
204-
font-size: 16px;
205-
color: white;
206-
">${authorInitials(author)}</div>
207-
<div style="
208-
font-size: 18px;
209-
font-weight: 500;
210-
color: ${BRAND.textSecondary};
211-
">${author}</div>
212-
</div>
213-
<div style="
214-
font-size: 16px;
215-
color: ${BRAND.textTertiary};
216-
">codingcat.dev</div>
217-
</div>
218-
</div>
219-
</div>
220-
`);
133+
return `<div style="display:flex;width:1200px;height:630px;background:${BRAND.bgGradient};padding:60px;box-sizing:border-box;font-family:'Inter',sans-serif"><div style="display:flex;flex-direction:column;width:100%;height:100%;justify-content:space-between"><div style="display:flex;align-items:center;justify-content:space-between">${logo}${badge(type, typeColor, episodeNumber)}</div><div style="display:flex;flex:1;align-items:center;padding:20px 0"><div style="font-size:${fontSize}px;font-weight:700;line-height:1.15;color:${BRAND.text};max-width:100%;overflow:hidden">${title}</div></div><div style="display:flex;align-items:center;justify-content:space-between">${authorBlock(author)}${urlBlock()}</div></div></div>`;
221134
}
222135

223136
/**
224137
* Generates a simpler OG HTML for default/generic pages.
225-
* No author avatar, no type badge — just branding + title + optional subtitle.
226138
*/
227139
export function generateDefaultOgHtml({
228140
title,
@@ -232,69 +144,9 @@ export function generateDefaultOgHtml({
232144
subtitle?: string;
233145
}): string {
234146
const fontSize = titleFontSize(title);
235-
236-
const subtitleBlock = subtitle
237-
? `<div style="
238-
font-size: 24px;
239-
font-weight: 400;
240-
color: ${BRAND.textSecondary};
241-
margin-top: 16px;
242-
line-height: 1.4;
243-
">${subtitle}</div>`
147+
const subtitleHtml = subtitle
148+
? `<div style="font-size:24px;font-weight:400;color:${BRAND.textSecondary};margin-top:16px;line-height:1.4">${subtitle}</div>`
244149
: "";
245150

246-
return minifyHtml(`
247-
<div style="
248-
display: flex;
249-
width: 1200px;
250-
height: 630px;
251-
background: ${BRAND.bgGradient};
252-
padding: 60px;
253-
box-sizing: border-box;
254-
font-family: 'Inter', sans-serif;
255-
">
256-
<div style="
257-
display: flex;
258-
flex-direction: column;
259-
width: 100%;
260-
height: 100%;
261-
justify-content: space-between;
262-
">
263-
<div style="display: flex; align-items: center; gap: 12px;">
264-
<div style="font-size: 40px;">🐱</div>
265-
<div style="
266-
font-size: 24px;
267-
font-weight: 700;
268-
color: ${BRAND.text};
269-
display: flex;
270-
"><span>CodingCat</span><span style="color: ${BRAND.primary};">.dev</span></div>
271-
</div>
272-
273-
<div style="
274-
display: flex;
275-
flex-direction: column;
276-
flex: 1;
277-
justify-content: center;
278-
padding: 20px 0;
279-
">
280-
<div style="
281-
font-size: ${fontSize}px;
282-
font-weight: 700;
283-
line-height: 1.15;
284-
color: ${BRAND.text};
285-
max-width: 100%;
286-
overflow: hidden;
287-
">${title}</div>
288-
${subtitleBlock}
289-
</div>
290-
291-
<div style="display: flex; align-items: center; justify-content: flex-end;">
292-
<div style="
293-
font-size: 16px;
294-
color: ${BRAND.textTertiary};
295-
">codingcat.dev</div>
296-
</div>
297-
</div>
298-
</div>
299-
`);
151+
return `<div style="display:flex;width:1200px;height:630px;background:${BRAND.bgGradient};padding:60px;box-sizing:border-box;font-family:'Inter',sans-serif"><div style="display:flex;flex-direction:column;width:100%;height:100%;justify-content:space-between">${logo}<div style="display:flex;flex-direction:column;flex:1;justify-content:center;padding:20px 0"><div style="font-size:${fontSize}px;font-weight:700;line-height:1.15;color:${BRAND.text};max-width:100%;overflow:hidden">${title}</div>${subtitleHtml}</div><div style="display:flex;align-items:center;justify-content:flex-end">${urlBlock()}</div></div></div>`;
300152
}
Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
/**
2-
* Minimal OG image test — single div, no nesting.
3-
* If this fails, the problem is workers-og/fonts/WASM, not templates.
2+
* Default OG image — debug: dump HTML to inspect what Satori receives.
3+
* Also test rendering with the minimal approach for comparison.
44
*/
55
import type { APIRoute } from "astro";
66
import { ImageResponse } from "workers-og";
7-
import { loadFonts } from "../../../lib/og-utils";
7+
import { generateDefaultOgHtml, loadFonts } from "../../../lib/og-utils";
88

99
export const prerender = false;
1010

1111
export const GET: APIRoute = async ({ url }) => {
12-
try {
13-
const html = '<div style="display:flex;width:1200px;height:630px;background:#000;color:#fff;font-size:48px;align-items:center;justify-content:center">Hello World</div>';
12+
const mode = url.searchParams.get("mode") || "render";
13+
const title = url.searchParams.get("title") || "CodingCat.dev";
14+
const subtitle = url.searchParams.get("subtitle") || undefined;
15+
16+
const html = generateDefaultOgHtml({ title, subtitle });
1417

18+
// Mode: dump — return raw HTML for inspection
19+
if (mode === "dump") {
20+
return new Response(html, {
21+
headers: { "Content-Type": "text/plain" },
22+
});
23+
}
24+
25+
// Mode: render — try to generate the image
26+
try {
1527
const response = new ImageResponse(html, {
1628
width: 1200,
1729
height: 630,
@@ -20,10 +32,10 @@ export const GET: APIRoute = async ({ url }) => {
2032

2133
const buffer = await response.arrayBuffer();
2234
if (buffer.byteLength === 0) {
23-
return new Response(JSON.stringify({ error: "Empty buffer", htmlLength: html.length }), {
24-
status: 500,
25-
headers: { "Content-Type": "application/json" },
26-
});
35+
return new Response(
36+
JSON.stringify({ error: "Empty buffer", htmlLength: html.length }),
37+
{ status: 500, headers: { "Content-Type": "application/json" } }
38+
);
2739
}
2840

2941
return new Response(buffer, {
@@ -33,9 +45,9 @@ export const GET: APIRoute = async ({ url }) => {
3345
},
3446
});
3547
} catch (e: any) {
36-
return new Response(JSON.stringify({ error: e.message, stack: e.stack }), {
37-
status: 500,
38-
headers: { "Content-Type": "application/json" },
39-
});
48+
return new Response(
49+
JSON.stringify({ error: e.message, stack: e.stack }),
50+
{ status: 500, headers: { "Content-Type": "application/json" } }
51+
);
4052
}
4153
};

0 commit comments

Comments
 (0)