feat(theme): add site footer and marketing sections

- Add SiteFooter, rendered through the Layout bottom slot so it appears on
  every page type. themeConfig.footer is deliberately left unset: setting
  it would render the built-in HomeFooter as well, on the home page only.
- Add a frontmatter-driven section system (advantages, contentGrid,
  techStack, cases and cta) with a single ordered array, so bands can be
  reordered or removed without touching code.
- Bands render only when they have content, so an unfilled page degrades
  to its heading rather than showing empty boxes.
- Add a SHOW_COPY_PLACEHOLDERS flag for drafting, false by default.
This commit is contained in:
zihluwang committed 2026-09-25 14:48:42 +08:00
1 parent b2eb6b9638
commit 638c71da44
14 files changed
+652 -3

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
import { useFrontmatter } from "@rspress/core/runtime"
import { SectionBand } from "./sections/SectionBand"
import { FeatureGrid } from "./sections/FeatureGrid"
import { TechStack } from "./sections/TechStack"
import { Cases } from "./sections/Cases"
import { CallToAction } from "./sections/CallToAction"
import { hasContent, type Section } from "./sections/types"
/** Type guard for a single entry of `frontmatter.sections`. */
function isSection(value: unknown): value is Section {
if (typeof value !== "object" || value === null) return false
const type = (value as { type?: unknown }).type
return (
type === "advantages" ||
type === "contentGrid" ||
type === "techStack" ||
type === "cases" ||
type === "cta"
)
}
function renderBody(section: Section) {
switch (section.type) {
case "advantages":
case "contentGrid":
return <FeatureGrid items={section.items} />
case "techStack":
return <TechStack groups={section.groups} />
case "cases":
return <Cases items={section.items} />
case "cta":
return <CallToAction actions={section.actions} code={section.code} />
}
}
/**
* Renders `frontmatter.sections` in declaration order.
*
* Attached to the `Layout` `afterHero` (home) and `afterDoc` (products,
* services) slots. Returns `null` for any page without a `sections` array, so
* it is inert on blog posts, project pages and every other doc page.
*/
export function HomeSections() {
const { frontmatter } = useFrontmatter()
const raw = frontmatter?.sections
if (!Array.isArray(raw)) return null
const sections = raw.filter(isSection)
if (sections.length === 0) return null
return (
<div className="rp-site-sections">
{sections.map((section, index) => (
<SectionBand
key={section.id ?? `${section.type}-${index}`}
section={section}
hasContent={hasContent(section)}>
{renderBody(section)}
</SectionBand>
))}
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useFrontmatter, useI18n, useLang, useSite } from "@rspress/core/runtime"
import { Link } from "@rspress/core/theme-original"
import type { CustomI18n } from "../lib/i18n"
import { ICP_NUMBER, MIIT_URL, PRIVACY_PATH, TERMS_PATH } from "../lib/site"
/**
* Site-wide footer, injected through the `Layout` bottom slot so it appears on
* every page type.
*
* The built-in `HomeFooter` only renders when `themeConfig.footer.message` is
* set, and it has no per-locale switching — so it is deliberately left unset in
* `rspress.config.ts` and this component is the single source of the footer.
*/
export function SiteFooter() {
const t = useI18n<CustomI18n>()
const lang = useLang()
const { site } = useSite()
const { frontmatter } = useFrontmatter()
if (frontmatter?.hideSiteFooter === true) return null
// Rspress strips the language prefix from routes in the default language,
// so only non-default locales need one.
const prefix = lang === site.lang ? "" : `/${lang}`
return (
<footer className="mt-16 border-t border-(--rp-c-divider-light)">
<div
className="mx-auto flex max-w-5xl flex-col gap-3 px-6 py-8 text-sm
text-(--rp-c-text-2) md:flex-row md:items-center md:justify-between">
<p className="m-0">{t("footer.copyright")}</p>
<nav className="flex flex-wrap items-center gap-x-4 gap-y-2">
{/* `Link` already applies the `rp-link` class itself. */}
<Link href={`${prefix}${PRIVACY_PATH}`}>{t("footer.privacy")}</Link>
<Link href={`${prefix}${TERMS_PATH}`}>{t("footer.terms")}</Link>
{/* The filing number must stay visible text and link to the MIIT
portal, per Chinese ICP requirements. */}
<a className="rp-link" href={MIIT_URL} target="_blank" rel="noopener noreferrer">
{ICP_NUMBER}
</a>
</nav>
</div>
</footer>
)
}
@@ -0,0 +1,67 @@
import { Button } from "@rspress/core/theme-original"
import { arr, str, type CallToActionLink, type ContactCode } from "./types"
/**
* Closing call-to-action band: one or more buttons, optionally alongside a
* scannable contact code (a WeChat QR, for instance).
*
* `Button` renders its own `<a>` (routed through the theme's `Link`), so it
* must be given `href` directly rather than being wrapped in an anchor.
*/
export function CallToAction({
actions,
code,
}: {
actions?: CallToActionLink[]
code?: ContactCode
}) {
const links = (arr<CallToActionLink>(actions) ?? []).filter(
(action) => str(action?.text) && str(action?.link)
)
const codeSrc = str(code?.src)
const codeCaption = str(code?.caption)
if (links.length === 0 && !codeSrc) return null
return (
<div className="flex flex-col items-center gap-8">
{links.length > 0 && (
<div className="flex flex-wrap justify-center gap-4">
{links.map((action, index) => (
<Button
key={`${str(action.text)}-${index}`}
type="a"
theme={action.theme === "alt" ? "alt" : "brand"}
href={str(action.link) as string}
// `.rp-button--big` sets a min-width but no horizontal padding, so
// a long label such as an email address runs into the rounded
// edges. Supply the padding ourselves.
className="px-10">
{str(action.text)}
</Button>
))}
</div>
)}
{codeSrc && (
<figure className="m-0 flex flex-col items-center gap-3">
{/* The white frame is not decoration. A QR code needs a quiet zone of
blank space around it to scan reliably, and the exported image has
almost none — the pattern runs close to its edges. */}
<div className="rounded-xl border border-(--rp-c-divider-light) bg-white p-4">
<img
src={codeSrc}
alt={codeCaption ?? ""}
width={160}
height={160}
className="block h-40 w-40"
/>
</div>
{codeCaption && (
<figcaption className="text-sm text-(--rp-c-text-2)">{codeCaption}</figcaption>
)}
</figure>
)}
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from "react"
import { arr, str, type CaseStudy } from "./types"
const ROTATE_MS = 8000
/**
* Customer case-study carousel.
*
* TODO(copy): populate `frontmatter.sections` with real case studies. With no
* items this renders nothing; with exactly one it degrades to a static card.
*
* Rotation is suspended when the visitor prefers reduced motion, and pauses
* while the carousel is hovered or focused.
*/
export function Cases({ items }: { items?: CaseStudy[] }) {
const studies = (arr<CaseStudy>(items) ?? []).filter((item) => str(item?.company))
const [active, setActive] = useState(0)
const [paused, setPaused] = useState(false)
const count = studies.length
const go = useCallback(
(next: number) => setActive(count > 0 ? ((next % count) + count) % count : 0),
[count]
)
useEffect(() => {
if (count <= 1 || paused) return
if (
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
) {
return
}
const timer = window.setInterval(() => setActive((current) => (current + 1) % count), ROTATE_MS)
return () => window.clearInterval(timer)
}, [count, paused])
if (count === 0) return null
const current = studies[Math.min(active, count - 1)]
const company = str(current.company) ?? ""
const quote = str(current.quote)
const details = str(current.details)
const author = str(current.author)
const role = str(current.role)
const link = str(current.link)
const logo = str(current.logo)
return (
<div
className="rounded-xl border border-(--rp-c-divider-light) bg-(--rp-c-bg-soft) p-8"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}>
<figure className="m-0 flex flex-col items-center gap-6 text-center">
{logo && <img src={logo} alt={company} height={40} className="h-10 w-auto" />}
{quote && (
<blockquote className="m-0 max-w-3xl text-lg leading-relaxed text-(--rp-c-text-1)">
{quote}
</blockquote>
)}
{/* Without an approved testimonial, describe the engagement instead. */}
{!quote && details && (
<p className="m-0 max-w-2xl text-base leading-relaxed text-(--rp-c-text-1)">{details}</p>
)}
<figcaption className="text-sm text-(--rp-c-text-2)">
{author && <span className="font-medium text-(--rp-c-text-1)">{author}</span>}
{author && role && <span> · </span>}
{role}
{link ? (
<>
{" · "}
<a className="rp-link" href={link}>
{company}
</a>
</>
) : (
company && <span> · {company}</span>
)}
</figcaption>
</figure>
{count > 1 && (
<div className="mt-6 flex justify-center gap-2">
{studies.map((study, index) => (
<button
key={`${str(study.company)}-${index}`}
type="button"
aria-label={str(study.company)}
aria-current={index === active}
onClick={() => go(index)}
className={`h-2 w-2 cursor-pointer rounded-full border-0 p-0 transition-colors ${
index === active ? "bg-(--rp-c-brand)" : "bg-(--rp-c-divider)"
}`}
/>
))}
</div>
)}
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import type { Feature } from "@rspress/core"
import { HomeFeature } from "@rspress/core/theme-original"
import { arr } from "./types"
/**
* Card grid used for both the homepage advantages and the products/services
* grids. Delegates to the stock `HomeFeature`, which already handles `span`,
* icons and the reveal animation — it accepts a `features` prop precisely so it
* can be driven from somewhere other than page frontmatter.
*/
export function FeatureGrid({ items }: { items?: Feature[] }) {
const features = arr<Feature>(items)
if (!features) return null
return <HomeFeature features={features} />
}
+49
View File
@@ -0,0 +1,49 @@
import type { ReactNode } from "react"
import { SHOW_COPY_PLACEHOLDERS } from "./placeholder"
import { str, type Section } from "./types"
interface SectionBandProps {
section: Section
/** Whether the section body produced any content. */
hasContent: boolean
children: ReactNode
}
/**
* Shell for a marketing band: the `<section>` wrapper, its heading and its
* subtitle. Renders nothing at all when the section has neither content nor a
* heading, so an unfilled page degrades to just its hero.
*/
export function SectionBand({ section, hasContent, children }: SectionBandProps) {
const title = str(section.title)
const subtitle = str(section.subtitle)
if (!hasContent && !title && !subtitle) {
if (!SHOW_COPY_PLACEHOLDERS) return null
return (
<section id={section.id} className="rp-site-section">
<div className="mx-auto max-w-5xl px-6">
<div className="rounded-lg border border-dashed border-(--rp-c-divider) p-8 text-center text-sm text-(--rp-c-text-3)">
TODO: {section.type}
</div>
</div>
</section>
)
}
return (
<section id={section.id} className="rp-site-section">
<div className="mx-auto max-w-5xl px-6">
{(title || subtitle) && (
<header className="mb-10 text-center">
{title && <h2 className="m-0 text-3xl font-semibold text-(--rp-c-text-1)">{title}</h2>}
{subtitle && (
<p className="mx-auto mt-3 max-w-2xl text-base text-(--rp-c-text-2)">{subtitle}</p>
)}
</header>
)}
{children}
</div>
</section>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { arr, str, type TechStackGroup, type TechStackItem } from "./types"
/**
* Technology matrix: named groups of technologies, each with an optional
* emoji/icon and link.
*
* TODO(copy): populate `frontmatter.sections` with real entries. Groups with no
* items are skipped, so a half-filled list still renders sensibly.
*/
export function TechStack({ groups }: { groups?: TechStackGroup[] }) {
const visible = (arr<TechStackGroup>(groups) ?? [])
.map((group) => ({ group, items: arr<TechStackItem>(group?.items) ?? [] }))
.filter(({ items }) => items.length > 0)
if (visible.length === 0) return null
return (
<div className="flex flex-col gap-10">
{visible.map(({ group, items }, groupIndex) => (
<div key={str(group.name) ?? `group-${groupIndex}`}>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-(--rp-c-text-3)">
{str(group.name) ?? ""}
</h3>
<ul className="m-0 grid list-none grid-cols-2 gap-3 p-0 sm:grid-cols-3 md:grid-cols-4">
{items.map((item, itemIndex) => {
const name = str(item?.name)
if (!name) return null
const url = str(item?.url)
const icon = str(item?.icon)
const body = (
<span className="flex items-center gap-2">
{icon && <span aria-hidden="true">{icon}</span>}
<span>{name}</span>
</span>
)
return (
<li key={`${name}-${itemIndex}`} className="m-0">
{url ? (
<a
className="rp-link flex items-center rounded-md border border-(--rp-c-divider-light) px-3 py-2 no-underline transition-colors hover:bg-(--rp-c-bg-mute)"
href={url}>
{body}
</a>
) : (
<span className="flex items-center rounded-md border border-(--rp-c-divider-light) px-3 py-2">
{body}
</span>
)}
</li>
)
})}
</ul>
</div>
))}
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Flip to `true` while drafting copy: sections with no content then render a
* dashed outline naming their type, instead of collapsing to nothing.
*
* MUST be `false` before release — confirm this file is the only match:
* grep -rn SHOW_COPY_PLACEHOLDERS theme/
*
* A plain constant is used rather than `import.meta.env.DEV` because the theme
* bundle does not guarantee that variable is inlined.
*/
export const SHOW_COPY_PLACEHOLDERS = false
+102
View File
@@ -0,0 +1,102 @@
import type { Feature } from "@rspress/core"
/**
* Shape of a `frontmatter.sections` entry.
*
* `FrontMatterMeta` ends with an index signature so `sections` is accepted, but
* frontmatter is parsed at runtime by gray-matter and takes no part in MDX type
* checking — every field must therefore be defended at the point of use.
*/
export interface TechStackItem {
name: string
icon?: string
url?: string
}
export interface TechStackGroup {
name: string
items?: TechStackItem[]
}
export interface CaseStudy {
company: string
logo?: string
/** A client testimonial. Only use one the client has actually approved. */
quote?: string
author?: string
role?: string
link?: string
/**
* What the engagement was. Rendered instead of `quote` when there is no
* approved testimonial — far better than showing a bare company name.
*/
details?: string
}
export interface CallToActionLink {
theme?: "brand" | "alt"
text: string
link: string
}
/** A scannable code, such as a WeChat contact QR. `src` is a public asset path. */
export interface ContactCode {
src: string
caption?: string
}
interface SectionBase {
id?: string
title?: string
subtitle?: string
}
export type Section =
| (SectionBase & { type: "advantages"; items?: Feature[] })
| (SectionBase & { type: "contentGrid"; items?: Feature[] })
| (SectionBase & { type: "techStack"; groups?: TechStackGroup[] })
| (SectionBase & { type: "cases"; items?: CaseStudy[] })
| (SectionBase & {
type: "cta"
actions?: CallToActionLink[]
code?: ContactCode
})
export type SectionType = Section["type"]
/** Returns a trimmed string, or `undefined` when the value is missing or blank. */
export function str(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined
}
/** Returns a non-empty array, or `undefined`. */
export function arr<T>(value: unknown): T[] | undefined {
return Array.isArray(value) && value.length > 0 ? (value as T[]) : undefined
}
/**
* Whether a section would render any body content.
*
* Shared by the renderers (which return `null` when empty) and by
* `SectionBand`, so the two can never disagree about whether a band is empty.
*/
export function hasContent(section: Section): boolean {
switch (section.type) {
case "advantages":
case "contentGrid":
return arr<Feature>(section.items) !== undefined
case "techStack":
return (
arr<TechStackGroup>(section.groups)?.some(
(group) => arr<TechStackItem>(group?.items) !== undefined
) ?? false
)
case "cases":
return arr<CaseStudy>(section.items) !== undefined
case "cta":
return (
arr<CallToActionLink>(section.actions) !== undefined || str(section.code?.src) !== undefined
)
}
}