feat(theme): add a capabilities section band

- Replace the techStack band with project-type capabilities
- Delete the TechStack component and its section type
- Give grid cards an optional status badge
- Pad --big buttons, which Rspress ships with none
- Revise the home, services and products copy
This commit is contained in:
zihluwang committed 2026-09-29 12:21:25 +08:00
1 parent f51407b98b
commit e149401346
14 files changed
+331 -292

No files matched your search

+6 -6
View File
@@ -1,7 +1,7 @@
import { useFrontmatter } from "@rspress/core/runtime"
import { SectionBand } from "./sections/SectionBand"
import { FeatureGrid } from "./sections/FeatureGrid"
import { TechStack } from "./sections/TechStack"
import { Capabilities } from "./sections/Capabilities"
import { Cases } from "./sections/Cases"
import { CallToAction } from "./sections/CallToAction"
import { hasContent, isHoisted, type Section } from "./sections/types"
@@ -13,7 +13,7 @@ function isSection(value: unknown): value is Section {
return (
type === "advantages" ||
type === "contentGrid" ||
type === "techStack" ||
type === "capabilities" ||
type === "cases" ||
type === "cta"
)
@@ -24,8 +24,8 @@ function renderBody(section: Section) {
case "advantages":
case "contentGrid":
return <FeatureGrid items={section.items} />
case "techStack":
return <TechStack groups={section.groups} />
case "capabilities":
return <Capabilities items={section.items} />
case "cases":
return <Cases items={section.items} />
case "cta":
@@ -47,8 +47,8 @@ interface HomeSectionsProps {
*
* Attached to the `Layout` `afterFeatures` (home) and `afterDoc` (products,
* services) slots, and to the `DocContent` override for hoisted bands. Returns
* `null` for any page without a `sections` array, so it is inert on blog posts,
* project pages and every other doc page.
* `null` for any page without a `sections` array, so it is inert on every doc
* page that does not opt in — the legal pages included.
*/
export function HomeSections({ placement }: HomeSectionsProps = {}) {
const { frontmatter } = useFrontmatter()
+1 -5
View File
@@ -32,11 +32,7 @@ export function CallToAction({
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">
href={str(action.link) as string}>
{str(action.text)}
</Button>
))}
@@ -0,0 +1,57 @@
import { arr, str, type Capability } from "./types"
/**
* Direction cards: the kinds of project we take on.
*
* The buying question is "what can you build for me", not "what do you build
* with", so each card names a project category and the concrete deliverables
* under it. The fill comes from `rp-capability-card` in `index.css` rather than
* a `bg-` utility, because the band alternates between the page background and
* `--rp-c-bg-soft` and the card has to invert against whichever it lands on.
*/
export function Capabilities({ items }: { items?: Capability[] }) {
const cards = (arr<Capability>(items) ?? [])
.map((item) => ({
title: str(item?.title) ?? "",
details: str(item?.details),
icon: str(item?.icon),
// `arr<string>` is an assertion, not a guarantee — frontmatter is parsed by
// gray-matter at runtime and takes no part in MDX type checking, so each
// entry still goes through `str` before it reaches the DOM.
entries: (arr<string>(item?.subItems) ?? []).map((entry) => str(entry)),
}))
.filter((card) => card.title !== "")
if (cards.length === 0) return null
return (
<ul className="m-0 grid list-none grid-cols-1 gap-6 p-0 md:grid-cols-3">
{cards.map(({ title, details, icon, entries }, index) => (
<li
key={`${title}-${index}`}
className="rp-capability-card flex flex-col rounded-xl border border-(--rp-c-divider-light) p-6">
{icon && (
<span aria-hidden="true" className="text-2xl">
{icon}
</span>
)}
<h3 className="mt-3 mb-0 text-lg font-semibold text-(--rp-c-text-1)">{title}</h3>
{details && (
<p className="mt-2 mb-0 text-sm leading-relaxed text-(--rp-c-text-2)">{details}</p>
)}
{entries.length > 0 && (
<ul className="mt-4 mb-0 list-disc pl-5 text-sm leading-relaxed text-(--rp-c-text-2)">
{entries.map((entry, entryIndex) =>
entry ? (
<li key={`${entry}-${entryIndex}`} className="m-0">
{entry}
</li>
) : null
)}
</ul>
)}
</li>
))}
</ul>
)
}
+26 -3
View File
@@ -1,15 +1,38 @@
import type { Feature } from "@rspress/core"
import { HomeFeature } from "@rspress/core/theme-original"
import { arr } from "./types"
import { arr, str, type FeatureCard } from "./types"
/** The badge is interpolated into markup, so it cannot carry its own markup. */
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
/**
* 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.
*
* A card's `badge` is folded into the front of `details` as a span, because
* `HomeFeature` renders `details` through `renderHtmlOrText` (which passes HTML
* through) and exposes no other slot. Note this makes the whole `details` string
* render as HTML for badged cards — frontmatter is first-party, and
* `renderHtmlOrText` already does this for any string containing a tag.
*/
export function FeatureGrid({ items }: { items?: Feature[] }) {
const features = arr<Feature>(items)
export function FeatureGrid({ items }: { items?: FeatureCard[] }) {
const features = arr<FeatureCard>(items)?.map(({ badge, ...feature }) => {
const label = str(badge)
if (!label) return feature as Feature
const details = str(feature.details) ?? ""
return {
...feature,
details: `<span class="rp-card-badge">${escapeHtml(label)}</span>${details}`,
} as Feature
})
if (!features) return null
return <HomeFeature features={features} />
}
-54
View File
@@ -1,54 +0,0 @@
import { arr, str, type TechStackGroup, type TechStackItem } from "./types"
/**
* Technology matrix: named groups of technologies, each with an optional
* emoji/icon and link.
*/
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>
)
}
+37 -17
View File
@@ -8,15 +8,37 @@ import type { Feature } from "@rspress/core"
* checking — every field must therefore be defended at the point of use.
*/
export interface TechStackItem {
name: string
icon?: string
url?: string
/**
* A `Feature` card, plus an optional status pill shown above its description —
* used to mark something as not yet released.
*
* The stock `Feature` shape has no field for this, and the `HomeFeature`
* component that renders the card (with its hover animation) is not exported in
* a form that can be extended without reimplementing it. `FeatureGrid` therefore
* folds the badge into `details`, which `HomeFeature` already renders as HTML.
*/
export interface FeatureCard extends Feature {
/** Short status label, e.g. "敬请期待". Rendered as a pill above `details`. */
badge?: string
}
export interface TechStackGroup {
name: string
items?: TechStackItem[]
/**
* One card in the `capabilities` band: a kind of project we take on.
*
* This band exists because buyers shop for "can you build me a corporate site",
* not for "do you know Docker" — so it names project categories and the
* deliverables under them, never technologies.
*/
export interface Capability {
title: string
/** One line on what the card covers. */
details?: string
icon?: string
/**
* The concrete things delivered in this direction. Named `subItems` rather
* than `items` because the card list itself is `items`.
*/
subItems?: string[]
}
export interface CaseStudy {
@@ -63,9 +85,9 @@ interface SectionBase {
}
export type Section =
| (SectionBase & { type: "advantages"; items?: Feature[] })
| (SectionBase & { type: "contentGrid"; items?: Feature[] })
| (SectionBase & { type: "techStack"; groups?: TechStackGroup[] })
| (SectionBase & { type: "advantages"; items?: FeatureCard[] })
| (SectionBase & { type: "contentGrid"; items?: FeatureCard[] })
| (SectionBase & { type: "capabilities"; items?: Capability[] })
| (SectionBase & { type: "cases"; items?: CaseStudy[] })
| (SectionBase & {
type: "cta"
@@ -95,13 +117,11 @@ 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
)
return arr<FeatureCard>(section.items) !== undefined
case "capabilities":
// `.some` rather than a bare existence check: a card with no title renders
// nothing, so a list of blank cards must not count as content.
return arr<Capability>(section.items)?.some((item) => str(item?.title)) ?? false
case "cases":
return arr<CaseStudy>(section.items) !== undefined
case "cta":