diff --git a/theme/components/HomeSections.tsx b/theme/components/HomeSections.tsx new file mode 100644 index 0000000..0f49ae5 --- /dev/null +++ b/theme/components/HomeSections.tsx @@ -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 + case "techStack": + return + case "cases": + return + case "cta": + return + } +} + +/** + * 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 ( +
+ {sections.map((section, index) => ( + + {renderBody(section)} + + ))} +
+ ) +} diff --git a/theme/components/SiteFooter.tsx b/theme/components/SiteFooter.tsx new file mode 100644 index 0000000..fcde369 --- /dev/null +++ b/theme/components/SiteFooter.tsx @@ -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() + 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 ( +
+
+

{t("footer.copyright")}

+ +
+
+ ) +} diff --git a/theme/components/sections/CallToAction.tsx b/theme/components/sections/CallToAction.tsx new file mode 100644 index 0000000..256fd0f --- /dev/null +++ b/theme/components/sections/CallToAction.tsx @@ -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 `` (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(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 ( +
+ {links.length > 0 && ( +
+ {links.map((action, index) => ( + + ))} +
+ )} + + {codeSrc && ( +
+ {/* 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. */} +
+ {codeCaption +
+ {codeCaption && ( +
{codeCaption}
+ )} +
+ )} +
+ ) +} diff --git a/theme/components/sections/Cases.tsx b/theme/components/sections/Cases.tsx new file mode 100644 index 0000000..967ef34 --- /dev/null +++ b/theme/components/sections/Cases.tsx @@ -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(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 ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocus={() => setPaused(true)} + onBlur={() => setPaused(false)}> +
+ {logo && {company}} + {quote && ( +
+ {quote} +
+ )} + {/* Without an approved testimonial, describe the engagement instead. */} + {!quote && details && ( +

{details}

+ )} +
+ {author && {author}} + {author && role && · } + {role} + {link ? ( + <> + {" · "} + + {company} + + + ) : ( + company && · {company} + )} +
+
+ + {count > 1 && ( +
+ {studies.map((study, index) => ( +
+ )} +
+ ) +} diff --git a/theme/components/sections/FeatureGrid.tsx b/theme/components/sections/FeatureGrid.tsx new file mode 100644 index 0000000..eec8c48 --- /dev/null +++ b/theme/components/sections/FeatureGrid.tsx @@ -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(items) + if (!features) return null + return +} diff --git a/theme/components/sections/SectionBand.tsx b/theme/components/sections/SectionBand.tsx new file mode 100644 index 0000000..c1cef93 --- /dev/null +++ b/theme/components/sections/SectionBand.tsx @@ -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 `
` 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 ( +
+
+
+ TODO: {section.type} +
+
+
+ ) + } + + return ( +
+
+ {(title || subtitle) && ( +
+ {title &&

{title}

} + {subtitle && ( +

{subtitle}

+ )} +
+ )} + {children} +
+
+ ) +} diff --git a/theme/components/sections/TechStack.tsx b/theme/components/sections/TechStack.tsx new file mode 100644 index 0000000..dbfa750 --- /dev/null +++ b/theme/components/sections/TechStack.tsx @@ -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(groups) ?? []) + .map((group) => ({ group, items: arr(group?.items) ?? [] })) + .filter(({ items }) => items.length > 0) + + if (visible.length === 0) return null + + return ( +
+ {visible.map(({ group, items }, groupIndex) => ( +
+

+ {str(group.name) ?? ""} +

+
    + {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 = ( + + {icon && } + {name} + + ) + return ( +
  • + {url ? ( + + {body} + + ) : ( + + {body} + + )} +
  • + ) + })} +
+
+ ))} +
+ ) +} diff --git a/theme/components/sections/placeholder.ts b/theme/components/sections/placeholder.ts new file mode 100644 index 0000000..aa41027 --- /dev/null +++ b/theme/components/sections/placeholder.ts @@ -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 diff --git a/theme/components/sections/types.ts b/theme/components/sections/types.ts new file mode 100644 index 0000000..a42423d --- /dev/null +++ b/theme/components/sections/types.ts @@ -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(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(section.items) !== undefined + case "techStack": + return ( + arr(section.groups)?.some( + (group) => arr(group?.items) !== undefined + ) ?? false + ) + case "cases": + return arr(section.items) !== undefined + case "cta": + return ( + arr(section.actions) !== undefined || str(section.code?.src) !== undefined + ) + } +} diff --git a/theme/index.css b/theme/index.css index bd6817f..333c4bb 100644 --- a/theme/index.css +++ b/theme/index.css @@ -4,4 +4,26 @@ --rp-c-brand: #0f766e; --rp-c-brand-dark: #115e59; --rp-c-brand-tint: rgba(15, 118, 110, 0.16); + + /* Vertical rhythm for the marketing bands on the home, products and + services pages. */ + --rp-site-section-gap: 4rem; +} + +/* `HomeFeature` always renders its wrapper, even when the page declares no + `features`. Hide the empty shell so a hero-only page does not show a + stray empty band. */ +.rp-home-feature:empty { + display: none; + border: 0; + padding: 0; +} + +/* Full-bleed marketing sections, driven by `frontmatter.sections`. */ +.rp-site-section { + padding-block: var(--rp-site-section-gap); +} + +.rp-site-section:nth-child(even) { + background: var(--rp-c-bg-soft); } diff --git a/theme/index.tsx b/theme/index.tsx index 828269e..dfa41d6 100644 --- a/theme/index.tsx +++ b/theme/index.tsx @@ -1,8 +1,13 @@ import "./index.css" -import type { DocLayoutProps } from "@rspress/core/theme-original" -import { DocLayout as OriginalDocLayout } from "@rspress/core/theme-original" +import type { DocLayoutProps, LayoutProps } from "@rspress/core/theme-original" +import { + DocLayout as OriginalDocLayout, + Layout as OriginalLayout, +} from "@rspress/core/theme-original" import { Tags } from "./components/Tags" import { Author } from "./components/Author" +import { SiteFooter } from "./components/SiteFooter" +import { HomeSections } from "./components/HomeSections" function DocLayout(props: DocLayoutProps) { return ( @@ -24,6 +29,48 @@ function DocLayout(props: DocLayoutProps) { ) } +/** + * Wraps the stock `Layout` to add the site-wide footer and the frontmatter + * driven marketing sections. + * + * `bottom` is rendered for every page type (home, doc, doc-wide, custom and + * blank), so this is the one place the footer needs to be attached. Note that + * the original `Layout` resolves `DocLayout` from `@rspress/core/theme` — the + * alias for this directory — so the override above is picked up automatically. + * + * `afterFeatures` covers the home page and sits below the stock hero and + * features grid; `afterDoc` covers products, services and any other doc page + * that declares `sections`. `afterDoc` is a sibling of the doc container rather + * than part of the prose column, so sections placed there stay full-bleed. + * `HomeSections` renders nothing without the frontmatter, and the two slots are + * never both live on the same page. + */ +function Layout(props: LayoutProps) { + return ( + + {props.bottom} + + + } + afterFeatures={ + <> + {props.afterFeatures} + + + } + afterDoc={ + <> + {props.afterDoc} + + + } + /> + ) +} + export * from "@rspress/core/theme-original" -export { DocLayout } +export { DocLayout, Layout } export { Kbd } from "./components/Kbd" diff --git a/theme/lib/i18n.ts b/theme/lib/i18n.ts new file mode 100644 index 0000000..439b087 --- /dev/null +++ b/theme/lib/i18n.ts @@ -0,0 +1,16 @@ +/** + * Type of the project's `i18n.json`, for use with `useI18n()`. + * + * `useI18n()` is generic over the keys *beyond* Rspress's built-in set: + * + * useI18n(): (key: keyof (T & I18nText), params?) => string + * + * Without a type argument, `T` defaults to `unknown` and only the built-in keys + * type-check — looking up one of our own keys (`footer.copyright`, say) is then + * an error. Passing this type fixes that for every key in `i18n.json`, and stays + * correct automatically as keys are added. + * + * `typeof import(...)` is a type-level import, so this costs nothing at runtime + * and the JSON is not pulled into the client bundle. + */ +export type CustomI18n = typeof import("../../i18n.json") diff --git a/theme/lib/site.ts b/theme/lib/site.ts new file mode 100644 index 0000000..5f375fa --- /dev/null +++ b/theme/lib/site.ts @@ -0,0 +1,21 @@ +/** + * Site-wide constants shared by theme components. + * + * Kept here rather than in `rspress.config.ts` so the client bundle can import + * them without pulling in the Node-side config. + */ + +/** + * Mainland China ICP filing number, as issued. + * + * Shown on both targets: the `.com` site redirects mainland-China visitors to + * `.cn`, so it is the filing number for the domain they land on either way. + */ +export const ICP_NUMBER = "湘ICP备2026042635号-1" + +/** Ministry of Industry and Information Technology filing portal. */ +export const MIIT_URL = "https://beian.miit.gov.cn/" + +/** Locale-relative paths. Prefix with the active language before use. */ +export const PRIVACY_PATH = "/legal/privacy" +export const TERMS_PATH = "/legal/terms" diff --git a/theme/lib/social-icons.ts b/theme/lib/social-icons.ts new file mode 100644 index 0000000..81142be --- /dev/null +++ b/theme/lib/social-icons.ts @@ -0,0 +1,32 @@ +/** + * Inline SVG markup for social links that Rspress ships no preset icon for. + * + * Values are injected verbatim as innerHTML by the theme's `SocialLink` + * component, so each must be a complete `` element. Match the shape + * Rspress uses for its presets so the icons sit consistently in the nav bar: + * + * + * + * + * + * `width="100%"` lets the nav bar size the glyph; the viewBox carries the + * aspect ratio, so keep whatever coordinate system the source artwork uses. + * `fill="currentColor"` is essential — the presets all set it, and without it + * a path defaults to solid black and disappears in dark mode. + * + * Rspress's built-in set covers Weibo, Bilibili, Zhihu, Juejin, QQ and WeChat, + * but not Douyin. Adding such an icon to the preset registry is not possible + * from userland — `getSocialIcons` throws on any name it does not recognise — + * so a literal SVG string is the supported route. + */ + +/** + * Official Douyin mark. + * + * The source artwork is authored on a 1024×1024 grid and fills it exactly + * (the glyph runs from y=0 to y=1024), so the viewBox is carried through + * unchanged. Editor attributes from the export (`t`, `class`, `version`, + * `p-id`, the fixed pixel `width`/`height`) have been dropped; `width="100%"` + * and `fill="currentColor"` are set here instead. + */ +export const DOUYIN_ICON = ``