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

Reply via email to