diff --git a/apps/blog/app/(en)/essays/[slug]/opengraph-image.tsx b/apps/blog/app/(en)/essays/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..8c64e87
--- /dev/null
+++ b/apps/blog/app/(en)/essays/[slug]/opengraph-image.tsx
@@ -0,0 +1,52 @@
+import { ImageResponse } from 'next/og';
+import { getEssayBySlug, getEssaySlugs } from '@/lib/essays';
+import { OgCard, OG_WIDTH, OG_HEIGHT } from '@/components/seo/og-card';
+import { ogFontsFor } from '@/lib/og-fonts';
+import { TOPIC_LABELS } from '@/lib/constants';
+
+export const dynamic = 'force-static';
+
+export const alt = 'Algo Mind essay';
+export const size = { width: OG_WIDTH, height: OG_HEIGHT };
+export const contentType = 'image/png';
+
+export function generateStaticParams() {
+ return getEssaySlugs().map((slug) => ({ slug }));
+}
+
+interface RouteProps {
+ params: { slug: string };
+}
+
+export default async function OgImage({ params }: RouteProps) {
+ const fonts = ogFontsFor('en');
+ const essay = getEssayBySlug(params.slug);
+ if (!essay || essay.lang !== 'en') {
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+ }
+
+ const kicker = essay.topics[0] ? TOPIC_LABELS[essay.topics[0]] : 'Essay';
+
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+}
diff --git a/apps/blog/app/(en)/essays/[slug]/page.tsx b/apps/blog/app/(en)/essays/[slug]/page.tsx
index e05b5a0..6e221cf 100644
--- a/apps/blog/app/(en)/essays/[slug]/page.tsx
+++ b/apps/blog/app/(en)/essays/[slug]/page.tsx
@@ -5,6 +5,8 @@ import { getEssayBySlug, getEssaySlugs, getTranslation } from '@/lib/essays';
import { getMDXComponents } from '@/components/mdx/MDXComponents';
import { mdxOptions } from '@/lib/mdx-options';
import { EssayLayout, EssayHeader } from '@/components/essay';
+import { JsonLd } from '@/components/seo';
+import { breadcrumbSchema, essayPostingSchema } from '@/lib/jsonld';
interface EssayPageProps {
params: Promise<{ slug: string }>;
@@ -53,6 +55,13 @@ export async function generateMetadata({
alternates.languages!['zh'] = `/zh/essays/${zhTranslation.slug}`;
}
+ // If the essay declares a hand-picked image, override the autogenerated
+ // opengraph-image. Next will then use this URL for both og:image and
+ // twitter:image. Otherwise the file-convention opengraph-image takes over.
+ const ogOverride = essay.image
+ ? { openGraph: { images: [essay.image] }, twitter: { images: [essay.image] } }
+ : {};
+
return {
title: essay.title,
description: essay.description,
@@ -62,6 +71,11 @@ export async function generateMetadata({
type: 'article',
publishedTime: essay.date,
tags: essay.topics,
+ ...ogOverride.openGraph,
+ },
+ twitter: {
+ card: 'summary_large_image',
+ ...ogOverride.twitter,
},
alternates,
};
@@ -150,22 +164,33 @@ export default async function EssayPage({ params }: EssayPageProps) {
const { title, description, date, type, topics, readingTime, content, toc } =
essay;
+ const urlPath = `/essays/${slug}`;
return (
-
- }
- >
-
-
+ <>
+
+
+
+ }
+ >
+
+
+ >
);
}
diff --git a/apps/blog/app/(en)/layout.tsx b/apps/blog/app/(en)/layout.tsx
index 08a2d6d..6c5512f 100644
--- a/apps/blog/app/(en)/layout.tsx
+++ b/apps/blog/app/(en)/layout.tsx
@@ -11,6 +11,8 @@ import {
} from '@/components/layout';
import { LanguageProvider } from '@/lib/i18n';
import { SITE_AUTHOR, SITE_URL } from '@/lib/constants';
+import { JsonLd } from '@/components/seo';
+import { siteGraph } from '@/lib/jsonld';
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
@@ -58,6 +60,13 @@ export default function EnRootLayout({ children }: EnRootLayoutProps) {
return (
+
+
+ ),
+ { ...size, fonts: ogFontsFor('en') },
+ );
+}
diff --git a/apps/blog/app/(en)/periodics/[slug]/opengraph-image.tsx b/apps/blog/app/(en)/periodics/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..ad2ff2e
--- /dev/null
+++ b/apps/blog/app/(en)/periodics/[slug]/opengraph-image.tsx
@@ -0,0 +1,45 @@
+import { ImageResponse } from 'next/og';
+import { getPeriodicBySlug, getPeriodicSlugs } from '@/lib/periodics';
+import { OgCard, OG_WIDTH, OG_HEIGHT } from '@/components/seo/og-card';
+import { ogFontsFor } from '@/lib/og-fonts';
+
+export const dynamic = 'force-static';
+
+export const alt = 'Algo Mind periodic';
+export const size = { width: OG_WIDTH, height: OG_HEIGHT };
+export const contentType = 'image/png';
+
+export function generateStaticParams() {
+ return getPeriodicSlugs().map((slug) => ({ slug }));
+}
+
+export default async function OgImage({ params }: { params: { slug: string } }) {
+ const fonts = ogFontsFor('en');
+ const periodic = getPeriodicBySlug(params.slug);
+ if (!periodic || periodic.lang !== 'en') {
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+ }
+
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+}
diff --git a/apps/blog/app/(en)/periodics/[slug]/page.tsx b/apps/blog/app/(en)/periodics/[slug]/page.tsx
index 8f87cd3..83050f3 100644
--- a/apps/blog/app/(en)/periodics/[slug]/page.tsx
+++ b/apps/blog/app/(en)/periodics/[slug]/page.tsx
@@ -6,6 +6,8 @@ import { getMDXComponents } from '@/components/mdx/MDXComponents';
import { mdxOptions } from '@/lib/mdx-options';
import { EssayLayout } from '@/components/essay';
import { PeriodicHeader } from '@/components/periodic';
+import { JsonLd } from '@/components/seo';
+import { breadcrumbSchema, periodicPostingSchema } from '@/lib/jsonld';
interface PeriodicPageProps {
params: Promise<{ slug: string }>;
@@ -152,23 +154,34 @@ export default async function PeriodicPage({ params }: PeriodicPageProps) {
const { title, description, date, issue, type, topics, readingTime, content, toc } =
periodic;
+ const urlPath = `/periodics/${slug}`;
return (
-
- }
- >
-
-
+ <>
+
+
+
+ }
+ >
+
+
+ >
);
}
diff --git a/apps/blog/app/(en)/series/[slug]/opengraph-image.tsx b/apps/blog/app/(en)/series/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..22336c0
--- /dev/null
+++ b/apps/blog/app/(en)/series/[slug]/opengraph-image.tsx
@@ -0,0 +1,46 @@
+import { ImageResponse } from 'next/og';
+import { getSeriesBySlug, getSeriesSlugs } from '@/lib/series';
+import { OgCard, OG_WIDTH, OG_HEIGHT } from '@/components/seo/og-card';
+import { ogFontsFor } from '@/lib/og-fonts';
+import { SERIES_CATEGORY_LABELS } from '@/lib/constants';
+
+export const dynamic = 'force-static';
+
+export const alt = 'Algo Mind series';
+export const size = { width: OG_WIDTH, height: OG_HEIGHT };
+export const contentType = 'image/png';
+
+export function generateStaticParams() {
+ return getSeriesSlugs().map((slug) => ({ slug }));
+}
+
+export default async function OgImage({ params }: { params: { slug: string } }) {
+ const fonts = ogFontsFor('en');
+ const series = getSeriesBySlug(params.slug);
+ if (!series || series.lang !== 'en') {
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+ }
+
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+}
diff --git a/apps/blog/app/(en)/series/[slug]/page.tsx b/apps/blog/app/(en)/series/[slug]/page.tsx
index 62eb7b6..1e93889 100644
--- a/apps/blog/app/(en)/series/[slug]/page.tsx
+++ b/apps/blog/app/(en)/series/[slug]/page.tsx
@@ -6,6 +6,8 @@ import { getMDXComponents } from '@/components/mdx/MDXComponents';
import { mdxOptions } from '@/lib/mdx-options';
import { EssayLayout } from '@/components/essay';
import { SeriesHeader } from '@/components/series';
+import { JsonLd } from '@/components/seo';
+import { breadcrumbSchema, seriesPageSchema } from '@/lib/jsonld';
interface SeriesPageProps {
params: Promise<{ slug: string }>;
@@ -150,24 +152,35 @@ export default async function SeriesItemPage({ params }: SeriesPageProps) {
const { title, description, date, updated, category, topics, itemCount, readingTime, content, toc } =
series;
+ const urlPath = `/series/${slug}`;
return (
-
- }
- >
-
-
+ <>
+
+
+
+ }
+ >
+
+
+ >
);
}
diff --git a/apps/blog/app/(zh)/layout.tsx b/apps/blog/app/(zh)/layout.tsx
index 4f6e642..aa950b6 100644
--- a/apps/blog/app/(zh)/layout.tsx
+++ b/apps/blog/app/(zh)/layout.tsx
@@ -11,6 +11,8 @@ import {
} from '@/components/layout';
import { LanguageProvider } from '@/lib/i18n';
import { SITE_AUTHOR, SITE_URL } from '@/lib/constants';
+import { JsonLd } from '@/components/seo';
+import { siteGraph } from '@/lib/jsonld';
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
@@ -57,6 +59,13 @@ export default function ZhRootLayout({ children }: ZhRootLayoutProps) {
return (
+
+
+ ),
+ { ...size, fonts: ogFontsFor('zh') },
+ );
+}
diff --git a/apps/blog/app/(zh)/zh/essays/[slug]/opengraph-image.tsx b/apps/blog/app/(zh)/zh/essays/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..e043ec6
--- /dev/null
+++ b/apps/blog/app/(zh)/zh/essays/[slug]/opengraph-image.tsx
@@ -0,0 +1,52 @@
+import { ImageResponse } from 'next/og';
+import { getEssayBySlug, getEssaySlugs } from '@/lib/essays';
+import { OgCard, OG_WIDTH, OG_HEIGHT } from '@/components/seo/og-card';
+import { ogFontsFor } from '@/lib/og-fonts';
+import { TOPIC_LABELS_ZH } from '@/lib/constants';
+
+export const dynamic = 'force-static';
+
+export const alt = '思算文章';
+export const size = { width: OG_WIDTH, height: OG_HEIGHT };
+export const contentType = 'image/png';
+
+export function generateStaticParams() {
+ return getEssaySlugs().map((slug) => ({ slug }));
+}
+
+interface RouteProps {
+ params: { slug: string };
+}
+
+export default async function OgImage({ params }: RouteProps) {
+ const fonts = ogFontsFor('zh');
+ const essay = getEssayBySlug(params.slug);
+ if (!essay || essay.lang !== 'zh') {
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+ }
+
+ const kicker = essay.topics[0] ? TOPIC_LABELS_ZH[essay.topics[0]] : '随笔';
+
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+}
diff --git a/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx b/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx
index 651995d..db794f0 100644
--- a/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx
+++ b/apps/blog/app/(zh)/zh/essays/[slug]/page.tsx
@@ -5,6 +5,8 @@ import { getEssayBySlug, getEssaySlugs, getTranslation } from '@/lib/essays';
import { getMDXComponents } from '@/components/mdx/MDXComponents';
import { mdxOptions } from '@/lib/mdx-options';
import { EssayLayout, EssayHeader } from '@/components/essay';
+import { JsonLd } from '@/components/seo';
+import { breadcrumbSchema, essayPostingSchema } from '@/lib/jsonld';
interface ZhEssayPageProps {
params: Promise<{ slug: string }>;
@@ -53,6 +55,10 @@ export async function generateMetadata({
alternates.languages!['x-default'] = `/zh/essays/${slug}`;
}
+ const ogOverride = essay.image
+ ? { openGraph: { images: [essay.image] }, twitter: { images: [essay.image] } }
+ : {};
+
return {
title: essay.title,
description: essay.description,
@@ -63,6 +69,11 @@ export async function generateMetadata({
publishedTime: essay.date,
tags: essay.topics,
locale: 'zh_CN',
+ ...ogOverride.openGraph,
+ },
+ twitter: {
+ card: 'summary_large_image',
+ ...ogOverride.twitter,
},
alternates,
};
@@ -151,23 +162,34 @@ export default async function ZhEssayPage({ params }: ZhEssayPageProps) {
const { title, description, date, type, topics, readingTime, content, toc } =
essay;
+ const urlPath = `/zh/essays/${slug}`;
return (
-
- }
- >
-
-
+ <>
+
+
+
+ }
+ >
+
+
+ >
);
}
diff --git a/apps/blog/app/(zh)/zh/feed.xml/route.ts b/apps/blog/app/(zh)/zh/feed.xml/route.ts
new file mode 100644
index 0000000..989ceb5
--- /dev/null
+++ b/apps/blog/app/(zh)/zh/feed.xml/route.ts
@@ -0,0 +1,12 @@
+import { buildAtomFeed } from '@/lib/feed';
+
+export const dynamic = 'force-static';
+
+export function GET() {
+ return new Response(buildAtomFeed('zh'), {
+ headers: {
+ 'Content-Type': 'application/atom+xml; charset=utf-8',
+ 'Cache-Control': 'public, max-age=3600',
+ },
+ });
+}
diff --git a/apps/blog/app/(zh)/zh/periodics/[slug]/opengraph-image.tsx b/apps/blog/app/(zh)/zh/periodics/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..5b3a257
--- /dev/null
+++ b/apps/blog/app/(zh)/zh/periodics/[slug]/opengraph-image.tsx
@@ -0,0 +1,45 @@
+import { ImageResponse } from 'next/og';
+import { getPeriodicBySlug, getPeriodicSlugs } from '@/lib/periodics';
+import { OgCard, OG_WIDTH, OG_HEIGHT } from '@/components/seo/og-card';
+import { ogFontsFor } from '@/lib/og-fonts';
+
+export const dynamic = 'force-static';
+
+export const alt = '思算文摘';
+export const size = { width: OG_WIDTH, height: OG_HEIGHT };
+export const contentType = 'image/png';
+
+export function generateStaticParams() {
+ return getPeriodicSlugs().map((slug) => ({ slug }));
+}
+
+export default async function OgImage({ params }: { params: { slug: string } }) {
+ const fonts = ogFontsFor('zh');
+ const periodic = getPeriodicBySlug(params.slug);
+ if (!periodic || periodic.lang !== 'zh') {
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+ }
+
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+}
diff --git a/apps/blog/app/(zh)/zh/periodics/[slug]/page.tsx b/apps/blog/app/(zh)/zh/periodics/[slug]/page.tsx
index 0914f5b..130dd24 100644
--- a/apps/blog/app/(zh)/zh/periodics/[slug]/page.tsx
+++ b/apps/blog/app/(zh)/zh/periodics/[slug]/page.tsx
@@ -6,6 +6,8 @@ import { getMDXComponents } from '@/components/mdx/MDXComponents';
import { mdxOptions } from '@/lib/mdx-options';
import { EssayLayout } from '@/components/essay';
import { PeriodicHeader } from '@/components/periodic';
+import { JsonLd } from '@/components/seo';
+import { breadcrumbSchema, periodicPostingSchema } from '@/lib/jsonld';
interface PeriodicPageProps {
params: Promise<{ slug: string }>;
@@ -155,24 +157,35 @@ export default async function ZhPeriodicPage({ params }: PeriodicPageProps) {
const { title, description, date, issue, type, topics, readingTime, content, toc } =
periodic;
+ const urlPath = `/zh/periodics/${slug}`;
return (
-
- }
- >
-
-
+ <>
+
+
+
+ }
+ >
+
+
+ >
);
}
diff --git a/apps/blog/app/(zh)/zh/series/[slug]/opengraph-image.tsx b/apps/blog/app/(zh)/zh/series/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..559e9c4
--- /dev/null
+++ b/apps/blog/app/(zh)/zh/series/[slug]/opengraph-image.tsx
@@ -0,0 +1,46 @@
+import { ImageResponse } from 'next/og';
+import { getSeriesBySlug, getSeriesSlugs } from '@/lib/series';
+import { OgCard, OG_WIDTH, OG_HEIGHT } from '@/components/seo/og-card';
+import { ogFontsFor } from '@/lib/og-fonts';
+import { SERIES_CATEGORY_LABELS_ZH } from '@/lib/constants';
+
+export const dynamic = 'force-static';
+
+export const alt = '思算系列';
+export const size = { width: OG_WIDTH, height: OG_HEIGHT };
+export const contentType = 'image/png';
+
+export function generateStaticParams() {
+ return getSeriesSlugs().map((slug) => ({ slug }));
+}
+
+export default async function OgImage({ params }: { params: { slug: string } }) {
+ const fonts = ogFontsFor('zh');
+ const series = getSeriesBySlug(params.slug);
+ if (!series || series.lang !== 'zh') {
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+ }
+
+ return new ImageResponse(
+ (
+
+ ),
+ { ...size, fonts },
+ );
+}
diff --git a/apps/blog/app/(zh)/zh/series/[slug]/page.tsx b/apps/blog/app/(zh)/zh/series/[slug]/page.tsx
index 7442c3a..6d885d1 100644
--- a/apps/blog/app/(zh)/zh/series/[slug]/page.tsx
+++ b/apps/blog/app/(zh)/zh/series/[slug]/page.tsx
@@ -6,6 +6,8 @@ import { getMDXComponents } from '@/components/mdx/MDXComponents';
import { mdxOptions } from '@/lib/mdx-options';
import { EssayLayout } from '@/components/essay';
import { SeriesHeader } from '@/components/series';
+import { JsonLd } from '@/components/seo';
+import { breadcrumbSchema, seriesPageSchema } from '@/lib/jsonld';
interface SeriesPageProps {
params: Promise<{ slug: string }>;
@@ -153,25 +155,36 @@ export default async function ZhSeriesItemPage({ params }: SeriesPageProps) {
const { title, description, date, updated, category, topics, itemCount, readingTime, content, toc } =
series;
+ const urlPath = `/zh/series/${slug}`;
return (
-
- }
- >
-
-
+ <>
+
+
+
+ }
+ >
+
+
+ >
);
}
diff --git a/apps/blog/app/feed.xml/route.ts b/apps/blog/app/feed.xml/route.ts
new file mode 100644
index 0000000..fe48f40
--- /dev/null
+++ b/apps/blog/app/feed.xml/route.ts
@@ -0,0 +1,12 @@
+import { buildAtomFeed } from '@/lib/feed';
+
+export const dynamic = 'force-static';
+
+export function GET() {
+ return new Response(buildAtomFeed('en'), {
+ headers: {
+ 'Content-Type': 'application/atom+xml; charset=utf-8',
+ 'Cache-Control': 'public, max-age=3600',
+ },
+ });
+}
diff --git a/apps/blog/components/seo/JsonLd.tsx b/apps/blog/components/seo/JsonLd.tsx
new file mode 100644
index 0000000..bfdaaad
--- /dev/null
+++ b/apps/blog/components/seo/JsonLd.tsx
@@ -0,0 +1,23 @@
+import { serializeJsonLd } from '@/lib/jsonld';
+
+interface JsonLdProps {
+ data: unknown;
+}
+
+/**
+ * Server-rendered