This is an automated email from the ASF dual-hosted git repository.
400Ping pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/mahout.git
The following commit(s) were added to refs/heads/main by this push:
new cb8e2f066 [Website] Redesign news index and blog post pages (#1418)
cb8e2f066 is described below
commit cb8e2f066660dc548b6e6d57c0a00ff71f9f0a33
Author: Jie-Kai Chang <[email protected]>
AuthorDate: Sun Jun 28 13:35:32 2026 +0800
[Website] Redesign news index and blog post pages (#1418)
* [Docs] Add 0.6 website version snapshot
Signed-off-by: 400Ping <[email protected]>
* [Website] Redesign news index and blog post pages
Signed-off-by: 400Ping <[email protected]>
---------
Signed-off-by: 400Ping <[email protected]>
---
...elease.md => 2025-04-17-Qumat-0.4.0-Release.md} | 2 +-
docs/blog/authors.yml | 6 +
website/docusaurus.config.ts | 2 +-
website/package.json | 2 +
website/scripts/sync-docs.js | 13 +
website/src/theme/BlogListPage/index.tsx | 239 +++++++++++++++
website/src/theme/BlogListPage/styles.module.css | 327 +++++++++++++++++++++
website/src/theme/BlogPostItem/index.tsx | 118 ++++++++
website/src/theme/BlogPostItem/styles.module.css | 133 +++++++++
website/src/theme/BlogPostPage/index.tsx | 81 +++++
website/src/theme/BlogPostPage/styles.module.css | 52 ++++
website/static/img/jiekaichang.png | Bin 0 -> 1198420 bytes
12 files changed, 973 insertions(+), 2 deletions(-)
diff --git a/docs/blog/2025-04-17-Qumat-0.4-Release.md
b/docs/blog/2025-04-17-Qumat-0.4.0-Release.md
similarity index 86%
rename from docs/blog/2025-04-17-Qumat-0.4-Release.md
rename to docs/blog/2025-04-17-Qumat-0.4.0-Release.md
index 66065cc87..25773242d 100644
--- a/docs/blog/2025-04-17-Qumat-0.4-Release.md
+++ b/docs/blog/2025-04-17-Qumat-0.4.0-Release.md
@@ -1,5 +1,5 @@
---
-title: Qumat 0.4 Release
+title: Qumat 0.4.0 Release
date: 2025-04-17
tags: [news]
authors: [mahout-team]
diff --git a/docs/blog/authors.yml b/docs/blog/authors.yml
index e6091954e..199b91ca3 100644
--- a/docs/blog/authors.yml
+++ b/docs/blog/authors.yml
@@ -5,6 +5,12 @@ mahout-team:
image_url: /img/mahout-favicon-black.svg
image_url_dark: /img/mahout-favicon.svg
+jiekaichang:
+ name: Jie-Kai Chang
+ title: Apache Mahout PMC Member
+ url: https://github.com/400Ping
+ image_url: /img/jiekaichang.png
+
ryankert:
name: Ryan Huang
title: Apache Mahout Committer
diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts
index f20a1ddec..d24b9949d 100644
--- a/website/docusaurus.config.ts
+++ b/website/docusaurus.config.ts
@@ -81,7 +81,7 @@ const config: Config = {
editUrl: 'https://github.com/apache/mahout/tree/main/website-new/',
blogTitle: 'News & Updates',
blogDescription: 'Apache Mahout project news, releases, and
community updates',
- postsPerPage: 10,
+ postsPerPage: 'ALL',
blogSidebarCount: 'ALL',
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
diff --git a/website/package.json b/website/package.json
index 2d2560617..386a65944 100644
--- a/website/package.json
+++ b/website/package.json
@@ -5,7 +5,9 @@
"scripts": {
"docusaurus": "docusaurus",
"sync": "node scripts/sync-docs.js",
+ "sync:no-api": "MAHOUT_SKIP_API_DOCS=1 node scripts/sync-docs.js",
"start": "npm run sync && docusaurus start",
+ "start:no-api": "npm run sync:no-api && docusaurus start --no-open",
"prebuild": "npm run sync",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
diff --git a/website/scripts/sync-docs.js b/website/scripts/sync-docs.js
index d188638fb..a21b50707 100644
--- a/website/scripts/sync-docs.js
+++ b/website/scripts/sync-docs.js
@@ -235,6 +235,19 @@ function generateApiDocs() {
},
];
+ if (process.env.MAHOUT_SKIP_API_DOCS === '1') {
+ console.log('Skipping generated Python API reference docs.');
+ for (const page of pages) {
+ const outputPath = path.join(API_DEST_DIR, page.output);
+ copyFile(path.join(API_SOURCE_DIR, page.header), outputPath);
+ fs.appendFileSync(
+ outputPath,
+ '\n\nAPI reference generation was skipped for this local preview.\n',
+ );
+ }
+ return;
+ }
+
runCommand('uv', ['sync', '--frozen', '--group', 'dev']);
for (const page of pages) {
diff --git a/website/src/theme/BlogListPage/index.tsx
b/website/src/theme/BlogListPage/index.tsx
new file mode 100644
index 000000000..61f47f36d
--- /dev/null
+++ b/website/src/theme/BlogListPage/index.tsx
@@ -0,0 +1,239 @@
+import React, {type ReactNode} from 'react';
+import clsx from 'clsx';
+
+import Link from '@docusaurus/Link';
+import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
+import {
+ PageMetadata,
+ HtmlClassNameProvider,
+ ThemeClassNames,
+} from '@docusaurus/theme-common';
+import {useDateTimeFormat} from '@docusaurus/theme-common/internal';
+import Layout from '@theme/Layout';
+import BlogListPaginator from '@theme/BlogListPaginator';
+import SearchMetadata from '@theme/SearchMetadata';
+import type {Props} from '@theme/BlogListPage';
+import BlogListPageStructuredData from '@theme/BlogListPage/StructuredData';
+
+import styles from './styles.module.css';
+
+type BlogListItem = Props['items'][number];
+
+function useFormattedDate() {
+ const dateTimeFormat = useDateTimeFormat({
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ timeZone: 'UTC',
+ });
+
+ return (blogDate: string) => dateTimeFormat.format(new Date(blogDate));
+}
+
+function ReadingTime({readingTime}: {readingTime?: number}) {
+ if (typeof readingTime === 'undefined') {
+ return null;
+ }
+
+ return <span>{Math.ceil(readingTime)} min read</span>;
+}
+
+function PostEyebrow({item}: {item: BlogListItem}) {
+ const {date, readingTime} = item.content.metadata;
+ const formatDate = useFormattedDate();
+
+ return (
+ <div className={styles.eyebrow}>
+ <time dateTime={date}>{formatDate(date)}</time>
+ <ReadingTime readingTime={readingTime} />
+ </div>
+ );
+}
+
+function Category({item}: {item: BlogListItem}) {
+ const label = item.content.metadata.tags[0]?.label ?? 'News';
+
+ return <div className={styles.category}>{label}</div>;
+}
+
+function Authors({item}: {item: BlogListItem}) {
+ const names = item.content.metadata.authors
+ .map((author) => author.name)
+ .filter(Boolean);
+
+ if (names.length === 0) {
+ return null;
+ }
+
+ return <div className={styles.authors}>{names.join(', ')}</div>;
+}
+
+function isMeetingMinutes(item: BlogListItem) {
+ const {title, tags} = item.content.metadata;
+
+ return (
+ title.toLowerCase().includes('meeting minutes') ||
+ tags.some((tag) => tag.label.toLowerCase() === 'minutes')
+ );
+}
+
+function PostCard({item}: {item: BlogListItem}) {
+ const {metadata} = item.content;
+
+ return (
+ <article className={styles.card}>
+ <Category item={item} />
+ <PostEyebrow item={item} />
+ <h2 className={styles.cardTitle}>
+ <Link to={metadata.permalink}>{metadata.title}</Link>
+ </h2>
+ {metadata.description && (
+ <p className={styles.cardDescription}>{metadata.description}</p>
+ )}
+ <div className={styles.cardFooter}>
+ <Authors item={item} />
+ <Link className={styles.readMore} to={metadata.permalink}>
+ Read more
+ </Link>
+ </div>
+ </article>
+ );
+}
+
+function MinutesItem({item}: {item: BlogListItem}) {
+ const {metadata} = item.content;
+
+ return (
+ <article className={styles.minutesItem}>
+ <PostEyebrow item={item} />
+ <h3 className={styles.minutesTitle}>
+ <Link to={metadata.permalink}>{metadata.title}</Link>
+ </h3>
+ {metadata.description && (
+ <p className={styles.minutesDescription}>{metadata.description}</p>
+ )}
+ </article>
+ );
+}
+
+function FeaturedPost({item}: {item: BlogListItem}) {
+ const {metadata} = item.content;
+
+ return (
+ <article className={styles.featured}>
+ <div className={styles.featuredContent}>
+ <Category item={item} />
+ <PostEyebrow item={item} />
+ <h2 className={styles.featuredTitle}>
+ <Link to={metadata.permalink}>{metadata.title}</Link>
+ </h2>
+ {metadata.description && (
+ <p className={styles.featuredDescription}>{metadata.description}</p>
+ )}
+ <div className={styles.featuredFooter}>
+ <Authors item={item} />
+ <Link className={styles.featuredLink} to={metadata.permalink}>
+ Read the latest
+ </Link>
+ </div>
+ </div>
+ <div className={styles.featuredMeta} aria-hidden="true">
+ <span>Latest</span>
+ <strong>{metadata.tags[0]?.label ?? 'News'}</strong>
+ </div>
+ </article>
+ );
+}
+
+function BlogListPageMetadata(props: Props): ReactNode {
+ const {metadata} = props;
+ const {
+ siteConfig: {title: siteTitle},
+ } = useDocusaurusContext();
+ const {blogDescription, blogTitle, permalink} = metadata;
+ const isBlogOnlyMode = permalink === '/';
+ const title = isBlogOnlyMode ? siteTitle : blogTitle;
+ return (
+ <>
+ <PageMetadata title={title} description={blogDescription} />
+ <SearchMetadata tag="blog_posts_list" />
+ </>
+ );
+}
+
+function BlogListHeader({metadata}: Pick<Props, 'metadata'>) {
+ return (
+ <header className={styles.header}>
+ <div>
+ <div className={styles.kicker}>Apache Mahout</div>
+ <h1 className={styles.title}>News</h1>
+ </div>
+ <p className={styles.description}>{metadata.blogDescription}</p>
+ </header>
+ );
+}
+
+function BlogListPageContent(props: Props): ReactNode {
+ const {metadata, items} = props;
+ const posts = items.filter((item) => !isMeetingMinutes(item));
+ const minutes = items.filter(isMeetingMinutes);
+ const [featured, ...blogPosts] = posts;
+
+ return (
+ <Layout>
+ <main className="container margin-vert--lg">
+ <BlogListHeader metadata={metadata} />
+ <nav className={styles.sectionNav} aria-label="News sections">
+ <a href="#blog">Blog</a>
+ <a href="#meeting-minutes">Meeting Minutes</a>
+ </nav>
+ <section id="blog" className={styles.section}>
+ <div className={styles.sectionHeader}>
+ <span>Blog</span>
+ <h2>Project updates and release notes</h2>
+ </div>
+ {featured && <FeaturedPost item={featured} />}
+ <section className={styles.grid} aria-label="News posts">
+ {blogPosts.map((item) => (
+ <PostCard
+ key={item.content.metadata.permalink}
+ item={item}
+ />
+ ))}
+ </section>
+ </section>
+ {minutes.length > 0 && (
+ <section id="meeting-minutes" className={styles.minutesSection}>
+ <div className={styles.sectionHeader}>
+ <span>Meeting Minutes</span>
+ <h2>Community meeting notes</h2>
+ </div>
+ <div className={styles.minutesList}>
+ {minutes.map((item) => (
+ <MinutesItem
+ key={item.content.metadata.permalink}
+ item={item}
+ />
+ ))}
+ </div>
+ </section>
+ )}
+ <BlogListPaginator metadata={metadata} />
+ </main>
+ </Layout>
+ );
+}
+
+export default function BlogListPage(props: Props): ReactNode {
+ return (
+ <HtmlClassNameProvider
+ className={clsx(
+ ThemeClassNames.wrapper.blogPages,
+ ThemeClassNames.page.blogListPage,
+ )}>
+ <BlogListPageMetadata {...props} />
+ <BlogListPageStructuredData {...props} />
+ <BlogListPageContent {...props} />
+ </HtmlClassNameProvider>
+ );
+}
diff --git a/website/src/theme/BlogListPage/styles.module.css
b/website/src/theme/BlogListPage/styles.module.css
new file mode 100644
index 000000000..4a9e20f73
--- /dev/null
+++ b/website/src/theme/BlogListPage/styles.module.css
@@ -0,0 +1,327 @@
+.header {
+ align-items: end;
+ border-bottom: 1px solid var(--ifm-color-emphasis-200);
+ display: grid;
+ gap: 2rem;
+ grid-template-columns: minmax(0, 1fr) minmax(18rem, 32rem);
+ margin-bottom: 2rem;
+ padding: 2rem 0 2.25rem;
+}
+
+.kicker {
+ color: var(--ifm-color-primary-darkest);
+ font-size: 0.78rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ margin-bottom: 0.85rem;
+ text-transform: uppercase;
+}
+
+.title {
+ font-size: 5.2rem;
+ letter-spacing: 0;
+ line-height: 0.92;
+ margin: 0;
+}
+
+.description {
+ color: var(--ifm-color-emphasis-700);
+ font-size: 1.12rem;
+ line-height: 1.62;
+ margin: 0 0 0.35rem;
+}
+
+.sectionNav {
+ align-items: center;
+ border-bottom: 1px solid var(--ifm-color-emphasis-200);
+ display: flex;
+ gap: 1.4rem;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1rem;
+}
+
+.sectionNav a {
+ color: var(--ifm-font-color-base);
+ font-size: 0.9rem;
+ font-weight: 800;
+}
+
+.sectionNav a:hover {
+ color: var(--ifm-color-primary-darkest);
+ text-decoration: none;
+}
+
+.section {
+ scroll-margin-top: calc(var(--ifm-navbar-height) + 1.5rem);
+}
+
+.sectionHeader {
+ margin: 2rem 0 1rem;
+}
+
+.sectionHeader span {
+ color: var(--ifm-color-primary-darkest);
+ display: block;
+ font-size: 0.78rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ margin-bottom: 0.45rem;
+ text-transform: uppercase;
+}
+
+.sectionHeader h2 {
+ font-size: 1.7rem;
+ letter-spacing: 0;
+ line-height: 1.15;
+ margin: 0;
+}
+
+.featured {
+ border-bottom: 1px solid var(--ifm-color-emphasis-200);
+ display: grid;
+ gap: 2rem;
+ grid-template-columns: minmax(0, 1fr) 15rem;
+ margin-bottom: 2rem;
+ padding: 2rem 0 2.5rem;
+}
+
+.featuredContent {
+ max-width: 52rem;
+}
+
+.featuredMeta {
+ align-self: stretch;
+ background:
+ linear-gradient(135deg, rgba(0, 188, 212, 0.16), transparent 58%),
+ var(--ifm-color-emphasis-100);
+ border: 1px solid var(--ifm-color-emphasis-200);
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-height: 13rem;
+ padding: 1.25rem;
+}
+
+.featuredMeta span {
+ color: var(--ifm-color-emphasis-600);
+ font-size: 0.78rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.featuredMeta strong {
+ font-size: 1.25rem;
+ line-height: 1.15;
+}
+
+.eyebrow {
+ align-items: center;
+ color: var(--ifm-color-emphasis-600);
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 0.82rem;
+ font-weight: 800;
+ gap: 0.55rem;
+ line-height: 1.4;
+ margin: 0.8rem 0 0;
+ text-transform: uppercase;
+}
+
+.eyebrow span::before {
+ content: '/';
+ margin-right: 0.55rem;
+}
+
+.featuredTitle {
+ font-size: 3.4rem;
+ letter-spacing: 0;
+ line-height: 1;
+ margin: 0.9rem 0 1rem;
+ max-width: 54rem;
+}
+
+.featuredTitle a,
+.cardTitle a {
+ color: var(--ifm-font-color-base);
+}
+
+.featuredTitle a:hover,
+.cardTitle a:hover {
+ color: var(--ifm-link-hover-color);
+ text-decoration: none;
+}
+
+.featuredDescription {
+ color: var(--ifm-color-emphasis-700);
+ font-size: 1.12rem;
+ line-height: 1.65;
+ margin: 0;
+ max-width: 45rem;
+}
+
+.featuredFooter,
+.cardFooter {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ justify-content: space-between;
+ margin-top: 1.5rem;
+}
+
+.authors {
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.92rem;
+ font-weight: 700;
+}
+
+.featuredLink,
+.readMore {
+ color: var(--ifm-color-primary-darkest);
+ font-size: 0.9rem;
+ font-weight: 800;
+}
+
+.category {
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: 999px;
+ color: var(--ifm-font-color-base);
+ display: inline-flex;
+ font-size: 0.82rem;
+ font-weight: 800;
+ line-height: 1.2;
+ padding: 0.42rem 0.7rem;
+ width: fit-content;
+}
+
+.grid {
+ display: grid;
+ gap: 1.2rem;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.card {
+ border: 1px solid var(--ifm-color-emphasis-200);
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ min-height: 19rem;
+ padding: 1.25rem;
+}
+
+.cardTitle {
+ font-size: 1.55rem;
+ letter-spacing: 0;
+ line-height: 1.12;
+ margin: 0.8rem 0;
+}
+
+.cardDescription {
+ color: var(--ifm-color-emphasis-700);
+ line-height: 1.58;
+ margin: 0;
+}
+
+.cardFooter {
+ margin-top: auto;
+ padding-top: 1.25rem;
+}
+
+.minutesSection {
+ border-top: 1px solid var(--ifm-color-emphasis-200);
+ margin-top: 3rem;
+ padding-top: 0.5rem;
+ scroll-margin-top: calc(var(--ifm-navbar-height) + 1.5rem);
+}
+
+.minutesList {
+ border-top: 1px solid var(--ifm-color-emphasis-200);
+}
+
+.minutesItem {
+ border-bottom: 1px solid var(--ifm-color-emphasis-200);
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: 14rem minmax(0, 1fr);
+ padding: 1rem 0;
+}
+
+.minutesItem .eyebrow {
+ margin: 0.2rem 0 0;
+}
+
+.minutesTitle {
+ font-size: 1.22rem;
+ letter-spacing: 0;
+ line-height: 1.25;
+ margin: 0;
+}
+
+.minutesTitle a {
+ color: var(--ifm-font-color-base);
+}
+
+.minutesTitle a:hover {
+ color: var(--ifm-link-hover-color);
+ text-decoration: none;
+}
+
+.minutesDescription {
+ color: var(--ifm-color-emphasis-700);
+ grid-column: 2;
+ line-height: 1.55;
+ margin: -0.55rem 0 0;
+}
+
+@media (max-width: 996px) {
+ .header,
+ .featured {
+ grid-template-columns: 1fr;
+ }
+
+ .title {
+ font-size: 4rem;
+ }
+
+ .featuredTitle {
+ font-size: 2.7rem;
+ }
+
+ .featuredMeta {
+ display: none;
+ }
+
+ .grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 700px) {
+ .grid {
+ grid-template-columns: 1fr;
+ }
+
+ .title {
+ font-size: 3.1rem;
+ }
+
+ .featuredTitle {
+ font-size: 2.1rem;
+ }
+
+ .header {
+ padding-top: 0.5rem;
+ }
+
+ .minutesItem {
+ gap: 0.45rem;
+ grid-template-columns: 1fr;
+ }
+
+ .minutesDescription {
+ grid-column: auto;
+ margin-top: 0;
+ }
+}
diff --git a/website/src/theme/BlogPostItem/index.tsx
b/website/src/theme/BlogPostItem/index.tsx
new file mode 100644
index 000000000..7fce9b774
--- /dev/null
+++ b/website/src/theme/BlogPostItem/index.tsx
@@ -0,0 +1,118 @@
+import React, {type ReactNode} from 'react';
+import clsx from 'clsx';
+import Link from '@docusaurus/Link';
+import {translate} from '@docusaurus/Translate';
+import {useBlogPost} from '@docusaurus/plugin-content-blog/client';
+import {usePluralForm} from '@docusaurus/theme-common';
+import {useDateTimeFormat} from '@docusaurus/theme-common/internal';
+import BlogPostItemContent from '@theme/BlogPostItem/Content';
+import BlogPostItemFooter from '@theme/BlogPostItem/Footer';
+import BlogPostItemHeaderAuthors from '@theme/BlogPostItem/Header/Authors';
+import type {Props} from '@theme/BlogPostItem';
+
+import styles from './styles.module.css';
+
+function useFormattedDate() {
+ const dateTimeFormat = useDateTimeFormat({
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ timeZone: 'UTC',
+ });
+
+ return (blogDate: string) => dateTimeFormat.format(new Date(blogDate));
+}
+
+function useReadingTimeLabel() {
+ const {selectMessage} = usePluralForm();
+
+ return (readingTimeFloat: number) => {
+ const readingTime = Math.ceil(readingTimeFloat);
+ return selectMessage(
+ readingTime,
+ translate(
+ {
+ id: 'theme.blog.post.readingTime.plurals',
+ description:
+ 'Pluralized label for "{readingTime} min read". Use as much plural
forms (separated by "|") as your language support (see
https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
+ message: 'One min read|{readingTime} min read',
+ },
+ {readingTime},
+ ),
+ );
+ };
+}
+
+function PostMeta() {
+ const {
+ metadata: {date, readingTime},
+ } = useBlogPost();
+ const formatDate = useFormattedDate();
+ const readingTimeLabel = useReadingTimeLabel();
+
+ return (
+ <div className={styles.meta}>
+ <time dateTime={date}>{formatDate(date)}</time>
+ {typeof readingTime !== 'undefined' && (
+ <>
+ <span aria-hidden="true">/</span>
+ <span>{readingTimeLabel(readingTime)}</span>
+ </>
+ )}
+ </div>
+ );
+}
+
+function ListPostItem({children, className}: Props): ReactNode {
+ const {
+ metadata: {permalink, title},
+ } = useBlogPost();
+
+ return (
+ <article className={clsx(styles.listItem, className)}>
+ <PostMeta />
+ <h2 className={styles.listTitle}>
+ <Link to={permalink}>{title}</Link>
+ </h2>
+ <div className={styles.listAuthors}>
+ <BlogPostItemHeaderAuthors />
+ </div>
+ <BlogPostItemContent className={styles.listContent}>
+ {children}
+ </BlogPostItemContent>
+ <BlogPostItemFooter />
+ </article>
+ );
+}
+
+function ArticlePostItem({children, className}: Props): ReactNode {
+ const {
+ metadata: {title},
+ } = useBlogPost();
+
+ return (
+ <article className={clsx(styles.article, className)}>
+ <header className={styles.articleHeader}>
+ <Link className={styles.breadcrumb} to="/blog">
+ News
+ </Link>
+ <PostMeta />
+ <h1 className={styles.articleTitle}>{title}</h1>
+ <BlogPostItemHeaderAuthors />
+ </header>
+ <BlogPostItemContent className={styles.articleContent}>
+ {children}
+ </BlogPostItemContent>
+ </article>
+ );
+}
+
+export default function BlogPostItem(props: Props): ReactNode {
+ const {isBlogPostPage} = useBlogPost();
+
+ return isBlogPostPage ? (
+ <ArticlePostItem {...props} />
+ ) : (
+ <ListPostItem {...props} />
+ );
+}
diff --git a/website/src/theme/BlogPostItem/styles.module.css
b/website/src/theme/BlogPostItem/styles.module.css
new file mode 100644
index 000000000..c341a9281
--- /dev/null
+++ b/website/src/theme/BlogPostItem/styles.module.css
@@ -0,0 +1,133 @@
+.meta {
+ align-items: center;
+ color: var(--ifm-color-emphasis-600);
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 0.86rem;
+ font-weight: 700;
+ gap: 0.45rem;
+ line-height: 1.4;
+ text-transform: uppercase;
+}
+
+.listItem {
+ border-bottom: 1px solid var(--ifm-color-emphasis-200);
+ padding: 0 0 1.75rem;
+}
+
+.listTitle {
+ font-size: clamp(1.6rem, 3vw, 2.35rem);
+ letter-spacing: 0;
+ line-height: 1.08;
+ margin: 0.65rem 0 0.85rem;
+}
+
+.listTitle a {
+ color: var(--ifm-font-color-base);
+}
+
+.listTitle a:hover {
+ color: var(--ifm-link-hover-color);
+ text-decoration: none;
+}
+
+.listAuthors {
+ margin-bottom: 0.6rem;
+}
+
+.listAuthors :global(.avatar) {
+ margin-bottom: 0;
+}
+
+.listContent {
+ color: var(--ifm-color-emphasis-800);
+ font-size: 1rem;
+ line-height: 1.7;
+}
+
+.listContent > :last-child {
+ margin-bottom: 0;
+}
+
+.listItem :global(footer) {
+ margin-top: 1rem;
+}
+
+.article {
+ margin: 0 auto;
+ max-width: 48rem;
+}
+
+.articleHeader {
+ border-bottom: 1px solid var(--ifm-color-emphasis-200);
+ margin-bottom: 2.5rem;
+ padding: 1.25rem 0 2rem;
+}
+
+.breadcrumb {
+ color: var(--ifm-color-primary-darkest);
+ display: inline-flex;
+ font-size: 0.82rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ margin-bottom: 2.25rem;
+ text-transform: uppercase;
+}
+
+.breadcrumb::before {
+ content: '<';
+ margin-right: 0.4rem;
+}
+
+.articleTitle {
+ font-size: clamp(2.15rem, 4.6vw, 3.85rem);
+ letter-spacing: 0;
+ line-height: 1.05;
+ margin: 0.9rem 0 1.4rem;
+}
+
+.articleContent {
+ font-size: 1.06rem;
+ line-height: 1.78;
+}
+
+.articleContent :global(h2) {
+ border-top: 1px solid var(--ifm-color-emphasis-200);
+ font-size: clamp(1.65rem, 3vw, 2.2rem);
+ letter-spacing: 0;
+ margin-top: 3rem;
+ padding-top: 2rem;
+}
+
+.articleContent :global(h3) {
+ font-size: 1.35rem;
+ letter-spacing: 0;
+ margin-top: 2rem;
+}
+
+.articleContent :global(img) {
+ border: 1px solid var(--ifm-color-emphasis-200);
+ border-radius: 8px;
+}
+
+.article :global(footer) {
+ border-top: 1px solid var(--ifm-color-emphasis-200);
+ margin-top: 3rem;
+ padding-top: 1.5rem;
+}
+
+@media (max-width: 996px) {
+ .article {
+ max-width: none;
+ }
+}
+
+@media (max-width: 480px) {
+ .articleHeader {
+ padding-top: 0.5rem;
+ }
+
+ .breadcrumb {
+ margin-bottom: 1.5rem;
+ }
+}
diff --git a/website/src/theme/BlogPostPage/index.tsx
b/website/src/theme/BlogPostPage/index.tsx
new file mode 100644
index 000000000..28e1110ae
--- /dev/null
+++ b/website/src/theme/BlogPostPage/index.tsx
@@ -0,0 +1,81 @@
+import React, {type ReactNode} from 'react';
+import clsx from 'clsx';
+import {HtmlClassNameProvider, ThemeClassNames} from
'@docusaurus/theme-common';
+import {
+ BlogPostProvider,
+ useBlogPost,
+} from '@docusaurus/plugin-content-blog/client';
+import Layout from '@theme/Layout';
+import BlogPostItem from '@theme/BlogPostItem';
+import BlogPostPaginator from '@theme/BlogPostPaginator';
+import BlogPostPageMetadata from '@theme/BlogPostPage/Metadata';
+import BlogPostPageStructuredData from '@theme/BlogPostPage/StructuredData';
+import TOC from '@theme/TOC';
+import ContentVisibility from '@theme/ContentVisibility';
+import type {Props} from '@theme/BlogPostPage';
+
+import styles from './styles.module.css';
+
+function BlogPostPageContent({
+ children,
+}: {
+ children: ReactNode;
+}): ReactNode {
+ const {metadata, toc} = useBlogPost();
+ const {nextItem, prevItem, frontMatter} = metadata;
+ const {
+ hide_table_of_contents: hideTableOfContents,
+ toc_min_heading_level: tocMinHeadingLevel,
+ toc_max_heading_level: tocMaxHeadingLevel,
+ } = frontMatter;
+
+ return (
+ <Layout>
+ <main className={styles.pageShell}>
+ <div className={styles.articleGrid}>
+ <div className={styles.articleColumn}>
+ <ContentVisibility metadata={metadata} />
+
+ <BlogPostItem>{children}</BlogPostItem>
+
+ {(nextItem || prevItem) && (
+ <div className={styles.paginator}>
+ <BlogPostPaginator nextItem={nextItem} prevItem={prevItem} />
+ </div>
+ )}
+ </div>
+ {!hideTableOfContents && toc.length > 0 && (
+ <aside className={styles.tocPanel}>
+ <div className={styles.tocTitle}>On this page</div>
+ <TOC
+ toc={toc}
+ minHeadingLevel={tocMinHeadingLevel}
+ maxHeadingLevel={tocMaxHeadingLevel}
+ />
+ </aside>
+ )}
+ </div>
+ </main>
+ </Layout>
+ );
+}
+
+export default function BlogPostPage(props: Props): ReactNode {
+ const BlogPostContent = props.content;
+ return (
+ <BlogPostProvider content={props.content} isBlogPostPage>
+ <HtmlClassNameProvider
+ className={clsx(
+ ThemeClassNames.wrapper.blogPages,
+ ThemeClassNames.page.blogPostPage,
+ styles.blogPostPage,
+ )}>
+ <BlogPostPageMetadata />
+ <BlogPostPageStructuredData />
+ <BlogPostPageContent>
+ <BlogPostContent />
+ </BlogPostPageContent>
+ </HtmlClassNameProvider>
+ </BlogPostProvider>
+ );
+}
diff --git a/website/src/theme/BlogPostPage/styles.module.css
b/website/src/theme/BlogPostPage/styles.module.css
new file mode 100644
index 000000000..5dc73b3fd
--- /dev/null
+++ b/website/src/theme/BlogPostPage/styles.module.css
@@ -0,0 +1,52 @@
+.pageShell {
+ margin: 0 auto;
+ max-width: 1200px;
+ padding: 2.5rem var(--ifm-spacing-horizontal) 4rem;
+}
+
+.articleGrid {
+ align-items: start;
+ display: grid;
+ gap: 4rem;
+ grid-template-columns: minmax(0, 48rem) 15rem;
+ justify-content: center;
+}
+
+.articleColumn {
+ min-width: 0;
+}
+
+.tocPanel {
+ border-left: 1px solid var(--ifm-color-emphasis-200);
+ padding-left: 1rem;
+ position: sticky;
+ top: calc(var(--ifm-navbar-height) + 1.5rem);
+}
+
+.tocTitle {
+ color: var(--ifm-color-emphasis-600);
+ font-size: 0.78rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ margin-bottom: 0.75rem;
+ text-transform: uppercase;
+}
+
+.paginator {
+ margin: 3rem 0 0;
+ max-width: 48rem;
+}
+
+@media (max-width: 996px) {
+ .pageShell {
+ padding-top: 1.5rem;
+ }
+
+ .articleGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .tocPanel {
+ display: none;
+ }
+}
diff --git a/website/static/img/jiekaichang.png
b/website/static/img/jiekaichang.png
new file mode 100644
index 000000000..eb8cd5d8b
Binary files /dev/null and b/website/static/img/jiekaichang.png differ