Skip to content

Commit 39aeb7b

Browse files
committed
feat(seo): generate content social previews
1 parent 92534ef commit 39aeb7b

14 files changed

Lines changed: 819 additions & 57 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"build": "next build",
1010
"dev": "next dev --turbo",
1111
"start": "next start",
12+
"og:audit": "node scripts/og-audit.mjs",
1213
"postinstall": "node scripts/generate-openapi.mjs && fumadocs-mdx && pnpm format",
1314
"lint": "biome check",
1415
"format": "biome format --write",

scripts/og-audit.mjs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { readdir, readFile } from "node:fs/promises";
2+
import { extname, join, relative, sep } from "node:path";
3+
4+
const BASE_URL = "https://usememos.com";
5+
const CONTENT_DIR = join(process.cwd(), "content");
6+
7+
async function listMdxFiles(dir) {
8+
const entries = await readdir(dir, { withFileTypes: true });
9+
const files = await Promise.all(
10+
entries.map(async (entry) => {
11+
const path = join(dir, entry.name);
12+
if (entry.isDirectory()) {
13+
return listMdxFiles(path);
14+
}
15+
16+
return extname(entry.name) === ".mdx" ? [path] : [];
17+
}),
18+
);
19+
20+
return files.flat();
21+
}
22+
23+
function parseFrontmatter(source) {
24+
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
25+
if (!match) {
26+
return {};
27+
}
28+
29+
const data = {};
30+
const lines = match[1].split(/\r?\n/);
31+
for (const line of lines) {
32+
const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
33+
if (!field) {
34+
continue;
35+
}
36+
37+
const [, key, rawValue] = field;
38+
data[key] = rawValue.replace(/^["']|["']$/g, "");
39+
}
40+
41+
return data;
42+
}
43+
44+
function toSlug(file, collection) {
45+
const collectionDir = join(CONTENT_DIR, collection);
46+
const path = relative(collectionDir, file)
47+
.replaceAll(sep, "/")
48+
.replace(/\.mdx$/, "");
49+
return path.endsWith("/index") ? path.slice(0, -"/index".length) : path === "index" ? "" : path;
50+
}
51+
52+
function toUrl(collection, slug) {
53+
const basePath = collection === "docs" ? "/docs" : `/${collection}`;
54+
return `${BASE_URL}${slug ? `${basePath}/${slug}` : basePath}`;
55+
}
56+
57+
async function buildPreviews() {
58+
const docs = (await listMdxFiles(join(CONTENT_DIR, "docs"))).map((file) => {
59+
const slug = toSlug(file, "docs");
60+
return {
61+
section: "Docs",
62+
kind: "generated",
63+
url: toUrl("docs", slug),
64+
};
65+
});
66+
67+
const blogIndex = {
68+
section: "Blog",
69+
kind: "generated",
70+
url: `${BASE_URL}/blog`,
71+
};
72+
73+
const blog = await Promise.all(
74+
(await listMdxFiles(join(CONTENT_DIR, "blog"))).map(async (file) => {
75+
const source = await readFile(file, "utf8");
76+
const frontmatter = parseFrontmatter(source);
77+
const slug = toSlug(file, "blog");
78+
79+
return {
80+
section: "Blog",
81+
kind: frontmatter.feature_image ? "explicit" : "generated",
82+
url: toUrl("blog", slug),
83+
};
84+
}),
85+
);
86+
87+
const changelogIndex = {
88+
section: "Changelog",
89+
kind: "generated",
90+
url: `${BASE_URL}/changelog`,
91+
};
92+
93+
const changelog = (await listMdxFiles(join(CONTENT_DIR, "changelog"))).map((file) => {
94+
const slug = toSlug(file, "changelog");
95+
return {
96+
section: "Changelog",
97+
kind: "generated",
98+
url: toUrl("changelog", slug),
99+
};
100+
});
101+
102+
return [...docs, blogIndex, ...blog, changelogIndex, ...changelog].sort((a, b) => a.url.localeCompare(b.url));
103+
}
104+
105+
function printGroup(kind, previews) {
106+
console.log(`\n${kind} (${previews.length})`);
107+
for (const preview of previews) {
108+
console.log(` ${preview.section.padEnd(13)} ${preview.url}`);
109+
}
110+
}
111+
112+
const previews = await buildPreviews();
113+
const groups = {
114+
default: previews.filter((preview) => preview.kind === "default"),
115+
generated: previews.filter((preview) => preview.kind === "generated"),
116+
explicit: previews.filter((preview) => preview.kind === "explicit"),
117+
};
118+
119+
console.log("Social preview audit for docs, blog, and changelog");
120+
printGroup("default", groups.default);
121+
printGroup("generated", groups.generated);
122+
printGroup("explicit", groups.explicit);
123+
124+
if (groups.default.length > 0) {
125+
console.error("\nDefault social previews remain in content routes. Assign explicit or generated images before shipping.");
126+
process.exitCode = 1;
127+
}

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

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { BlogPostHeader } from "@/components/blog-post-header";
88
import { BlogPostHeroImage } from "@/components/blog-post-hero-image";
99
import { Breadcrumbs } from "@/components/breadcrumbs";
1010
import { Footer } from "@/components/footer";
11-
import { getAbsoluteBlogImageUrl } from "@/lib/blog";
12-
import { buildBreadcrumbJsonLd, DEFAULT_OG_IMAGE } from "@/lib/seo";
11+
import { buildBreadcrumbJsonLd } from "@/lib/seo";
12+
import { getBlogSocialPreview, getOpenGraphImages, getTwitterImages } from "@/lib/social-preview";
1313
import { blogSource } from "@/lib/source";
1414

1515
interface BlogPageProps {
@@ -35,13 +35,14 @@ export default async function BlogPostPage({ params }: BlogPageProps) {
3535
{ href: `/blog/${slug}`, name: data.title },
3636
];
3737
const breadcrumbJsonLd = buildBreadcrumbJsonLd(breadcrumbItems);
38+
const preview = getBlogSocialPreview(page);
3839

3940
const jsonLd = {
4041
"@context": "https://schema.org",
4142
"@type": "BlogPosting",
4243
headline: data.title,
4344
description: data.description,
44-
image: getAbsoluteBlogImageUrl(data.feature_image) ?? DEFAULT_OG_IMAGE,
45+
image: preview.imageUrl,
4546
datePublished: data.published_at,
4647
author: {
4748
"@type": "Organization",
@@ -107,29 +108,27 @@ export async function generateMetadata({ params }: BlogPageProps): Promise<Metad
107108
}
108109

109110
const { data } = page;
110-
const pageUrl = `https://usememos.com/blog/${slug}`;
111-
const absoluteImageUrl = getAbsoluteBlogImageUrl(data.feature_image);
112-
const imageUrl = absoluteImageUrl ?? DEFAULT_OG_IMAGE;
111+
const preview = getBlogSocialPreview(page);
113112

114113
return {
115114
title: data.title,
116115
description: data.description,
117116
alternates: {
118-
canonical: pageUrl,
117+
canonical: preview.url,
119118
},
120119
openGraph: {
121-
title: data.title,
122-
description: data.description,
120+
title: preview.title,
121+
description: preview.description,
123122
type: "article",
124123
publishedTime: data.published_at,
125-
url: pageUrl,
126-
images: [imageUrl],
124+
url: preview.url,
125+
images: getOpenGraphImages(preview),
127126
},
128127
twitter: {
129128
card: "summary_large_image",
130-
title: data.title,
131-
description: data.description,
132-
images: [imageUrl],
129+
title: preview.title,
130+
description: preview.description,
131+
images: getTwitterImages(preview),
133132
},
134133
};
135134
}

src/app/blog/page.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,36 @@ import { BlogListItem } from "@/components/blog-list-item";
66
import { Breadcrumbs } from "@/components/breadcrumbs";
77
import { Footer } from "@/components/footer";
88
import { BLOG_COLUMN_CLASS } from "@/lib/blog";
9-
import { buildBreadcrumbJsonLd, buildMarketingMetadata } from "@/lib/seo";
9+
import { buildBreadcrumbJsonLd } from "@/lib/seo";
10+
import { getBlogIndexSocialPreview, getOpenGraphImages, getTwitterImages } from "@/lib/social-preview";
1011
import { blogSource } from "@/lib/source";
1112

1213
export const dynamic = "force-static";
1314
export const revalidate = 1800;
1415

15-
export const metadata: Metadata = buildMarketingMetadata({
16-
title: "Blog",
17-
description: "Insights, updates, and stories from the team building Memos, the open-source note-taking tool for instant capture.",
18-
path: "/blog",
19-
});
16+
const socialPreview = getBlogIndexSocialPreview();
17+
18+
export const metadata: Metadata = {
19+
title: socialPreview.title,
20+
description: socialPreview.description,
21+
alternates: {
22+
canonical: socialPreview.url,
23+
},
24+
openGraph: {
25+
title: `${socialPreview.title} - Memos`,
26+
description: socialPreview.description,
27+
type: "website",
28+
url: socialPreview.url,
29+
siteName: "Memos",
30+
images: getOpenGraphImages(socialPreview),
31+
},
32+
twitter: {
33+
card: "summary_large_image",
34+
title: `${socialPreview.title} - Memos`,
35+
description: socialPreview.description,
36+
images: getTwitterImages(socialPreview),
37+
},
38+
};
2039

2140
const breadcrumbItems = [
2241
{ href: "/", name: "Home" },

src/app/changelog/[slug]/page.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
getChangelogVersion,
1919
sortChangelogPages,
2020
} from "@/lib/changelog";
21-
import { buildBreadcrumbJsonLd, buildDefaultOpenGraphImages, DEFAULT_OG_IMAGE } from "@/lib/seo";
21+
import { buildBreadcrumbJsonLd } from "@/lib/seo";
22+
import { getChangelogSocialPreview, getOpenGraphImages, getTwitterImages } from "@/lib/social-preview";
2223
import { changelogSource } from "@/lib/source";
2324

2425
interface ChangelogPageProps {
@@ -133,24 +134,24 @@ export async function generateMetadata({ params }: ChangelogPageProps): Promise<
133134
}
134135
const { data } = page;
135136
const version = getChangelogVersion(data.title);
136-
const pageUrl = `https://usememos.com/changelog/${slug}`;
137+
const preview = getChangelogSocialPreview(page);
137138
return {
138139
title: `${version} Release Notes`,
139-
description: getChangelogDescription(version, data.description),
140-
alternates: { canonical: pageUrl },
140+
description: preview.description,
141+
alternates: { canonical: preview.url },
141142
openGraph: {
142-
title: `${version} Release Notes`,
143-
description: getChangelogDescription(version, data.description),
143+
title: preview.title,
144+
description: preview.description,
144145
type: "article",
145146
publishedTime: data.date,
146-
url: pageUrl,
147-
images: buildDefaultOpenGraphImages(`${version} Release Notes - Memos`),
147+
url: preview.url,
148+
images: getOpenGraphImages(preview),
148149
},
149150
twitter: {
150151
card: "summary_large_image",
151-
title: `${version} Release Notes`,
152-
description: getChangelogDescription(version, data.description),
153-
images: [DEFAULT_OG_IMAGE],
152+
title: preview.title,
153+
description: preview.description,
154+
images: getTwitterImages(preview),
154155
},
155156
};
156157
}

src/app/changelog/page.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,36 @@ import { Breadcrumbs } from "@/components/breadcrumbs";
66
import { ChangelogListItem } from "@/components/changelog-list-item";
77
import { Footer } from "@/components/footer";
88
import { CHANGELOG_COLUMN_CLASS, getChangelogDescription, getChangelogVersion, sortChangelogPages } from "@/lib/changelog";
9-
import { buildBreadcrumbJsonLd, buildMarketingMetadata } from "@/lib/seo";
9+
import { buildBreadcrumbJsonLd } from "@/lib/seo";
10+
import { getChangelogIndexSocialPreview, getOpenGraphImages, getTwitterImages } from "@/lib/social-preview";
1011
import { changelogSource } from "@/lib/source";
1112

1213
export const dynamic = "force-static";
1314
export const revalidate = 1800;
1415

15-
export const metadata: Metadata = buildMarketingMetadata({
16-
title: "Changelog",
17-
description: "Stay up to date with new features, improvements, and bug fixes in Memos.",
18-
path: "/changelog",
19-
});
16+
const socialPreview = getChangelogIndexSocialPreview();
17+
18+
export const metadata: Metadata = {
19+
title: socialPreview.title,
20+
description: socialPreview.description,
21+
alternates: {
22+
canonical: socialPreview.url,
23+
},
24+
openGraph: {
25+
title: `${socialPreview.title} - Memos`,
26+
description: socialPreview.description,
27+
type: "website",
28+
url: socialPreview.url,
29+
siteName: "Memos",
30+
images: getOpenGraphImages(socialPreview),
31+
},
32+
twitter: {
33+
card: "summary_large_image",
34+
title: `${socialPreview.title} - Memos`,
35+
description: socialPreview.description,
36+
images: getTwitterImages(socialPreview),
37+
},
38+
};
2039

2140
const breadcrumbItems = [
2241
{ href: "/", name: "Home" },

src/app/docs/[...slug]/page.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { notFound, redirect } from "next/navigation";
55
import { AdsSectionMobile } from "@/components/ads-section";
66
import { Breadcrumbs } from "@/components/breadcrumbs";
77
import { getApiDocsVersionFromSlug, getApiDocsVersionLabel, normalizeApiDocsSlug } from "@/lib/api-docs";
8-
import { buildBreadcrumbJsonLd, buildDefaultOpenGraphImages, DEFAULT_OG_IMAGE } from "@/lib/seo";
8+
import { buildBreadcrumbJsonLd } from "@/lib/seo";
9+
import { getDocsSocialPreview, getOpenGraphImages, getTwitterImages } from "@/lib/social-preview";
910
import { source } from "@/lib/source";
1011
import { tocConfig } from "@/lib/toc-config";
1112
import { getMDXComponents } from "@/mdx-components";
@@ -112,25 +113,25 @@ export async function generateMetadata(props: { params: Promise<{ slug: string[]
112113
const page = source.getPage(normalizedSlug);
113114
if (!page) notFound();
114115

115-
const isApi = page.url.startsWith("/docs/api");
116+
const preview = getDocsSocialPreview(page);
116117
return {
117118
title: page.data.title,
118119
description: page.data.description,
119120
alternates: {
120-
canonical: `https://usememos.com${page.url}`,
121+
canonical: preview.url,
121122
},
122123
openGraph: {
123-
title: isApi ? `${page.data.title} - Memos API Reference` : page.data.title,
124-
description: page.data.description,
124+
title: preview.title,
125+
description: preview.description,
125126
type: "article",
126-
url: `https://usememos.com${page.url}`,
127-
images: buildDefaultOpenGraphImages(page.data.title),
127+
url: preview.url,
128+
images: getOpenGraphImages(preview),
128129
},
129130
twitter: {
130131
card: "summary_large_image",
131-
title: isApi ? `${page.data.title} - Memos API Reference` : page.data.title,
132-
description: page.data.description,
133-
images: [DEFAULT_OG_IMAGE],
132+
title: preview.title,
133+
description: preview.description,
134+
images: getTwitterImages(preview),
134135
},
135136
};
136137
}

0 commit comments

Comments
 (0)