From dc0803a4bdd4db273fbc861d9ce81f231d240312 Mon Sep 17 00:00:00 2001
From: zzq0826 <770166635@qq.com>
Date: Mon, 21 Sep 2026 16:26:20 +0800
Subject: [PATCH] feat(blog): the blog is back at /blog, in the new brand
(#1625)
---
scripts/build-landing.mjs | 14 +
src/app/_blog/[blogId]/MoreBlogs.tsx | 52 --
src/app/_blog/[blogId]/actions.ts | 17 -
.../[blogId]/components/tableOfContents.tsx | 124 -----
src/app/_blog/[blogId]/detail.tsx | 132 -----
src/app/_blog/[blogId]/page.tsx | 49 --
src/app/_blog/layout.tsx | 16 -
src/app/_blog/page.tsx | 288 ----------
src/app/_components/LandingFooter.tsx | 20 +-
src/app/_components/LandingNav.tsx | 8 +-
src/app/_components/landing.module.css | 4 +-
src/app/blog/PostCard.tsx | 26 +
src/app/blog/PostList.tsx | 51 ++
src/app/blog/[blogId]/components/BackLink.tsx | 14 +
.../[blogId]/components/tableOfContents.tsx | 80 +++
src/app/blog/[blogId]/page.tsx | 112 ++++
src/app/blog/blog.module.css | 513 ++++++++++++++++++
src/app/blog/layout.tsx | 38 ++
src/app/blog/page.tsx | 24 +
src/app/blog/posts.ts | 37 ++
src/app/sitemap.ts | 9 +
src/components/ScrollToTop/index.tsx | 3 +-
22 files changed, 946 insertions(+), 685 deletions(-)
delete mode 100644 src/app/_blog/[blogId]/MoreBlogs.tsx
delete mode 100644 src/app/_blog/[blogId]/actions.ts
delete mode 100644 src/app/_blog/[blogId]/components/tableOfContents.tsx
delete mode 100644 src/app/_blog/[blogId]/detail.tsx
delete mode 100644 src/app/_blog/[blogId]/page.tsx
delete mode 100644 src/app/_blog/layout.tsx
delete mode 100644 src/app/_blog/page.tsx
create mode 100644 src/app/blog/PostCard.tsx
create mode 100644 src/app/blog/PostList.tsx
create mode 100644 src/app/blog/[blogId]/components/BackLink.tsx
create mode 100644 src/app/blog/[blogId]/components/tableOfContents.tsx
create mode 100644 src/app/blog/[blogId]/page.tsx
create mode 100644 src/app/blog/blog.module.css
create mode 100644 src/app/blog/layout.tsx
create mode 100644 src/app/blog/page.tsx
create mode 100644 src/app/blog/posts.ts
diff --git a/scripts/build-landing.mjs b/scripts/build-landing.mjs
index 84c94a04a..b1503a43b 100644
--- a/scripts/build-landing.mjs
+++ b/scripts/build-landing.mjs
@@ -45,6 +45,20 @@ once(
'Terms of service\n Support',
)
once("white paper link", 'href="/scroll-whitepaper.pdf"', 'href="/files/whitepaper.pdf"')
+// the blog is a page of its own again (Zhengqi, 2026-09-21): it went off the site with the
+// rest of the old pages in the redesign, and the link people were sent to it with has been
+// dead since. It sits in the nav after the product sections and opens the footer's
+// Resources column, here and in LandingFooter.tsx (the other pages' footer) alike.
+once(
+ "blog nav link",
+ '
AI hardware',
+ 'AI hardware\n Blog',
+)
+once(
+ "blog footer link",
+ 'Documentation',
+ 'Blog\n Documentation',
+)
// ---- the Compass button on the Compass API panel: his file points it at his own Compass
// prototype (index.html, 2026-09-11); here it opens the Compass site ----
diff --git a/src/app/_blog/[blogId]/MoreBlogs.tsx b/src/app/_blog/[blogId]/MoreBlogs.tsx
deleted file mode 100644
index 972084839..000000000
--- a/src/app/_blog/[blogId]/MoreBlogs.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Pagination } from "swiper/modules"
-import { Swiper, SwiperSlide } from "swiper/react"
-
-import { Box, Stack, Typography } from "@mui/material"
-
-import ArticleCard from "@/components/ArticleCard"
-
-const MoreBlogs = props => {
- const { blogs, title, ...restProps } = props
-
- return (
-
-
- {title}
-
-
-
- {props.blogs.map(blog => (
-
-
-
-
-
- ))}
-
-
- )
-}
-
-export default MoreBlogs
diff --git a/src/app/_blog/[blogId]/actions.ts b/src/app/_blog/[blogId]/actions.ts
deleted file mode 100644
index 65e05e043..000000000
--- a/src/app/_blog/[blogId]/actions.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-"use server"
-
-import { fetchBlogDetailURL } from "@/apis/blog"
-
-export const fetchBlogContent = async (blogId: string) => {
- const response = await fetch(fetchBlogDetailURL(blogId))
-
- if (response.ok) {
- const blogContent = await response.text()
- if (!blogContent) {
- throw new Error("Not found")
- }
- return blogContent
- } else {
- throw new Error("Failed to fetch blog content")
- }
-}
diff --git a/src/app/_blog/[blogId]/components/tableOfContents.tsx b/src/app/_blog/[blogId]/components/tableOfContents.tsx
deleted file mode 100644
index e14e02e36..000000000
--- a/src/app/_blog/[blogId]/components/tableOfContents.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { default as RouterLink } from "next/link"
-import { usePathname } from "next/navigation"
-import type { FC } from "react"
-import { useEffect, useRef, useState } from "react"
-
-import { SvgIcon, Typography } from "@mui/material"
-import { styled } from "@mui/system"
-
-import BackSvg from "@/assets/svgs/common/back.svg"
-
-const Link = styled(RouterLink)({
- display: "flex",
- alignItems: "center",
- "& *": {
- fontWeight: 500,
- },
-})
-
-export interface Heading {
- depth: string
- text: string
- slug: string
-}
-
-const TableOfContents: FC = () => {
- const pathname = usePathname()
-
- const [headings, setHeadings] = useState([])
- const tableOfContents = useRef(null)
- const [currentID, setCurrentID] = useState("")
- const scrolledRef = useRef(false)
-
- const hash = pathname!.split("#")[1]
- const hashRef = useRef(hash)
-
- useEffect(() => {
- if (hash) {
- // We want to reset if the hash has changed
- if (hashRef.current !== hash) {
- hashRef.current = hash
- scrolledRef.current = false
- }
-
- // only attempt to scroll if we haven't yet (this could have just reset above if hash changed)
- if (!scrolledRef.current) {
- const id = hash.replace("#", "")
- const element = document.getElementById(id)
- if (element) {
- element.scrollIntoView()
- scrolledRef.current = true
- }
- }
- }
- }, [tableOfContents.current])
-
- useEffect(() => {
- if (!tableOfContents.current) return
- const setCurrent: IntersectionObserverCallback = entries => {
- for (const entry of entries) {
- if (entry.isIntersecting) {
- setCurrentID(entry.target.id)
- break
- }
- }
- }
-
- const observerOptions: IntersectionObserverInit = {
- // Negative top margin accounts for `scroll-margin`.
- // Negative bottom margin means heading needs to be towards top of viewport to trigger intersection.
- rootMargin: "0px 0% -66%",
- }
-
- const headingsObserver = new IntersectionObserver(setCurrent, observerOptions)
- // Observe all the h2 in the main page content.
- document.querySelectorAll("h2").forEach((heading: HTMLHeadElement) => {
- const id = (heading.textContent as string)
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/[^a-z0-9]+/g, "-")
- heading.id = id
- headingsObserver.observe(heading)
- })
- // Stop observing when the component is unmounted.
- return () => headingsObserver.disconnect()
- }, [tableOfContents.current])
-
- useEffect(() => {
- if (!tableOfContents.current) return
- refreshHeadings()
- }, [tableOfContents.current])
-
- const refreshHeadings = () => {
- const headingList: Heading[] = []
- document.querySelectorAll("h2").forEach((heading: HTMLHeadElement) => {
- if (heading.className) return
- headingList.push({
- depth: heading.nodeName.charAt(1),
- text: heading.textContent as string,
- slug: heading.id,
- })
- })
- setHeadings(headingList)
- }
-
- return (
- <>
-
- -
-
-
- All blogs
-
-
- {headings.map(header => (
- -
- {header.text}
-
- ))}
-
- >
- )
-}
-
-export default TableOfContents
diff --git a/src/app/_blog/[blogId]/detail.tsx b/src/app/_blog/[blogId]/detail.tsx
deleted file mode 100644
index 235fcf11c..000000000
--- a/src/app/_blog/[blogId]/detail.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-"use client"
-
-import { shuffle } from "lodash"
-import { useRouter } from "next/navigation"
-import { useEffect, useMemo, useState } from "react"
-import ReactMarkdown from "react-markdown"
-import rehypeKatex from "rehype-katex"
-import rehypeRaw from "rehype-raw"
-import remarkGfm from "remark-gfm"
-import remarkMath from "remark-math"
-
-import { Box } from "@mui/material"
-import { styled } from "@mui/system"
-
-import blogSource from "@/assets/blog/main.data.json"
-import LoadingPage from "@/components/LoadingPage"
-import { LANGUAGE_MAP } from "@/constants"
-import useCheckViewport from "@/hooks/useCheckViewport"
-import useUserLanguage from "@/hooks/useUserLanguage"
-import { filterBlogsByLanguage } from "@/utils"
-
-import MoreBlogs from "./MoreBlogs"
-import { fetchBlogContent } from "./actions"
-import TOC from "./components/tableOfContents"
-
-const BlogContainer = styled(Box)(
- ({ theme }) => `
- max-width: 140rem;
- padding: 6rem 4rem 14rem;
- overflow: visible;
- display: flex;
- width: 100%;
- margin: auto;
- ${theme.breakpoints.down("md")} {
- padding: 4rem 2rem;
- display: block;
- overflow: hidden;
- };
- `,
-) as typeof Box
-
-const BlogNavbar = styled(Box)(({ theme }) => ({
- position: "sticky",
- top: "14rem",
- maxWidth: "32vw",
- [theme.breakpoints.down("md")]: {
- display: "none",
- },
-})) as typeof Box
-
-const BlogDetail = props => {
- const { blogId } = props
- const router = useRouter()
-
- const [language] = useUserLanguage()
- const [blogContent, setBlogContent] = useState(null)
- const [moreBlog, setMoreBlog] = useState([])
-
- const [loading, setLoading] = useState(true)
-
- const blogsWithLang = useMemo(() => filterBlogsByLanguage(blogSource, language), [blogSource, language])
-
- useEffect(() => {
- // TODO: no _lang_ in blogId
- async function fetchCurrentBlog() {
- const regex = /([^_]*?)_lang_[^_]+/g
- const blogIdMatch = blogId?.match(regex)
-
- const blogItemWithLang = blogSource.find(item => item.id === `${blogId}_lang_${language}`)
-
- if ((!blogIdMatch && language === "en") || (blogIdMatch && language !== "en") || (!blogIdMatch && language !== "en" && !blogItemWithLang)) {
- let anchors = [...document.querySelectorAll("a")]
- anchors.map(anchor => {
- if (anchor.href.includes("/Content/")) {
- anchor.setAttribute("target", "")
- }
- return anchor
- })
- try {
- setLoading(true)
- const text = await fetchBlogContent(blogId)
- setBlogContent(text)
- } catch (_error) {
- router.push("/404")
- } finally {
- setLoading(false)
- }
- } else if (blogIdMatch && language === "en") {
- const nextBlogId = blogId.replace(regex, "$1")
- router.push(`/blog/${nextBlogId}`)
- } else if (blogItemWithLang) {
- router.push(`/blog/${blogId}_lang_${language}`)
- }
- }
- fetchCurrentBlog()
- }, [blogId, language])
-
- useEffect(() => {
- const blogs = shuffle(blogsWithLang.filter(blog => blog.id !== blogId)).slice(0, 3)
- setMoreBlog(blogs)
- }, [blogId, blogsWithLang])
-
- const { isPortrait } = useCheckViewport()
-
- return (
-
- {loading ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
-
- {!!isPortrait && }
-
- )}
-
- )
-}
-
-export default BlogDetail
diff --git a/src/app/_blog/[blogId]/page.tsx b/src/app/_blog/[blogId]/page.tsx
deleted file mode 100644
index b4f99a0f1..000000000
--- a/src/app/_blog/[blogId]/page.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { notFound } from "next/navigation"
-
-import blogSource from "@/assets/blog/main.data.json"
-import { isSepolia } from "@/utils"
-import { genMeta } from "@/utils/route"
-
-import Detail from "./detail"
-
-export const generateMetadata = genMeta(async ({ params }) => {
- const { blogId } = await params
- const currentBlog = blogSource.find(blog => blog.id === blogId)
- const imgUrl = currentBlog?.ogImg || currentBlog?.posterImg || ""
-
- return {
- titleSuffix: currentBlog?.title,
- relativeURL: currentBlog?.canonical || `https://scroll.io/blog/${currentBlog?.id}`,
- description: currentBlog?.summary,
- ogImg: imgUrl,
- twitterImg: imgUrl,
- alternates: {
- canonical: currentBlog?.canonical,
- },
- }
-})
-
-const BlogDetail = async ({ params }) => {
- if (isSepolia) {
- notFound()
- }
- const { blogId } = await params
- return (
- <>
-
-
-
- >
- )
-}
-
-export default BlogDetail
diff --git a/src/app/_blog/layout.tsx b/src/app/_blog/layout.tsx
deleted file mode 100644
index 8ee03bbd4..000000000
--- a/src/app/_blog/layout.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { notFound } from "next/navigation"
-
-import { isSepolia } from "@/utils"
-import { genMeta } from "@/utils/route"
-
-export const generateMetadata = genMeta(() => ({
- titleSuffix: "Blog",
- relativeURL: "/blog",
-}))
-
-export default function Layout({ children }) {
- if (isSepolia) {
- notFound()
- }
- return <>{children}>
-}
diff --git a/src/app/_blog/page.tsx b/src/app/_blog/page.tsx
deleted file mode 100644
index ab6613692..000000000
--- a/src/app/_blog/page.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-"use client"
-
-import { orderBy } from "lodash"
-import { useSearchParams } from "next/navigation"
-import { useEffect, useMemo, useState } from "react"
-
-import { Tune as TuneIcon } from "@mui/icons-material"
-import { Box, Modal, Typography } from "@mui/material"
-import { styled } from "@mui/system"
-
-import blogSource from "@/assets/blog/main.data.json"
-import ArticleCard from "@/components/ArticleCard"
-import SectionWrapper from "@/components/SectionWrapper"
-import { LANGUAGE_MAP, getBlogCategoryList, getBlogSortList } from "@/constants"
-import useCheckViewport from "@/hooks/useCheckViewport"
-import { filterBlogsByLanguage } from "@/utils"
-
-const BlogContainer = styled(Box)(({ theme }) => ({
- padding: "0 6rem 14rem",
- [theme.breakpoints.down("md")]: {
- padding: "0 2rem 9rem",
- },
-})) as typeof Box
-
-const BlogBox = styled(Box)(({ theme }) => ({
- marginBottom: "9rem",
- [theme.breakpoints.down("md")]: {
- marginBottom: "0",
- padding: "3rem 0",
- "&:not(:last-of-type)": {
- borderBottom: `1px solid ${theme.vars.palette.themeBackground.highlight}`,
- },
- "&:first-of-type": {
- padding: "0 0 3rem",
- },
- },
-})) as typeof Box
-
-const Header = styled(Box)(({ theme }) => ({
- padding: "15.5rem 0",
- [theme.breakpoints.down("md")]: {
- padding: "6.8rem 0 8rem",
- },
-})) as typeof Box
-
-const Title = styled(Typography)(({ theme }) => ({
- fontSize: "6.4rem",
- lineHeight: 1,
- fontWeight: 600,
- [theme.breakpoints.down("sm")]: {
- fontSize: "3.6rem",
- },
-}))
-
-const Summary = styled(Typography)(({ theme }) => ({
- fontSize: "2.6rem",
- maxWidth: "56rem",
- marginTop: "2.4rem",
- [theme.breakpoints.down("md")]: {
- marginTop: "2rem",
- },
- [theme.breakpoints.down("sm")]: {
- fontSize: "2rem",
- },
-}))
-
-const FilterContainer = styled(Box)(({ theme }) => ({
- [theme.breakpoints.down("lg")]: {
- display: "flex",
- justifyContent: "flex-end",
- },
-})) as typeof Box
-
-const MobileFilter = styled(Box)(({ theme }) => ({
- marginBottom: "1.7rem",
- fontSize: "1.6rem",
- fontWeight: 500,
- color: theme.vars.palette.text.primary,
- cursor: "pointer",
- borderRadius: "20px",
- border: `1px solid ${theme.vars.palette.text.primary}`,
- width: "fit-content",
- padding: "0.5rem 1.2rem",
- [theme.breakpoints.between("sm", "lg")]: {
- marginBottom: "3rem",
- },
-})) as typeof Box
-
-const FilterModal = styled(Box)({
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
- height: "100vh",
-}) as typeof Box
-
-const FilterModalContent = styled(Box)(({ theme }) => ({
- background: theme.vars.palette.background.default,
- borderRadius: "2rem",
- width: "35.8rem",
- padding: "1.4rem 1.8rem",
-})) as typeof Box
-
-const BlogBody = styled(Box)(({ theme }) => ({
- display: "grid",
- gap: "3rem",
- gridTemplateColumns: "1fr 4fr",
- [theme.breakpoints.down("lg")]: {
- gridTemplateColumns: "1fr",
- gap: "0",
- },
-})) as typeof Box
-
-const FilterTypeName = styled(Typography)(({ theme }) => ({
- color: theme.vars.palette.text.primary,
- fontSize: "1.6rem",
- fontWeight: 600,
- marginBottom: "2rem",
- "&:nth-of-type(2)": {
- marginTop: "6.8rem",
- },
- [theme.breakpoints.down("lg")]: {
- height: "4rem",
- lineHeight: "4rem",
- marginBottom: 0,
- fontSize: "2rem",
- "&:nth-of-type(2)": {
- marginTop: "5rem",
- },
- },
-}))
-
-const FilterItem = styled(Typography)(({ theme }) => ({
- color: theme.vars.palette.text.primary,
- cursor: "pointer",
- fontSize: "1.6rem",
- marginBottom: "1.2rem",
- "&.active": {
- fontWeight: 700,
- },
- "&:hover": {
- fontWeight: 700,
- },
- [theme.breakpoints.down("lg")]: {
- fontWeight: 500,
- height: "3.6rem",
- lineHeight: "3.6rem",
- marginBottom: 0,
- fontSize: "1.8rem",
- },
-}))
-
-const BlogList = styled("ul")(({ theme }) => ({
- display: "flex",
- flexDirection: "column",
- justifyContent: "space-between",
- width: "100%",
- [theme.breakpoints.down("md")]: {
- borderRight: "none",
- marginBottom: "0",
- justifyContent: "center",
- },
-}))
-
-const Blog = () => {
- const searchParams = useSearchParams()
- const { isDesktop } = useCheckViewport()
- const language = "en"
- const BLOG_CATEGORY_LIST = useMemo(() => getBlogCategoryList(language), [language])
- const BLOG_SORT_LIST = useMemo(() => getBlogSortList(language), [language])
- const BLOG_COPY = useMemo(() => LANGUAGE_MAP[language], [language])
- const [filterOpen, setFilterOpen] = useState(false)
- const handleFilterOpen = () => setFilterOpen(true)
- const handleFilterClose = () => setFilterOpen(false)
-
- const [blogs, setBlogs] = useState(blogSource)
- const [queryForm, setQueryForm] = useState({
- sort: "Newest",
- category: searchParams?.get("category") ?? "All",
- })
-
- const blogsWithLang = useMemo(() => filterBlogsByLanguage(blogSource, language), [blogSource, language])
-
- useEffect(() => {
- const blogs = orderBy(
- blogsWithLang.filter(blog => blog.type === queryForm.category || queryForm.category === "All"),
- "date",
- queryForm.sort === "Newest" ? "desc" : "asc",
- )
- setBlogs(blogs)
- }, [queryForm, blogsWithLang])
-
- const hanleFilter = (attr: string, value: string) => {
- handleFilterClose()
- setQueryForm({
- ...queryForm,
- [attr]: value,
- })
- }
-
- const renderBlogs = () => {
- return (
-
- {blogs.map(blog => (
-
-
-
- ))}
-
- )
- }
-
- const renderFilter = () => {
- if (isDesktop) {
- return (
-
- {BLOG_COPY.category}
- {BLOG_CATEGORY_LIST.map(({ label, key }) => (
- hanleFilter("category", key)} key={key} className={key === queryForm.category ? "active" : ""}>
- {label}
-
- ))}
-
- {BLOG_COPY.sort}
- {BLOG_SORT_LIST.map(({ label, key }) => (
- hanleFilter("sort", key)} key={key} className={key === queryForm.sort ? "active" : ""}>
- {label}
-
- ))}
-
- )
- }
- return (
-
-
-
- {BLOG_COPY.filters}
-
-
-
-
-
- Filters
-
-
-
- Category
- {BLOG_CATEGORY_LIST.map(({ label, key }) => (
- hanleFilter("category", key)} key={key} className={key === queryForm.category ? "active" : ""}>
- {label}
-
- ))}
- Order by
- {BLOG_SORT_LIST.map(({ label, key }) => (
- hanleFilter("sort", key)} key={key} className={key === queryForm.sort ? "active" : ""}>
- {label}
-
- ))}
-
-
-
-
- )
- }
-
- return (
-
-
-
- {BLOG_COPY.title}
- {BLOG_COPY.sub_title}
-
-
- {renderFilter()}
- {renderBlogs()}
-
-
-
- )
-}
-
-export default Blog
diff --git a/src/app/_components/LandingFooter.tsx b/src/app/_components/LandingFooter.tsx
index b33544d15..2b9e804ee 100644
--- a/src/app/_components/LandingFooter.tsx
+++ b/src/app/_components/LandingFooter.tsx
@@ -1,6 +1,7 @@
import Link from "next/link"
import ScrollMarkSvg from "@/assets/svgs/landingpage/scroll-mark.svg"
+import { DOC_URL } from "@/constants/link"
import AnchorLink from "./AnchorLink"
import FooterRidge from "./FooterRidge"
@@ -28,6 +29,17 @@ const FOOTER_COLUMNS = [
{ label: "AI hardware", href: "/#hardware" },
],
},
+ {
+ title: "Resources",
+ links: [
+ // the blog, back on the site and opening the column (Zhengqi, 2026-09-21); the three
+ // under it are the ones his file carries, so the two footers finally say the same thing
+ { label: "Blog", href: "/blog" },
+ { label: "Documentation", href: DOC_URL, external: true },
+ { label: "White paper", href: "/files/whitepaper.pdf", external: true },
+ { label: "Scroll swap", href: "https://swap.scroll.io/swap?input=0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", external: true },
+ ],
+ },
]
interface LandingFooterProps {
@@ -55,8 +67,12 @@ const LandingFooter = ({ ridge = false }: LandingFooterProps) => (
{FOOTER_COLUMNS.map(({ title, links }) => (