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:
14 files changed
+331
-292
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user