Remove CMS extension system (#706)
This commit is contained in:
parent
1bdaa273e1
commit
ea6dd5cf14
@ -33,7 +33,6 @@ const nextConfig = {
|
||||
// 显式 alias,保证 Turbopack 也能解析
|
||||
config.resolve.alias = {
|
||||
...(config.resolve.alias ?? {}),
|
||||
"@cms": path.join(__dirname, "src", "lib", "cms"),
|
||||
"@components": path.join(__dirname, "src", "components"),
|
||||
"@i18n": path.join(__dirname, "src", "i18n"),
|
||||
"@lib": path.join(__dirname, "src", "lib"),
|
||||
|
||||
@ -2,23 +2,16 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { collectExtensionProviders } from '@cms/client'
|
||||
import { LanguageProvider } from '@i18n/LanguageProvider'
|
||||
import { UserProvider } from '@lib/userStore'
|
||||
import { ThemeProvider } from '@components/theme'
|
||||
|
||||
const extensionProviders = collectExtensionProviders()
|
||||
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
let tree: ReactNode = (
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<UserProvider>{children}</UserProvider>
|
||||
<UserProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</UserProvider>
|
||||
</LanguageProvider>
|
||||
)
|
||||
|
||||
for (const Provider of extensionProviders) {
|
||||
tree = <Provider>{tree}</Provider>
|
||||
}
|
||||
|
||||
return <ThemeProvider>{tree}</ThemeProvider>
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
import Link from 'next/link'
|
||||
import SearchComponent from '@components/search'
|
||||
import { getHomepagePosts } from '@cms/content'
|
||||
import { getHomepagePosts } from '@lib/marketingContent'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import './globals.css'
|
||||
import { applyExtensionLayouts, getActiveTheme } from '@cms'
|
||||
import { AppProviders } from './AppProviders'
|
||||
|
||||
export const metadata = {
|
||||
@ -11,20 +8,14 @@ export const metadata = {
|
||||
description: 'Unified tools for your cloud native stack',
|
||||
}
|
||||
|
||||
const theme = getActiveTheme()
|
||||
const ThemeProvider = theme.Provider ?? Fragment
|
||||
const htmlAttributes = theme.htmlAttributes ?? { lang: 'en' }
|
||||
const bodyClassName = theme.bodyClassName ?? 'bg-[var(--color-background)] text-[var(--color-text)]'
|
||||
const htmlAttributes = { lang: 'en' }
|
||||
const bodyClassName = 'bg-[var(--color-background)] text-[var(--color-text)]'
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const content = applyExtensionLayouts(children)
|
||||
|
||||
return (
|
||||
<html {...htmlAttributes}>
|
||||
<body className={bodyClassName}>
|
||||
<ThemeProvider>
|
||||
<AppProviders>{content}</AppProviders>
|
||||
</ThemeProvider>
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@ -5,7 +5,7 @@ import Link from 'next/link'
|
||||
|
||||
import { useLanguage } from '../../i18n/LanguageProvider'
|
||||
import { designTokens } from '@theme/designTokens'
|
||||
import type { HomepagePost } from '@cms/content'
|
||||
import type { HomepagePost } from '@lib/marketingContent'
|
||||
|
||||
const feed: Record<'zh' | 'en', { title: string; subtitle: string; cta: string }> = {
|
||||
zh: {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getHomepagePosts } from '@cms/content'
|
||||
import { getHomepagePosts } from '@lib/marketingContent'
|
||||
import CommunityFeed from './CommunityFeed'
|
||||
|
||||
export default async function CommunityFeedServer() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import ContactPanelClient from './ContactPanelClient'
|
||||
|
||||
import { getContactPanelContent } from '@cms/content'
|
||||
import { getContactPanelContent } from '@lib/marketingContent'
|
||||
|
||||
type ContactPanelProps = {
|
||||
className?: string
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
import { useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { ContactPanelContent, ContactItemContent } from '@cms/content'
|
||||
import type { ContactPanelContent, ContactItemContent } from '@lib/marketingContent'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getHomepageHero, getHeroSolutions } from '@cms/content'
|
||||
import { getHomepageHero, getHeroSolutions } from '@lib/marketingContent'
|
||||
import HeroBannerClient from './HeroBannerClient'
|
||||
|
||||
export default async function HeroBanner() {
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { HeroContent, HeroSolution } from '@cms/content'
|
||||
import type { HeroContent, HeroSolution } from '@lib/marketingContent'
|
||||
import HeroProductTabs from './HeroProductTabs'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useId, useMemo, useState, type KeyboardEvent } from 'react'
|
||||
|
||||
import type { HeroSolution } from '@cms/content'
|
||||
import type { HeroSolution } from '@lib/marketingContent'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import ProductMatrixClient from './ProductMatrixClient'
|
||||
|
||||
import { getHeroSolutions } from '@cms/content'
|
||||
import { getHeroSolutions } from '@lib/marketingContent'
|
||||
|
||||
export default async function ProductMatrix() {
|
||||
const solutions = await getHeroSolutions()
|
||||
|
||||
@ -4,7 +4,7 @@ import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { HeroSolution } from '@cms/content'
|
||||
import type { HeroSolution } from '@lib/marketingContent'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import Link from 'next/link'
|
||||
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import type { SidebarSection } from '@cms/content'
|
||||
import type { SidebarSection } from '@lib/marketingContent'
|
||||
|
||||
interface SidebarCardProps {
|
||||
section: SidebarSection
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export { collectExtensionProviders } from './extensionRuntime'
|
||||
@ -1,53 +0,0 @@
|
||||
export type TemplateName = 'default' | (string & {})
|
||||
export type ThemeName = 'default'
|
||||
export type ExtensionName = 'app-shell' | 'markdown-sync'
|
||||
|
||||
export interface ContentSourceConfig {
|
||||
type: 'filesystem' | 'git'
|
||||
/**
|
||||
* Absolute path to the root directory that stores markdown content.
|
||||
* When `type` is `git`, the directory is expected to be hydrated by a
|
||||
* GitOps workflow ahead of time.
|
||||
*/
|
||||
root: string
|
||||
/** Remote git repository used for GitOps synchronisation. */
|
||||
repository?: string
|
||||
/** Branch or tag to fetch when GitOps is enabled. */
|
||||
ref?: string
|
||||
}
|
||||
|
||||
export interface CmsConfig {
|
||||
template: TemplateName
|
||||
theme: ThemeName
|
||||
extensions: ExtensionName[]
|
||||
content: {
|
||||
/** Default namespace used when a section does not override its source. */
|
||||
defaultNamespace: string
|
||||
sources: Record<string, ContentSourceConfig>
|
||||
}
|
||||
}
|
||||
|
||||
export const cmsConfig: CmsConfig = {
|
||||
template: 'default',
|
||||
theme: 'default',
|
||||
extensions: ['app-shell', 'markdown-sync'],
|
||||
content: {
|
||||
defaultNamespace: 'homepage',
|
||||
sources: {
|
||||
homepage: {
|
||||
type: 'filesystem',
|
||||
root: 'src/lib/cms/content/homepage',
|
||||
},
|
||||
docs: {
|
||||
type: 'filesystem',
|
||||
root: 'src/lib/cms/content/docs',
|
||||
},
|
||||
marketing: {
|
||||
type: 'git',
|
||||
root: 'cms/content/marketing',
|
||||
repository: 'git@github.com:example/xcontrol-marketing.git',
|
||||
ref: 'main',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useMemo } from 'react'
|
||||
|
||||
import { listContentSourcesMetadata } from '../contentSources'
|
||||
|
||||
type ContentSourceState = {
|
||||
namespace: string
|
||||
type: 'filesystem' | 'git'
|
||||
repository?: string
|
||||
ref?: string
|
||||
}
|
||||
|
||||
const MarkdownContentContext = createContext<ContentSourceState[]>([])
|
||||
|
||||
export function MarkdownContentProvider({ children }: { children: ReactNode }) {
|
||||
const sources = useMemo(() => {
|
||||
return listContentSourcesMetadata()
|
||||
}, [])
|
||||
|
||||
return <MarkdownContentContext.Provider value={sources}>{children}</MarkdownContentContext.Provider>
|
||||
}
|
||||
|
||||
export function useMarkdownSources() {
|
||||
return useContext(MarkdownContentContext)
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
# Getting Started
|
||||
|
||||
Welcome to the docs! This is a placeholder document.
|
||||
@ -1,359 +0,0 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { readMarkdownDirectory, readMarkdownFile } from '../../../lib/markdown'
|
||||
import { resolveContentSource } from './source'
|
||||
import { isFeatureEnabled } from '../../../lib/featureToggles'
|
||||
|
||||
export interface HeroContent {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
primaryCtaLabel?: string
|
||||
primaryCtaHref?: string
|
||||
secondaryCtaLabel?: string
|
||||
secondaryCtaHref?: string
|
||||
highlights: string[]
|
||||
bodyHtml: string
|
||||
}
|
||||
|
||||
export interface HeroSolution {
|
||||
slug: string
|
||||
title: string
|
||||
tagline?: string
|
||||
description?: string
|
||||
features: string[]
|
||||
bodyHtml: string
|
||||
primaryCtaLabel?: string
|
||||
primaryCtaHref?: string
|
||||
secondaryCtaLabel?: string
|
||||
secondaryCtaHref?: string
|
||||
tertiaryCtaLabel?: string
|
||||
tertiaryCtaHref?: string
|
||||
}
|
||||
|
||||
export interface HomepagePost {
|
||||
slug: string
|
||||
title: string
|
||||
author?: string
|
||||
date?: string
|
||||
readingTime?: string
|
||||
tags: string[]
|
||||
excerpt: string
|
||||
contentHtml: string
|
||||
}
|
||||
|
||||
export interface SidebarSection {
|
||||
slug: string
|
||||
title: string
|
||||
layout?: string
|
||||
tags: string[]
|
||||
bodyHtml: string
|
||||
ctaLabel?: string
|
||||
ctaHref?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface ContactItemContent {
|
||||
slug: string
|
||||
title: string
|
||||
type?: string
|
||||
description?: string
|
||||
bodyHtml: string
|
||||
qrValue?: string
|
||||
qrImage?: string
|
||||
icon?: string
|
||||
ctaLabel?: string
|
||||
ctaHref?: string
|
||||
}
|
||||
|
||||
export interface ContactPanelContent {
|
||||
title: string
|
||||
subtitle?: string
|
||||
bodyHtml?: string
|
||||
items: ContactItemContent[]
|
||||
}
|
||||
|
||||
const { absolutePath: HOMEPAGE_CONTENT_ROOT } = resolveContentSource('homepage')
|
||||
const CMS_HOMEPAGE_FLAG_PATH = '/homepage/dynamic'
|
||||
|
||||
const FALLBACK_HERO: HeroContent = {
|
||||
title: '欢迎来到 XControl',
|
||||
highlights: [],
|
||||
bodyHtml: '',
|
||||
}
|
||||
|
||||
const isCmsHomepageEnabled = () => isFeatureEnabled('cmsExperience', CMS_HOMEPAGE_FLAG_PATH)
|
||||
|
||||
function ensureString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const stringItem = ensureString(item)
|
||||
if (stringItem) {
|
||||
return stringItem
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function ensureStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => ensureString(item))
|
||||
.filter((item): item is string => Boolean(item && item.trim()))
|
||||
}
|
||||
const single = ensureString(value)
|
||||
return single ? [single] : []
|
||||
}
|
||||
|
||||
function ensureNumber(value: unknown): number | undefined {
|
||||
const stringValue = ensureString(value)
|
||||
if (!stringValue) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = Number(stringValue)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
function extractExcerpt(markdown: string): string {
|
||||
const blocks = markdown.split(/\r?\n\s*\r?\n/)
|
||||
for (const block of blocks) {
|
||||
const trimmed = block.trim()
|
||||
if (!trimmed) continue
|
||||
const withoutFormatting = trimmed
|
||||
.replace(/^#+\s*/g, '')
|
||||
.replace(/[`*_>\[\]]/g, '')
|
||||
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
|
||||
if (withoutFormatting.trim()) {
|
||||
return withoutFormatting.trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export async function getHomepageHero(): Promise<HeroContent> {
|
||||
if (!isCmsHomepageEnabled()) {
|
||||
return FALLBACK_HERO
|
||||
}
|
||||
|
||||
const hero = await readMarkdownFile('hero.md', { baseDir: HOMEPAGE_CONTENT_ROOT })
|
||||
|
||||
return {
|
||||
eyebrow: ensureString(hero.metadata.eyebrow),
|
||||
title: ensureString(hero.metadata.title) ?? '欢迎来到 XControl',
|
||||
subtitle: ensureString(hero.metadata.subtitle),
|
||||
primaryCtaLabel: ensureString(hero.metadata.primaryCtaLabel),
|
||||
primaryCtaHref: ensureString(hero.metadata.primaryCtaHref),
|
||||
secondaryCtaLabel: ensureString(hero.metadata.secondaryCtaLabel),
|
||||
secondaryCtaHref: ensureString(hero.metadata.secondaryCtaHref),
|
||||
highlights: ensureStringArray(hero.metadata.highlights),
|
||||
bodyHtml: hero.html,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHeroSolutions(): Promise<HeroSolution[]> {
|
||||
if (!isCmsHomepageEnabled()) {
|
||||
return []
|
||||
}
|
||||
|
||||
let solutions: (HeroSolution & { order?: number })[] = []
|
||||
try {
|
||||
const files = await readMarkdownDirectory('solutions', { baseDir: HOMEPAGE_CONTENT_ROOT })
|
||||
solutions = files.map((file) => ({
|
||||
slug: file.slug,
|
||||
title: ensureString(file.metadata.title) ?? file.slug,
|
||||
tagline: ensureString(file.metadata.tagline),
|
||||
description: ensureString(file.metadata.description),
|
||||
features: ensureStringArray(file.metadata.features),
|
||||
bodyHtml: file.html,
|
||||
primaryCtaLabel: ensureString(file.metadata.primaryCtaLabel),
|
||||
primaryCtaHref: ensureString(file.metadata.primaryCtaHref),
|
||||
secondaryCtaLabel: ensureString(file.metadata.secondaryCtaLabel),
|
||||
secondaryCtaHref: ensureString(file.metadata.secondaryCtaHref),
|
||||
tertiaryCtaLabel: ensureString(file.metadata.tertiaryCtaLabel),
|
||||
tertiaryCtaHref: ensureString(file.metadata.tertiaryCtaHref),
|
||||
order: ensureNumber(file.metadata.order),
|
||||
}))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return solutions
|
||||
.sort((a, b) => {
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order
|
||||
}
|
||||
if (a.order !== undefined) return -1
|
||||
if (b.order !== undefined) return 1
|
||||
return a.title.localeCompare(b.title, 'zh-CN')
|
||||
})
|
||||
.map(({ order: _order, ...solution }) => solution)
|
||||
}
|
||||
|
||||
export async function getHomepagePosts(): Promise<HomepagePost[]> {
|
||||
const blogContentRoot = path.join(process.cwd(), 'src', 'content', 'blog')
|
||||
|
||||
let posts: HomepagePost[] = []
|
||||
try {
|
||||
const files = await readMarkdownDirectory('', { baseDir: blogContentRoot })
|
||||
|
||||
posts = files.map((file) => {
|
||||
const title = ensureString(file.metadata.title) ?? file.slug
|
||||
const author = ensureString(file.metadata.author)
|
||||
const date = ensureString(file.metadata.date)
|
||||
const readingTime = ensureString(file.metadata.readingTime)
|
||||
const tags = ensureStringArray(file.metadata.tags)
|
||||
const excerptMetadata = ensureString(file.metadata.excerpt)
|
||||
const excerpt = excerptMetadata ?? extractExcerpt(file.content)
|
||||
|
||||
return {
|
||||
slug: file.slug,
|
||||
title,
|
||||
author,
|
||||
date,
|
||||
readingTime,
|
||||
tags,
|
||||
excerpt,
|
||||
contentHtml: file.html,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const withParsedDates = posts.map((post) => ({
|
||||
...post,
|
||||
dateValue: post.date ? new Date(post.date) : undefined,
|
||||
}))
|
||||
|
||||
withParsedDates.sort((a, b) => {
|
||||
if (a.dateValue && b.dateValue) {
|
||||
return b.dateValue.getTime() - a.dateValue.getTime()
|
||||
}
|
||||
if (a.dateValue) return -1
|
||||
if (b.dateValue) return 1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
|
||||
return withParsedDates.map(({ dateValue: _dateValue, ...post }) => post)
|
||||
}
|
||||
|
||||
export async function getSidebarSections(): Promise<SidebarSection[]> {
|
||||
if (!isCmsHomepageEnabled()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const sidebarDir = path.join('sidebar')
|
||||
let sections: SidebarSection[] = []
|
||||
try {
|
||||
const files = await readMarkdownDirectory(sidebarDir, { baseDir: HOMEPAGE_CONTENT_ROOT })
|
||||
sections = files.map((file) => ({
|
||||
slug: file.slug,
|
||||
title: ensureString(file.metadata.title) ?? file.slug,
|
||||
layout: ensureString(file.metadata.layout),
|
||||
tags: ensureStringArray(file.metadata.tags),
|
||||
bodyHtml: file.html,
|
||||
ctaLabel: ensureString(file.metadata.ctaLabel),
|
||||
ctaHref: ensureString(file.metadata.ctaHref),
|
||||
order: ensureNumber(file.metadata.order),
|
||||
}))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
.sort((a, b) => {
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order
|
||||
}
|
||||
if (a.order !== undefined) return -1
|
||||
if (b.order !== undefined) return 1
|
||||
return a.title.localeCompare(b.title, 'zh-CN')
|
||||
})
|
||||
.map(({ order: _order, ...section }) => section)
|
||||
}
|
||||
|
||||
export async function getContactPanelContent(): Promise<ContactPanelContent | undefined> {
|
||||
if (!isCmsHomepageEnabled()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let panelFile
|
||||
try {
|
||||
panelFile = await readMarkdownFile(path.join('contact', 'panel.md'), { baseDir: HOMEPAGE_CONTENT_ROOT })
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let items: (ContactItemContent & { order?: number })[] = []
|
||||
try {
|
||||
const itemFiles = await readMarkdownDirectory(path.join('contact', 'items'), {
|
||||
baseDir: HOMEPAGE_CONTENT_ROOT,
|
||||
})
|
||||
items = itemFiles.map((file) => ({
|
||||
slug: file.slug,
|
||||
title: ensureString(file.metadata.title) ?? file.slug,
|
||||
type: ensureString(file.metadata.type),
|
||||
description: ensureString(file.metadata.description),
|
||||
bodyHtml: file.html,
|
||||
qrValue: ensureString(file.metadata.qrValue),
|
||||
qrImage: ensureString(file.metadata.qrImage),
|
||||
icon: ensureString(file.metadata.icon),
|
||||
ctaLabel: ensureString(file.metadata.ctaLabel),
|
||||
ctaHref: ensureString(file.metadata.ctaHref),
|
||||
order: ensureNumber(file.metadata.order),
|
||||
}))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const sortedItems = items
|
||||
.sort((a, b) => {
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order
|
||||
}
|
||||
if (a.order !== undefined) return -1
|
||||
if (b.order !== undefined) return 1
|
||||
return a.title.localeCompare(b.title, 'zh-CN')
|
||||
})
|
||||
.map(({ order: _order, ...item }) => item)
|
||||
|
||||
return {
|
||||
title: ensureString(panelFile.metadata.title) ?? '保持联系',
|
||||
subtitle: ensureString(panelFile.metadata.subtitle),
|
||||
bodyHtml: panelFile.html,
|
||||
items: sortedItems,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContentLastUpdated(): Promise<string | undefined> {
|
||||
if (!isCmsHomepageEnabled()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(path.join(HOMEPAGE_CONTENT_ROOT, 'hero.md'))
|
||||
return stats.mtime.toISOString()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
---
|
||||
title: 欢迎支持
|
||||
type: info
|
||||
order: 4
|
||||
icon: star
|
||||
description: 欢迎支持关注 Star
|
||||
ctaLabel: 访问 GitHub
|
||||
ctaHref: https://github.com/CloudNativeSuite
|
||||
---
|
||||
点击链接访问 CloudNativeSuite GitHub,欢迎支持关注 Star,获取更多项目动态。
|
||||
@ -1,12 +0,0 @@
|
||||
---
|
||||
title: 获取支持
|
||||
type: info
|
||||
order: 3
|
||||
icon: life-buoy
|
||||
description: 了解使用反馈与社区支持
|
||||
ctaLabel: 联系我们
|
||||
ctaHref: https://github.com/svc-design/XControl/issues
|
||||
---
|
||||
|
||||
欢迎提交您的使用反馈或功能建议,我们会尽快回复。
|
||||
如需寻求技术帮助,可联系:**manbuzhe2008@gmail.com**
|
||||
@ -1,9 +0,0 @@
|
||||
---
|
||||
title: 加入微信群
|
||||
type: qr
|
||||
order: 2
|
||||
qrValue: https://xcontrol.cloud/contact/wechat-community
|
||||
qrImage: https://dl.svc.plus/images/contact/wechat-group.jpg
|
||||
description: 与产品团队和同行实时交流
|
||||
---
|
||||
添加 XControl 社区小助手,获取最新活动信息并加入兴趣小组。
|
||||
@ -1,9 +0,0 @@
|
||||
---
|
||||
title: 微信公众号
|
||||
type: qr
|
||||
order: 1
|
||||
qrValue: https://xcontrol.cloud/contact/wechat-official
|
||||
qrImage: https://dl.svc.plus/images/contact/wechat-official.jpg
|
||||
description: 了解商业产品和专业支持服务
|
||||
---
|
||||
关注 XControl 官方公众号,解锁上云实践案例与专家分享。
|
||||
@ -1,4 +0,0 @@
|
||||
---
|
||||
title: 保持联系
|
||||
subtitle: 扫码关注或加入社区,获取最新产品动态与支持。
|
||||
---
|
||||
@ -1,16 +0,0 @@
|
||||
---
|
||||
eyebrow: 云原生套件
|
||||
title: 构建一体化的云原生工具集
|
||||
subtitle: 将资产管理、访问控制、可观测与自动化工作流整合到一个响应迅速的体验里,帮助团队高效落地治理策略。
|
||||
primaryCtaLabel: 立即体验
|
||||
primaryCtaHref: /register
|
||||
secondaryCtaLabel: 产品文档
|
||||
secondaryCtaHref: /docs
|
||||
highlights:
|
||||
- 跨集群纳管与多云环境统一治理
|
||||
- 以策略为核心的安全与合规编排
|
||||
- 数据驱动的可观测与成本分析
|
||||
- 场景化模板快速对接业务流程
|
||||
---
|
||||
|
||||
XControl 采用模块化架构设计,可在保持核心稳定的前提下按需引入观测、身份、编排等能力包。通过开放 API 与事件流,您可以轻松连接现有的 DevOps 工具链,让业务交付与平台治理协同运转。
|
||||
@ -1,11 +0,0 @@
|
||||
---
|
||||
title: 社区巡回沙龙启动:实践分享与产品路线解读
|
||||
author: 社区团队
|
||||
date: 2024-07-12
|
||||
readingTime: 3 分钟
|
||||
tags:
|
||||
- 社区活动
|
||||
- 生态
|
||||
---
|
||||
|
||||
今年的巡回沙龙将覆盖八个城市,围绕平台工程、自动化安全与可观测进行实战分享。我们还准备了产品路线解读与开放问答,欢迎报名参与,与来自不同行业的伙伴一起交流经验。
|
||||
@ -1,12 +0,0 @@
|
||||
---
|
||||
title: 观测即服务:从日志到业务指标的一体化洞察
|
||||
author: 平台可观测团队
|
||||
date: 2024-07-30
|
||||
readingTime: 5 分钟
|
||||
tags:
|
||||
- 最佳实践
|
||||
- 观测
|
||||
- 数据分析
|
||||
---
|
||||
|
||||
针对多租户环境,我们重构了指标与日志的归档体系,实现了秒级可视化。通过业务域建模,团队可将技术指标与业务目标关联,快速定位影响用户体验的关键链路。
|
||||
@ -1,13 +0,0 @@
|
||||
---
|
||||
title: 版本 1.8 正式发布:策略联动与可观测双升级
|
||||
author: XControl 产品团队
|
||||
date: 2024-08-15
|
||||
readingTime: 8 分钟
|
||||
tags:
|
||||
- 发布公告
|
||||
- 策略中心
|
||||
- 可观测
|
||||
excerpt: 全新策略联动引擎与跨集群指标联邦能力上线,为大规模云原生团队带来统一的治理体验。
|
||||
---
|
||||
|
||||
我们针对复杂多集群环境推出了「策略联动」功能,可将身份、资源与安全策略编织成全局一致的执行链路。与此同时,指标联邦能力支持将多源观测数据按团队、业务或环境维度聚合分析,让治理效果实时可视化。
|
||||
@ -1,10 +0,0 @@
|
||||
---
|
||||
title: 社区热议
|
||||
ctaLabel: 加入社区
|
||||
ctaHref: https://example.com/community
|
||||
order: 1
|
||||
---
|
||||
|
||||
- [平台工程读书会](#) —— 每周聚焦云原生治理案例
|
||||
- [Slack 交流群](#) —— 与 2000+ 从业者实时交流
|
||||
- [GitHub Issues](#) —— 提交需求与反馈问题
|
||||
@ -1,8 +0,0 @@
|
||||
---
|
||||
title: 推荐资源
|
||||
order: 3
|
||||
---
|
||||
|
||||
1. [平台上线指南](#) — 分阶段部署 XControl 的最佳实践
|
||||
2. [安全策略手册](#) — 常见合规基线的策略模板
|
||||
3. [观测数据白皮书](#) — 如何构建统一的指标与日志视图
|
||||
@ -1,12 +0,0 @@
|
||||
---
|
||||
title: 热门标签
|
||||
layout: tags
|
||||
tags:
|
||||
- 策略驱动
|
||||
- 云原生安全
|
||||
- 平台工程
|
||||
- 观测洞察
|
||||
- 成本治理
|
||||
- 最佳实践
|
||||
order: 4
|
||||
---
|
||||
@ -1,17 +0,0 @@
|
||||
---
|
||||
title: XCloudFlow
|
||||
tagline: 多云 IaC
|
||||
order: 1
|
||||
description: 通过声明式模型统一编排多云基础设施,自动化落地资源策略与合规标准。
|
||||
primaryCtaLabel: 立刻体验
|
||||
primaryCtaHref: /demo?product=xcloudflow
|
||||
secondaryCtaLabel: 下载链接
|
||||
secondaryCtaHref: /download?product=xcloudflow
|
||||
tertiaryCtaLabel: 文档链接
|
||||
tertiaryCtaHref: /docs/xcloudflow
|
||||
features:
|
||||
- 跨云资源蓝图与参数化交付
|
||||
- GitOps 工作流驱动基础设施变更
|
||||
- 内置审批、审计保障合规
|
||||
---
|
||||
XCloudFlow 将 Terraform、Pulumi 等主流 IaC 模型统一到一个工作台,为多云环境提供自助式交付与集中治理。
|
||||
@ -1,17 +0,0 @@
|
||||
---
|
||||
title: XControl 平台
|
||||
tagline: 云原生治理中枢
|
||||
order: 3
|
||||
description: 为多团队提供统一的权限、策略与工作流编排,让交付与治理协同无缝衔接。
|
||||
primaryCtaLabel: 立刻体验
|
||||
primaryCtaHref: /demo?product=xcontrol
|
||||
secondaryCtaLabel: 下载链接
|
||||
secondaryCtaHref: /download?product=xcontrol
|
||||
tertiaryCtaLabel: 文档链接
|
||||
tertiaryCtaHref: /docs/xcontrol
|
||||
features:
|
||||
- 一站式权限与合规策略中心
|
||||
- 工作流自动化驱动跨团队协作
|
||||
- 可扩展插件架构连接现有系统
|
||||
---
|
||||
XControl 以策略即代码为核心,为云原生基础设施提供可观测、可治理、可审计的统一控制平面。
|
||||
@ -1,17 +0,0 @@
|
||||
---
|
||||
title: XScopeHub
|
||||
tagline: AI & 可观察性
|
||||
order: 2
|
||||
description: 利用 AI 驱动的分析工作台,统一日志、指标与追踪,快速定位异常并推荐修复路径。
|
||||
primaryCtaLabel: 立刻体验
|
||||
primaryCtaHref: /demo?product=xscopehub
|
||||
secondaryCtaLabel: 下载链接
|
||||
secondaryCtaHref: /download?product=xscopehub
|
||||
tertiaryCtaLabel: 文档链接
|
||||
tertiaryCtaHref: /docs/xscopehub
|
||||
features:
|
||||
- 全栈可观察性数据联邦检索
|
||||
- 智能告警关联与根因分析
|
||||
- 预置 AI 助手生成运维建议
|
||||
---
|
||||
XScopeHub 通过语义化检索与时序分析,实现跨环境的可观察性汇聚与智能洞察。
|
||||
@ -1,17 +0,0 @@
|
||||
---
|
||||
title: XStream
|
||||
tagline: 网络加速器
|
||||
order: 4
|
||||
description: 按需构建全球传输网络,保障跨地域应用与数据同步的稳定低时延体验。
|
||||
primaryCtaLabel: 立刻体验
|
||||
primaryCtaHref: /demo?product=xstream
|
||||
secondaryCtaLabel: 下载链接
|
||||
secondaryCtaHref: /download?product=xstream
|
||||
tertiaryCtaLabel: 文档链接
|
||||
tertiaryCtaHref: /docs/xstream
|
||||
features:
|
||||
- 动态最优路径与带宽调度
|
||||
- 内置零信任安全与访问控制
|
||||
- 对接主流 CDN 与边缘节点
|
||||
---
|
||||
XStream 通过软件定义的网络加速技术,为实时互动、音视频与数据分发提供稳定的全球链路。
|
||||
@ -1 +0,0 @@
|
||||
export * from './homepage'
|
||||
@ -1,45 +0,0 @@
|
||||
import path from 'path'
|
||||
|
||||
import { cmsConfig } from '../config'
|
||||
|
||||
export interface ResolvedContentSource {
|
||||
namespace: string
|
||||
absolutePath: string
|
||||
type: 'filesystem' | 'git'
|
||||
repository?: string
|
||||
ref?: string
|
||||
}
|
||||
|
||||
export function resolveContentSource(namespace?: string): ResolvedContentSource {
|
||||
if (typeof window !== 'undefined') {
|
||||
throw new Error('resolveContentSource can only be called on the server')
|
||||
}
|
||||
|
||||
const targetNamespace = namespace ?? cmsConfig.content.defaultNamespace
|
||||
const source = cmsConfig.content.sources[targetNamespace]
|
||||
if (!source) {
|
||||
const fallbackRoot = path.join(
|
||||
process.cwd(),
|
||||
'src',
|
||||
'lib',
|
||||
'cms',
|
||||
'content',
|
||||
targetNamespace
|
||||
)
|
||||
return {
|
||||
namespace: targetNamespace,
|
||||
absolutePath: fallbackRoot,
|
||||
type: 'filesystem',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
namespace: targetNamespace,
|
||||
absolutePath: path.isAbsolute(source.root)
|
||||
? source.root
|
||||
: path.join(process.cwd(), source.root),
|
||||
type: source.type,
|
||||
repository: source.repository,
|
||||
ref: source.ref,
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { cmsConfig } from './config'
|
||||
|
||||
export function listContentSourcesMetadata() {
|
||||
return Object.entries(cmsConfig.content.sources).map(([namespace, source]) => ({
|
||||
namespace,
|
||||
type: source.type,
|
||||
repository: source.repository,
|
||||
ref: source.ref,
|
||||
}))
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
import { ComponentType, createElement, isValidElement, type ReactNode } from 'react'
|
||||
|
||||
import { cmsConfig, type ExtensionName, type ThemeName } from './config'
|
||||
import type { CmsExtension, CmsTheme } from './types'
|
||||
import { appShellExtension } from './extensions/appShell'
|
||||
import { markdownSyncExtension } from './extensions/markdownSync'
|
||||
import defaultTheme from './themes/default'
|
||||
import { AppShellBypass } from '../appShellBypass'
|
||||
|
||||
const themeRegistry: Record<ThemeName, CmsTheme> = {
|
||||
default: defaultTheme,
|
||||
}
|
||||
|
||||
const extensionRegistry: Record<ExtensionName, CmsExtension> = {
|
||||
'app-shell': appShellExtension,
|
||||
'markdown-sync': markdownSyncExtension,
|
||||
}
|
||||
|
||||
export function getActiveTheme(): CmsTheme {
|
||||
return themeRegistry[cmsConfig.theme]
|
||||
}
|
||||
|
||||
export function getActiveExtensions(): CmsExtension[] {
|
||||
return cmsConfig.extensions
|
||||
.map((extensionName) => extensionRegistry[extensionName])
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function collectExtensionProviders(): ComponentType<{ children: ReactNode }>[] {
|
||||
return getActiveExtensions().flatMap((extension) => extension.providers ?? [])
|
||||
}
|
||||
|
||||
export function applyExtensionLayouts(children: ReactNode): ReactNode {
|
||||
const { content, skipAppShell } = unwrapAppShellBypass(children)
|
||||
|
||||
return getActiveExtensions().reduceRight((acc, extension) => {
|
||||
if (!extension.Layout) {
|
||||
return acc
|
||||
}
|
||||
if (skipAppShell && extension.name === 'app-shell') {
|
||||
return acc
|
||||
}
|
||||
const LayoutComponent = extension.Layout
|
||||
return createElement(LayoutComponent, null, acc)
|
||||
}, content)
|
||||
}
|
||||
|
||||
function unwrapAppShellBypass(node: ReactNode): { content: ReactNode; skipAppShell: boolean } {
|
||||
if (isValidElement(node) && (node.type as any) === AppShellBypass) {
|
||||
return { content: (node.props as any).children, skipAppShell: true }
|
||||
}
|
||||
|
||||
return { content: node, skipAppShell: false }
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import Footer from '@components/Footer'
|
||||
import Navbar from '@components/Navbar'
|
||||
import { AskAIButton } from '@components/AskAIButton'
|
||||
|
||||
import type { CmsExtension } from '../types'
|
||||
|
||||
function AppShellLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="pt-[var(--app-shell-nav-offset)]">
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
<AskAIButton />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const appShellExtension: CmsExtension = {
|
||||
name: 'app-shell',
|
||||
Layout: AppShellLayout,
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import type { CmsExtension } from '../types'
|
||||
import { MarkdownContentProvider } from '../content/ContentContext'
|
||||
|
||||
export const markdownSyncExtension: CmsExtension = {
|
||||
name: 'markdown-sync',
|
||||
providers: [MarkdownContentProvider],
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
export {
|
||||
applyExtensionLayouts,
|
||||
collectExtensionProviders,
|
||||
getActiveExtensions,
|
||||
getActiveTheme,
|
||||
} from './extensionRuntime'
|
||||
|
||||
export {
|
||||
getActiveTemplate,
|
||||
listRegisteredTemplateNames,
|
||||
registerTemplate,
|
||||
registerTemplateLoader,
|
||||
} from '@modules/templateRegistry'
|
||||
export { listContentSourcesMetadata } from './contentSources'
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "@cms",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.ts"
|
||||
}
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
export { default } from '@modules/templates/default'
|
||||
export { defaultHomeLayoutConfig } from './config'
|
||||
@ -1,16 +0,0 @@
|
||||
import type { CmsTheme } from '../../types'
|
||||
|
||||
function PassthroughThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const defaultTheme: CmsTheme = {
|
||||
name: 'default',
|
||||
htmlAttributes: {
|
||||
lang: 'en',
|
||||
},
|
||||
bodyClassName: 'bg-[var(--color-background)] text-[var(--color-text)]',
|
||||
Provider: PassthroughThemeProvider,
|
||||
}
|
||||
|
||||
export default defaultTheme
|
||||
@ -1,24 +0,0 @@
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
|
||||
import type { HomePageTemplateSlots, TemplateRenderProps } from '@modules/templates/types'
|
||||
|
||||
export interface CmsTemplate {
|
||||
name: string
|
||||
pages: {
|
||||
home: ComponentType<TemplateRenderProps<HomePageTemplateSlots>>
|
||||
[key: string]: ComponentType<any> | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface CmsTheme {
|
||||
name: string
|
||||
htmlAttributes?: Record<string, string>
|
||||
bodyClassName?: string
|
||||
Provider?: ComponentType<{ children: ReactNode }>
|
||||
}
|
||||
|
||||
export interface CmsExtension {
|
||||
name: string
|
||||
providers?: ComponentType<{ children: ReactNode }>[]
|
||||
Layout?: ComponentType<{ children: ReactNode }>
|
||||
}
|
||||
269
dashboard/src/lib/marketingContent.ts
Normal file
269
dashboard/src/lib/marketingContent.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import path from 'path'
|
||||
|
||||
import { readMarkdownDirectory } from './markdown'
|
||||
|
||||
export interface HeroContent {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
primaryCtaLabel?: string
|
||||
primaryCtaHref?: string
|
||||
secondaryCtaLabel?: string
|
||||
secondaryCtaHref?: string
|
||||
highlights: string[]
|
||||
bodyHtml: string
|
||||
}
|
||||
|
||||
export interface HeroSolution {
|
||||
slug: string
|
||||
title: string
|
||||
tagline?: string
|
||||
description?: string
|
||||
features: string[]
|
||||
bodyHtml: string
|
||||
primaryCtaLabel?: string
|
||||
primaryCtaHref?: string
|
||||
secondaryCtaLabel?: string
|
||||
secondaryCtaHref?: string
|
||||
tertiaryCtaLabel?: string
|
||||
tertiaryCtaHref?: string
|
||||
}
|
||||
|
||||
export interface HomepagePost {
|
||||
slug: string
|
||||
title: string
|
||||
author?: string
|
||||
date?: string
|
||||
readingTime?: string
|
||||
tags: string[]
|
||||
excerpt: string
|
||||
contentHtml: string
|
||||
}
|
||||
|
||||
export interface SidebarSection {
|
||||
slug: string
|
||||
title: string
|
||||
layout?: string
|
||||
tags: string[]
|
||||
bodyHtml: string
|
||||
ctaLabel?: string
|
||||
ctaHref?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface ContactItemContent {
|
||||
slug: string
|
||||
title: string
|
||||
type?: string
|
||||
description?: string
|
||||
bodyHtml: string
|
||||
qrValue?: string
|
||||
qrImage?: string
|
||||
icon?: string
|
||||
ctaLabel?: string
|
||||
ctaHref?: string
|
||||
}
|
||||
|
||||
export interface ContactPanelContent {
|
||||
title: string
|
||||
subtitle?: string
|
||||
bodyHtml?: string
|
||||
items: ContactItemContent[]
|
||||
}
|
||||
|
||||
const HERO_CONTENT: HeroContent = {
|
||||
eyebrow: '云原生套件',
|
||||
title: '构建一体化的云原生工具集',
|
||||
subtitle:
|
||||
'将资产管理、访问控制、可观测与自动化工作流整合到一个响应迅速的体验里,帮助团队高效落地治理策略。',
|
||||
primaryCtaLabel: '立即体验',
|
||||
primaryCtaHref: '/register',
|
||||
secondaryCtaLabel: '产品文档',
|
||||
secondaryCtaHref: '/docs',
|
||||
highlights: [
|
||||
'跨集群纳管与多云环境统一治理',
|
||||
'以策略为核心的安全与合规编排',
|
||||
'数据驱动的可观测与成本分析',
|
||||
'场景化模板快速对接业务流程',
|
||||
],
|
||||
bodyHtml:
|
||||
'<p>XControl 采用模块化架构设计,可在保持核心稳定的前提下按需引入观测、身份、编排等能力包。通过开放 API 与事件流,您可以轻松连接现有的 DevOps 工具链,让业务交付与平台治理协同运转。</p>',
|
||||
}
|
||||
|
||||
const HERO_SOLUTIONS: HeroSolution[] = [
|
||||
{
|
||||
slug: 'xcloudflow',
|
||||
title: 'XCloudFlow',
|
||||
tagline: '多云 IaC',
|
||||
description: '通过声明式模型统一编排多云基础设施,自动化落地资源策略与合规标准。',
|
||||
features: ['跨云资源蓝图与参数化交付', 'GitOps 工作流驱动基础设施变更', '内置审批、审计保障合规'],
|
||||
bodyHtml:
|
||||
'<p>XCloudFlow 将 Terraform、Pulumi 等主流 IaC 模型统一到一个工作台,为多云环境提供自助式交付与集中治理。</p>',
|
||||
primaryCtaLabel: '立刻体验',
|
||||
primaryCtaHref: '/demo?product=xcloudflow',
|
||||
secondaryCtaLabel: '下载链接',
|
||||
secondaryCtaHref: '/download?product=xcloudflow',
|
||||
tertiaryCtaLabel: '文档链接',
|
||||
tertiaryCtaHref: '/docs/xcloudflow',
|
||||
},
|
||||
{
|
||||
slug: 'xscopehub',
|
||||
title: 'XScopeHub',
|
||||
tagline: 'AI & 可观察性',
|
||||
description: '利用 AI 驱动的分析工作台,统一日志、指标与追踪,快速定位异常并推荐修复路径。',
|
||||
features: ['全栈可观察性数据联邦检索', '智能告警关联与根因分析', '预置 AI 助手生成运维建议'],
|
||||
bodyHtml:
|
||||
'<p>XScopeHub 通过语义化检索与时序分析,实现跨环境的可观察性汇聚与智能洞察。</p>',
|
||||
primaryCtaLabel: '立刻体验',
|
||||
primaryCtaHref: '/demo?product=xscopehub',
|
||||
secondaryCtaLabel: '下载链接',
|
||||
secondaryCtaHref: '/download?product=xscopehub',
|
||||
tertiaryCtaLabel: '文档链接',
|
||||
tertiaryCtaHref: '/docs/xscopehub',
|
||||
},
|
||||
{
|
||||
slug: 'xstream',
|
||||
title: 'XStream',
|
||||
tagline: '网络加速器',
|
||||
description: '按需构建全球传输网络,保障跨地域应用与数据同步的稳定低时延体验。',
|
||||
features: ['动态最优路径与带宽调度', '内置零信任安全与访问控制', '对接主流 CDN 与边缘节点'],
|
||||
bodyHtml:
|
||||
'<p>XStream 通过软件定义的网络加速技术,为实时互动、音视频与数据分发提供稳定的全球链路。</p>',
|
||||
primaryCtaLabel: '立刻体验',
|
||||
primaryCtaHref: '/demo?product=xstream',
|
||||
secondaryCtaLabel: '下载链接',
|
||||
secondaryCtaHref: '/download?product=xstream',
|
||||
tertiaryCtaLabel: '文档链接',
|
||||
tertiaryCtaHref: '/docs/xstream',
|
||||
},
|
||||
]
|
||||
|
||||
const CONTACT_PANEL: ContactPanelContent = {
|
||||
title: '保持联系',
|
||||
subtitle: '扫码关注或加入社区,获取最新产品动态与支持。',
|
||||
items: [
|
||||
{
|
||||
slug: 'wechat-official',
|
||||
title: '微信公众号',
|
||||
type: 'qr',
|
||||
description: '了解商业产品和专业支持服务',
|
||||
bodyHtml: '关注 XControl 官方公众号,解锁上云实践案例与专家分享。',
|
||||
qrValue: 'https://xcontrol.cloud/contact/wechat-official',
|
||||
qrImage: 'https://dl.svc.plus/images/contact/wechat-official.jpg',
|
||||
},
|
||||
{
|
||||
slug: 'wechat-group',
|
||||
title: '加入微信群',
|
||||
type: 'qr',
|
||||
description: '与产品团队和同行实时交流',
|
||||
bodyHtml: '添加 XControl 社区小助手,获取最新活动信息并加入兴趣小组。',
|
||||
qrValue: 'https://xcontrol.cloud/contact/wechat-community',
|
||||
qrImage: 'https://dl.svc.plus/images/contact/wechat-group.jpg',
|
||||
},
|
||||
{
|
||||
slug: 'support',
|
||||
title: '获取支持',
|
||||
type: 'info',
|
||||
description: '了解使用反馈与社区支持',
|
||||
bodyHtml:
|
||||
'<p>欢迎提交您的使用反馈或功能建议,我们会尽快回复。<br/>如需寻求技术帮助,可联系:<strong>manbuzhe2008@gmail.com</strong></p>',
|
||||
icon: 'life-buoy',
|
||||
ctaLabel: '联系我们',
|
||||
ctaHref: 'https://github.com/svc-design/XControl/issues',
|
||||
},
|
||||
{
|
||||
slug: 'github-star',
|
||||
title: '欢迎支持',
|
||||
type: 'info',
|
||||
description: '欢迎支持关注 Star',
|
||||
bodyHtml: '点击链接访问 CloudNativeSuite GitHub,欢迎支持关注 Star,获取更多项目动态。',
|
||||
icon: 'star',
|
||||
ctaLabel: '访问 GitHub',
|
||||
ctaHref: 'https://github.com/CloudNativeSuite',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const BLOG_CONTENT_ROOT = path.join(process.cwd(), 'src', 'content', 'blog')
|
||||
|
||||
function extractExcerpt(markdown: string): string {
|
||||
const blocks = markdown.split(/\r?\n\s*\r?\n/)
|
||||
for (const block of blocks) {
|
||||
const trimmed = block.trim()
|
||||
if (!trimmed) continue
|
||||
const withoutFormatting = trimmed
|
||||
.replace(/^#+\s*/g, '')
|
||||
.replace(/[`*_>\[\]]/g, '')
|
||||
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
|
||||
if (withoutFormatting.trim()) {
|
||||
return withoutFormatting.trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export async function getHomepageHero(): Promise<HeroContent> {
|
||||
return HERO_CONTENT
|
||||
}
|
||||
|
||||
export async function getHeroSolutions(): Promise<HeroSolution[]> {
|
||||
return HERO_SOLUTIONS
|
||||
}
|
||||
|
||||
export async function getHomepagePosts(): Promise<HomepagePost[]> {
|
||||
let posts: HomepagePost[] = []
|
||||
try {
|
||||
const files = await readMarkdownDirectory('', { baseDir: BLOG_CONTENT_ROOT })
|
||||
|
||||
posts = files.map((file) => {
|
||||
const title = typeof file.metadata.title === 'string' ? file.metadata.title : file.slug
|
||||
const author = typeof file.metadata.author === 'string' ? file.metadata.author : undefined
|
||||
const date = typeof file.metadata.date === 'string' ? file.metadata.date : undefined
|
||||
const readingTime =
|
||||
typeof file.metadata.readingTime === 'string' ? file.metadata.readingTime : undefined
|
||||
const tags = Array.isArray(file.metadata.tags)
|
||||
? file.metadata.tags.filter((tag): tag is string => typeof tag === 'string')
|
||||
: []
|
||||
const excerptMetadata = typeof file.metadata.excerpt === 'string' ? file.metadata.excerpt : undefined
|
||||
const excerpt = excerptMetadata ?? extractExcerpt(file.content)
|
||||
|
||||
return {
|
||||
slug: file.slug,
|
||||
title,
|
||||
author,
|
||||
date,
|
||||
readingTime,
|
||||
tags,
|
||||
excerpt,
|
||||
contentHtml: file.html,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const withParsedDates = posts.map((post) => ({
|
||||
...post,
|
||||
dateValue: post.date ? new Date(post.date) : undefined,
|
||||
}))
|
||||
|
||||
withParsedDates.sort((a, b) => {
|
||||
if (a.dateValue && b.dateValue) {
|
||||
return b.dateValue.getTime() - a.dateValue.getTime()
|
||||
}
|
||||
if (a.dateValue) return -1
|
||||
if (b.dateValue) return 1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
|
||||
return withParsedDates.map(({ dateValue: _dateValue, ...post }) => post)
|
||||
}
|
||||
|
||||
export async function getContactPanelContent(): Promise<ContactPanelContent | undefined> {
|
||||
if (!CONTACT_PANEL.items.length) {
|
||||
return undefined
|
||||
}
|
||||
return CONTACT_PANEL
|
||||
}
|
||||
@ -1,12 +1,11 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { cmsConfig } from '@cms/config'
|
||||
import type { CmsTemplate } from '@cms/types'
|
||||
|
||||
import defaultTemplate from './templates/default'
|
||||
import type { TemplateDefinition } from './templates/types'
|
||||
|
||||
type CmsTemplate = TemplateDefinition
|
||||
|
||||
const BUILTIN_TEMPLATES: Record<string, TemplateDefinition> = {
|
||||
default: defaultTemplate,
|
||||
}
|
||||
@ -64,8 +63,7 @@ export function resolveTemplateName(explicitName?: string, options?: TemplateSel
|
||||
}
|
||||
|
||||
const envOverride =
|
||||
typeof process !== 'undefined' &&
|
||||
(process.env.NEXT_PUBLIC_TEMPLATE || process.env.CMS_TEMPLATE)
|
||||
typeof process !== 'undefined' && (process.env.NEXT_PUBLIC_TEMPLATE || process.env.CMS_TEMPLATE)
|
||||
|
||||
if (envOverride) {
|
||||
return envOverride
|
||||
@ -75,7 +73,7 @@ export function resolveTemplateName(explicitName?: string, options?: TemplateSel
|
||||
throw new Error('Template name not provided and config fallback disabled.')
|
||||
}
|
||||
|
||||
return cmsConfig.template
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function getActiveTemplate(options?: TemplateSelectionOptions): CmsTemplate {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { CommonHomeLayoutConfig } from '@templates/layouts/commonHome'
|
||||
import type { CommonHomeLayoutConfig } from '../layouts/commonHome'
|
||||
|
||||
export const defaultHomeLayoutConfig: CommonHomeLayoutConfig = {
|
||||
rootClassName: 'relative min-h-screen bg-brand-surface text-brand-navy antialiased',
|
||||
@ -17,8 +17,6 @@ export const defaultHomeLayoutConfig: CommonHomeLayoutConfig = {
|
||||
containerClassName: 'relative px-8',
|
||||
contentClassName: 'mx-auto w-full max-w-6xl',
|
||||
gridClassName: 'grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px] lg:items-start lg:gap-12',
|
||||
slots: [
|
||||
{ key: 'CommunityFeed' },
|
||||
],
|
||||
slots: [{ key: 'CommunityFeed' }],
|
||||
},
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import ProductMatrix from '@components/home/ProductMatrix'
|
||||
import Sidebar from '@components/home/Sidebar'
|
||||
|
||||
import { defaultHomeLayoutConfig } from '@cms/templates/default/config'
|
||||
import { defaultHomeLayoutConfig } from './config'
|
||||
|
||||
import { createCommonHomeTemplate } from '../layouts/commonHome'
|
||||
import type { TemplateDefinition } from '../types'
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
import { cmsConfig } from '@cms/config'
|
||||
|
||||
vi.mock('@cms/content/source', () => ({
|
||||
resolveContentSource: () => ({
|
||||
absolutePath: 'src/lib/cms/content',
|
||||
}),
|
||||
}))
|
||||
|
||||
import {
|
||||
__resetTemplateRegistryForTests,
|
||||
getActiveTemplate,
|
||||
listRegisteredTemplateNames,
|
||||
registerTemplate,
|
||||
registerTemplateLoader,
|
||||
resolveTemplateName,
|
||||
type TemplateDefinition,
|
||||
} from '@modules/templateRegistry'
|
||||
|
||||
const originalTemplateName = cmsConfig.template
|
||||
const templateFixturesPath = path.join(process.cwd(), 'src', 'modules', 'templates', 'fs-template')
|
||||
|
||||
const runtimeTemplate: TemplateDefinition = {
|
||||
name: 'runtime',
|
||||
pages: {
|
||||
home: () => null,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetTemplateRegistryForTests()
|
||||
cmsConfig.template = 'default'
|
||||
delete process.env.NEXT_PUBLIC_TEMPLATE
|
||||
delete process.env.CMS_TEMPLATE
|
||||
fs.rmSync(templateFixturesPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cmsConfig.template = originalTemplateName
|
||||
delete process.env.NEXT_PUBLIC_TEMPLATE
|
||||
delete process.env.CMS_TEMPLATE
|
||||
__resetTemplateRegistryForTests()
|
||||
const modulePath = path.join(templateFixturesPath, 'index.js')
|
||||
delete require.cache[modulePath]
|
||||
fs.rmSync(templateFixturesPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('templateRegistry', () => {
|
||||
it('returns the default template from configuration', () => {
|
||||
const template = getActiveTemplate()
|
||||
expect(template.name).toBe('default')
|
||||
})
|
||||
|
||||
it('prefers an explicit template name over configuration', () => {
|
||||
registerTemplate('runtime', runtimeTemplate)
|
||||
const template = getActiveTemplate({ name: 'runtime' })
|
||||
expect(template).toBe(runtimeTemplate)
|
||||
})
|
||||
|
||||
it('allows environment variables to override the template', () => {
|
||||
registerTemplate('runtime', runtimeTemplate)
|
||||
process.env.NEXT_PUBLIC_TEMPLATE = 'runtime'
|
||||
const template = getActiveTemplate()
|
||||
expect(template).toBe(runtimeTemplate)
|
||||
})
|
||||
|
||||
it('throws when config fallback is disabled and name is missing', () => {
|
||||
expect(() => resolveTemplateName(undefined, { fallbackToConfig: false })).toThrowError()
|
||||
})
|
||||
|
||||
it('supports registering custom loaders', () => {
|
||||
const loaderTemplate: TemplateDefinition = {
|
||||
name: 'loader',
|
||||
pages: {
|
||||
home: () => null,
|
||||
},
|
||||
}
|
||||
|
||||
registerTemplateLoader((name) => {
|
||||
return name === 'loader-template' ? loaderTemplate : null
|
||||
})
|
||||
|
||||
const template = getActiveTemplate({ name: 'loader-template', fallbackToConfig: false })
|
||||
expect(template).toBe(loaderTemplate)
|
||||
})
|
||||
|
||||
it('discovers templates from the filesystem', () => {
|
||||
fs.mkdirSync(templateFixturesPath, { recursive: true })
|
||||
const templateModulePath = path.join(templateFixturesPath, 'index.js')
|
||||
fs.writeFileSync(
|
||||
templateModulePath,
|
||||
"module.exports = { name: 'fs-template', pages: { home: () => null } };\n",
|
||||
'utf8',
|
||||
)
|
||||
|
||||
delete require.cache[templateModulePath]
|
||||
expect(fs.existsSync(templateModulePath)).toBe(true)
|
||||
expect(
|
||||
path.join(process.cwd(), 'src', 'modules', 'templates', 'fs-template', 'index.js')
|
||||
).toBe(templateModulePath)
|
||||
|
||||
cmsConfig.template = 'fs-template'
|
||||
const template = getActiveTemplate()
|
||||
expect(template.name).toBe('fs-template')
|
||||
})
|
||||
|
||||
it('keeps the @cms facade compatible', async () => {
|
||||
const direct = getActiveTemplate()
|
||||
|
||||
vi.stubGlobal('window', undefined)
|
||||
const { getActiveTemplate: legacyGetActiveTemplate } = await import('@cms')
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
const facade = legacyGetActiveTemplate()
|
||||
expect(facade).toEqual(direct)
|
||||
})
|
||||
|
||||
it('lists registered template names including runtime additions', () => {
|
||||
registerTemplate('runtime', runtimeTemplate)
|
||||
const names = listRegisteredTemplateNames()
|
||||
expect(names).toContain('default')
|
||||
expect(names).toContain('runtime')
|
||||
})
|
||||
})
|
||||
@ -21,7 +21,6 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@components': path.resolve(__dirname, '..', '..', 'src', 'components'),
|
||||
'@cms': path.resolve(__dirname, '..', '..', 'src', 'lib', 'cms'),
|
||||
'@i18n': path.resolve(__dirname, '..', '..', 'src', 'i18n'),
|
||||
'@lib': path.resolve(__dirname, '..', '..', 'src', 'lib'),
|
||||
'@types': path.resolve(__dirname, '..', '..', 'types'),
|
||||
|
||||
@ -17,9 +17,6 @@
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".", // 👈 根路径基准
|
||||
"paths": {
|
||||
"@cms": ["src/lib/cms/index.ts"], // 👈 增加这行以兼容 import "@cms"
|
||||
"@cms/*": ["src/lib/cms/*"],
|
||||
|
||||
"@components/*": ["src/components/*"],
|
||||
"@i18n/*": ["src/i18n/*"],
|
||||
"@lib/*": ["src/lib/*"],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user