Skip to content

Commit ddefcce

Browse files
authored
Merge pull request #6 from Hexi1997/codex/fix-issue-4-pinned-icon-inline
Update blog OG image fallback
2 parents f4a09f1 + f62a781 commit ddefcce

8 files changed

Lines changed: 291 additions & 99 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ source: "https://example.com/original-link" # optional external link post
156156
- **title** (required): post title
157157
- **date** (required): publish date, format: YYYY-MM-DD
158158
- **pinned** (optional): whether the post is pinned. Pinned posts are sorted before non-pinned posts; when multiple posts are pinned, they are still ordered by date descending.
159-
- **cover** (optional): cover image path; supports relative paths (for example, `assets/cover.jpg`) or external URLs (for example, `https://example.com/image.jpg`). If missing, the system tries the first image in markdown; if none exists, it falls back to `/default-cover.webp`.
159+
- **cover** (optional): cover image path; supports relative paths (for example, `assets/cover.jpg`) or external URLs (for example, `https://example.com/image.jpg`). If missing, the post page falls back to a dynamically generated OG image that includes the Blogit branding, title, and tags.
160160
- **source** (optional): external article URL. If set, clicking the card in the list redirects directly to this external URL (opens in a new tab), instead of the internal post detail page. The source link is also shown at the bottom of the post detail page. Useful for third-party content references.
161161
- **tags** (optional): post tags. Supports YAML array format, for example `tags: ["nextjs", "react"]` or multiline list format.
162162

README_CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ source: "https://example.com/original-link" # 可选,外链文章
156156
- **title** (必需):文章标题
157157
- **date** (必需):发布日期,格式:YYYY-MM-DD
158158
- **pinned** (可选):是否置顶文章。置顶文章会排在非置顶文章前面;如果有多篇置顶文章,它们之间仍然按日期倒序排列。
159-
- **cover** (可选):封面图片路径,支持相对路径(如 `assets/cover.jpg`)或外部 URL(如 `https://example.com/image.jpg`)。如果未设置,系统会自动提取 markdown 内容中的第一张图片作为封面;如果内容中没有图片,则使用占位图 `/default-cover.webp`
159+
- **cover**(可选):封面图片路径,支持相对路径(如 `assets/cover.jpg`)或外部 URL(如 `https://example.com/image.jpg`)。如果未设置,文章页面会回退到动态生成的 OG 图片,其中包含 Blogit 品牌、文章标题和 tags。
160160
- **source** (可选):外部文章链接。当设置此字段时,点击博客列表中的文章会直接跳转到该外部链接(新标签页打开),而不是内部博客详情页。在博客详情页底部也会显示来源链接。适用于引用第三方内容的场景
161161
- **tags** (可选):文章标签。支持 YAML 数组格式,例如 `tags: ["nextjs", "react"]` 或多行列表写法
162162

apps/blog/app/api/posts/route.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,19 @@ export async function GET() {
1414
slugs.map(async (slug) => {
1515
const post = await getBlogPostBySlug(slug);
1616
if (!post) return null;
17-
18-
const coverIsAbsolute = post.cover.startsWith("http://") || post.cover.startsWith("https://");
19-
20-
// Force production static path (/blog-assets) to avoid /api/blog-assets
21-
const normalizedPath = post.cover.replace("/api/blog-assets", "/blog-assets");
22-
23-
const normalizedCover = coverIsAbsolute
24-
? normalizedPath
25-
: `${baseUrl}${normalizedPath.startsWith("/") ? "" : "/"}${normalizedPath}`;
17+
const normalizedCover = post.cover?.replace(
18+
"/api/blog-assets",
19+
"/blog-assets"
20+
);
2621

2722
return {
2823
...post,
29-
cover: normalizedCover,
24+
cover: normalizedCover
25+
? normalizedCover.startsWith("http://") ||
26+
normalizedCover.startsWith("https://")
27+
? normalizedCover
28+
: `${baseUrl}${normalizedCover.startsWith("/") ? "" : "/"}${normalizedCover}`
29+
: undefined,
3030
};
3131
})
3232
);
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { ImageResponse } from "next/og";
2+
import { notFound } from "next/navigation";
3+
import { getBlogPostBySlug } from "@/lib/blog";
4+
5+
export const runtime = "nodejs";
6+
export const alt = "BLOGIT article preview";
7+
export const size = {
8+
width: 1200,
9+
height: 630,
10+
};
11+
export const contentType = "image/png";
12+
13+
const logoMarkSvg = encodeURIComponent(`
14+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
15+
<g clip-path="url(#clip0_2_2)">
16+
<path d="M0.894141 7.08776L2.05614 5.35076C2.16314 5.19276 2.23114 5.18876 2.34914 5.24476C4.90514 6.72076 7.19714 6.92976 9.56114 5.90676C11.9111 4.88776 13.3241 3.08676 14.0061 0.247756C14.0781 -0.00824395 14.1721 -0.00624393 14.2371 0.00275607L16.2671 0.442756C16.6101 0.528756 16.5971 0.622756 16.5891 0.692756C16.1391 4.02076 13.8341 6.94376 10.5701 8.32476C7.25314 9.75076 3.65014 9.43676 0.930141 7.48476C0.874141 7.43676 0.748141 7.30776 0.898141 7.08776H0.895141H0.894141ZM11.0091 23.8858L13.0901 23.9998C13.4401 24.0058 13.4501 23.9128 13.4591 23.8438C13.9091 20.5158 12.4601 17.0858 9.68014 14.8908C6.86014 12.6348 3.30214 11.9808 0.161141 13.1418C0.0961407 13.1748 -0.0608593 13.2648 0.0251407 13.5148L0.684141 15.4988C0.747141 15.6788 0.809141 15.7008 0.938141 15.6778C3.79314 14.9338 6.05814 15.3388 8.06614 16.9528C10.0621 18.5588 10.9461 20.6688 10.8501 23.5968C10.8531 23.8548 10.9431 23.8778 11.0081 23.8868L11.0091 23.8858ZM12.3441 10.0178C12.2514 10.0047 12.1578 9.99799 12.0641 9.99776C11.6421 9.99776 11.2441 10.1298 10.9181 10.3778C10.7186 10.5325 10.5517 10.7253 10.4272 10.945C10.3028 11.1648 10.2232 11.407 10.1931 11.6578C10.1231 12.1658 10.2531 12.6678 10.5551 13.0688C10.8601 13.4758 11.3141 13.7388 11.8321 13.8088C12.3346 13.8756 12.843 13.7405 13.2461 13.4331C13.6491 13.1257 13.9138 12.671 13.9821 12.1688C14.0187 11.9194 14.005 11.6652 13.942 11.4212C13.8789 11.1771 13.7677 10.9481 13.615 10.7477C13.4622 10.5472 13.2709 10.3793 13.0523 10.2538C12.8337 10.1282 12.5923 10.0477 12.3421 10.0168L12.3441 10.0178ZM22.7361 6.52476L21.7701 4.67476C21.6331 4.37076 21.5231 4.40076 21.4101 4.43876C18.3211 5.77076 16.1901 8.69876 15.7071 12.2768C15.2241 15.8568 16.5041 19.2498 19.1331 21.3518C19.1921 21.4038 19.2501 21.4218 19.3151 21.4168C19.3935 21.4081 19.4654 21.369 19.5151 21.3078L20.8731 19.6798C20.9981 19.5228 20.9441 19.4418 20.8791 19.3738C18.7491 17.2778 17.9531 15.1938 18.2991 12.6258C18.6481 10.0428 20.0091 8.19276 22.5851 6.79876C22.7891 6.67576 22.7551 6.57076 22.7361 6.52276V6.52476Z" fill="black"/>
17+
</g>
18+
<defs>
19+
<clipPath id="clip0_2_2">
20+
<rect width="24" height="24" rx="4" fill="white"/>
21+
</clipPath>
22+
</defs>
23+
</svg>
24+
`);
25+
26+
interface OgImageProps {
27+
params: Promise<{
28+
slug: string;
29+
}>;
30+
}
31+
32+
export default async function OgImage({ params }: OgImageProps) {
33+
const { slug } = await params;
34+
const post = await getBlogPostBySlug(slug);
35+
36+
if (!post) {
37+
notFound();
38+
}
39+
40+
const tags = (post.tags || []).slice(0, 4);
41+
42+
return new ImageResponse(
43+
(
44+
<div
45+
style={{
46+
display: "flex",
47+
height: "100%",
48+
width: "100%",
49+
position: "relative",
50+
overflow: "hidden",
51+
background:
52+
"radial-gradient(circle at top left, #f6efe7 0%, #f3efe8 30%, #ece7de 58%, #ddd6ca 100%)",
53+
color: "#111111",
54+
fontFamily:
55+
'"SF Pro Display", "Geist", "Segoe UI", sans-serif',
56+
}}
57+
>
58+
<div
59+
style={{
60+
position: "absolute",
61+
top: -120,
62+
right: -60,
63+
width: 420,
64+
height: 420,
65+
borderRadius: "9999px",
66+
background:
67+
"radial-gradient(circle, rgba(255,255,255,0.85) 0%, rgba(255,255,255,0) 72%)",
68+
}}
69+
/>
70+
<div
71+
style={{
72+
position: "absolute",
73+
right: 64,
74+
bottom: -110,
75+
width: 300,
76+
height: 300,
77+
borderRadius: "9999px",
78+
border: "1px solid rgba(17,17,17,0.08)",
79+
background: "rgba(255,255,255,0.18)",
80+
}}
81+
/>
82+
<div
83+
style={{
84+
display: "flex",
85+
flexDirection: "column",
86+
justifyContent: "space-between",
87+
width: "100%",
88+
padding: "48px 56px 44px",
89+
zIndex: 1,
90+
}}
91+
>
92+
<div
93+
style={{
94+
display: "flex",
95+
alignItems: "center",
96+
justifyContent: "space-between",
97+
gap: 24,
98+
}}
99+
>
100+
<div
101+
style={{
102+
display: "flex",
103+
alignItems: "center",
104+
gap: 12,
105+
padding: "12px 16px",
106+
borderRadius: 9999,
107+
background: "rgba(255,255,255,0.78)",
108+
border: "1px solid rgba(17,17,17,0.08)",
109+
boxShadow: "0 8px 24px rgba(17,17,17,0.08)",
110+
}}
111+
>
112+
<img
113+
src={`data:image/svg+xml;charset=utf-8,${logoMarkSvg}`}
114+
alt="BLOGIT"
115+
width="38"
116+
height="38"
117+
/>
118+
<div
119+
style={{
120+
display: "flex",
121+
alignItems: "center",
122+
background: "#111111",
123+
color: "#ffffff",
124+
padding: "7px 14px 8px",
125+
borderRadius: 10,
126+
fontSize: 24,
127+
letterSpacing: "0.08em",
128+
fontWeight: 800,
129+
}}
130+
>
131+
BLOGIT
132+
</div>
133+
</div>
134+
<div
135+
style={{
136+
display: "flex",
137+
alignItems: "center",
138+
gap: 10,
139+
color: "rgba(17,17,17,0.48)",
140+
fontSize: 18,
141+
letterSpacing: "0.18em",
142+
textTransform: "uppercase",
143+
}}
144+
>
145+
<div
146+
style={{
147+
width: 56,
148+
height: 1,
149+
background: "rgba(17,17,17,0.18)",
150+
}}
151+
/>
152+
Article Preview
153+
</div>
154+
</div>
155+
156+
<div
157+
style={{
158+
display: "flex",
159+
flexDirection: "column",
160+
gap: 28,
161+
maxWidth: 980,
162+
}}
163+
>
164+
<div
165+
style={{
166+
display: "flex",
167+
flexWrap: "wrap",
168+
gap: 12,
169+
}}
170+
>
171+
{tags.length > 0 ? (
172+
tags.map((tag) => (
173+
<div
174+
key={tag}
175+
style={{
176+
display: "flex",
177+
alignItems: "center",
178+
padding: "10px 16px",
179+
borderRadius: 9999,
180+
background: "rgba(255,255,255,0.72)",
181+
border: "1px solid rgba(17,17,17,0.08)",
182+
fontSize: 22,
183+
color: "rgba(17,17,17,0.74)",
184+
}}
185+
>
186+
#{tag}
187+
</div>
188+
))
189+
) : (
190+
<div
191+
style={{
192+
display: "flex",
193+
alignItems: "center",
194+
padding: "10px 16px",
195+
borderRadius: 9999,
196+
background: "rgba(255,255,255,0.72)",
197+
border: "1px solid rgba(17,17,17,0.08)",
198+
fontSize: 22,
199+
color: "rgba(17,17,17,0.64)",
200+
}}
201+
>
202+
#Blogit
203+
</div>
204+
)}
205+
</div>
206+
207+
<div
208+
style={{
209+
display: "flex",
210+
fontSize: 62,
211+
lineHeight: 1.08,
212+
fontWeight: 800,
213+
letterSpacing: "-0.04em",
214+
}}
215+
>
216+
{post.title}
217+
</div>
218+
</div>
219+
220+
<div
221+
style={{
222+
display: "flex",
223+
alignItems: "center",
224+
justifyContent: "space-between",
225+
color: "rgba(17,17,17,0.5)",
226+
fontSize: 20,
227+
}}
228+
>
229+
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
230+
<div
231+
style={{
232+
width: 10,
233+
height: 10,
234+
borderRadius: "9999px",
235+
background: "#111111",
236+
}}
237+
/>
238+
Built with Blogit
239+
</div>
240+
<div>{new Date(post.date).getFullYear()}</div>
241+
</div>
242+
</div>
243+
</div>
244+
),
245+
size
246+
);
247+
}

apps/blog/app/blog/[slug]/page.tsx

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,26 +36,18 @@ export async function generateMetadata({
3636
};
3737
}
3838

39-
// Get base URL
40-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "";
41-
42-
// Ensure image URL is absolute
43-
// If already absolute (http/https), keep it
44-
// If relative, convert to absolute URL
45-
let imageUrl = post.cover;
46-
if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) {
47-
// Ensure leading slash
48-
if (!imageUrl.startsWith("/")) {
49-
imageUrl = `/${imageUrl}`;
50-
}
51-
// Build full absolute URL
52-
imageUrl = `${baseUrl}${imageUrl}`;
53-
}
54-
55-
// In production, ensure dev API route is not used
56-
if (process.env.NODE_ENV === "production") {
57-
imageUrl = imageUrl.replace("/api/blog-assets", "/blog-assets");
58-
}
39+
const baseUrl = (process.env.NEXT_PUBLIC_SITE_URL || "").replace(/\/$/, "");
40+
const ogImagePath = post.cover || `/blog/${slug}/opengraph-image`;
41+
const normalizedOgImagePath =
42+
process.env.NODE_ENV === "production"
43+
? ogImagePath.replace("/api/blog-assets", "/blog-assets")
44+
: ogImagePath;
45+
const imageUrl =
46+
normalizedOgImagePath.startsWith("http://") ||
47+
normalizedOgImagePath.startsWith("https://") ||
48+
!baseUrl
49+
? normalizedOgImagePath
50+
: `${baseUrl}${normalizedOgImagePath.startsWith("/") ? "" : "/"}${normalizedOgImagePath}`;
5951

6052
return {
6153
title: `${post.title}`,

apps/blog/app/rss.xml/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function getBaseUrl(): string {
2828
return "";
2929
}
3030

31-
function normalizeCover(cover: string, baseUrl: string): string | null {
31+
function normalizeCover(cover: string | undefined, baseUrl: string): string | null {
3232
if (!cover) return null;
3333
const normalizedPath = cover.replace("/api/blog-assets", "/blog-assets");
3434
const isAbsolute =

0 commit comments

Comments
 (0)