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:
14 files changed
+652
-3
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+50
-3
@@ -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 (
|
||||
<OriginalLayout
|
||||
{...props}
|
||||
bottom={
|
||||
<>
|
||||
{props.bottom}
|
||||
<SiteFooter />
|
||||
</>
|
||||
}
|
||||
afterFeatures={
|
||||
<>
|
||||
{props.afterFeatures}
|
||||
<HomeSections />
|
||||
</>
|
||||
}
|
||||
afterDoc={
|
||||
<>
|
||||
{props.afterDoc}
|
||||
<HomeSections />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export * from "@rspress/core/theme-original"
|
||||
export { DocLayout }
|
||||
export { DocLayout, Layout }
|
||||
export { Kbd } from "./components/Kbd"
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Type of the project's `i18n.json`, for use with `useI18n()`.
|
||||
*
|
||||
* `useI18n<T>()` is generic over the keys *beyond* Rspress's built-in set:
|
||||
*
|
||||
* useI18n<T>(): (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")
|
||||
@@ -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"
|
||||
@@ -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 `<svg>` element. Match the shape
|
||||
* Rspress uses for its presets so the icons sit consistently in the nav bar:
|
||||
*
|
||||
* <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 24 24">
|
||||
* <path fill="currentColor" d="…" />
|
||||
* </svg>
|
||||
*
|
||||
* `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 = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 1024 1024"><path fill="currentColor" d="M937.4 423.9c-84 0-165.7-27.3-232.9-77.8v352.3c0 179.9-138.6 325.6-309.6 325.6S85.3 878.3 85.3 698.4c0-179.9 138.6-325.6 309.6-325.6 17.1 0 33.7 1.5 49.9 4.3v186.6c-15.5-6.1-32-9.2-48.6-9.2-76.3 0-138.2 65-138.2 145.3 0 80.2 61.9 145.3 138.2 145.3 76.2 0 138.1-65.1 138.1-145.3V0H707c0 134.5 103.7 243.5 231.6 243.5v180.3l-1.2 0.1"/></svg>`
|
||||
Reference in new issue
Block a user