feat: add markdown content pipeline (#547)

This commit is contained in:
shenlan 2025-10-17 15:27:29 +08:00 committed by GitHub
parent b5fdae1d57
commit f8bd1f64b8
14 changed files with 1713 additions and 3 deletions

View File

@ -55,6 +55,17 @@ configuration selects values based on `NEXT_PUBLIC_RUNTIME_ENV` (falling back to
`defaultEnvironment`). Use `NEXT_PUBLIC_ACCOUNT_SERVICE_URL` for ad-hoc overrides, otherwise adjust the YAML file to specify
environment-specific URLs such as `http://localhost:8080` for development/test and `https://account.svc.plus` for production.
## Content operations
- Markdown articles live under [`content/`](./content). Each file starts with a YAML frontmatter block that defines
`title`, `summary`, `version`, `updatedAt`, `tags`, `status`, `author`, and optional `links`. The [`content/README.md`](./content/README.md)
documents the schema in detail.
- The dashboard exposes a `GET /api/content/<slug>` endpoint powered by `remark` that renders Markdown to HTML, collects
headings, and surfaces git commit metadata for version tracking. Frontend slots can consume this API via the
`MarkdownContentSlot` component.
- Run [`scripts/sync-content.sh`](./scripts/sync-content.sh) with `CONTENT_REPO_URL` (and optional `CONTENT_REPO_BRANCH`,
`CONTENT_REPO_SUBDIR`) to push the latest content into an external documentation repository as part of your GitOps flow.
## Account service configuration
`account/config/account.yaml` now accepts a `server.publicUrl` value such as `https://account.svc.plus:8443`. The account service

35
content/README.md Normal file
View File

@ -0,0 +1,35 @@
# Content Directory
This directory stores markdown-based content that can be rendered inside the XControl dashboard. Each markdown file **must** start with a YAML frontmatter block describing the document metadata followed by the document body.
## Frontmatter schema
```yaml
---
slug: "announcements/welcome" # Optional. Defaults to the file path without the .md extension.
title: "Release Note Title" # Required. Used as the document heading.
summary: "Short abstract." # Optional. Appears beside the title and in previews.
version: "v1.0.0" # Optional. Free-form version string for the document content.
updatedAt: "2025-02-03T09:00:00Z" # Optional ISO-8601 timestamp. Falls back to latest git commit time.
tags: # Optional list of labels.
- docs
- release
status: "published" # Optional. Free-form status indicator (published, draft, etc.).
author: "Docs Team" # Optional. Displayed in dashboards alongside version info.
links: # Optional. Additional related links rendered as reference list.
- label: "Changelog"
href: "https://example.com/changelog"
---
```
Additional custom keys are preserved and returned by the content API.
## Adding new documents
1. Create a markdown file anywhere under this directory (nested folders are allowed).
2. Populate the frontmatter according to the schema above.
3. Commit the file and run the dashboard to render it via the `/api/content/*` endpoints.
## Version history
The dashboard content API inspects the git history for each markdown file. Commit metadata is surfaced in the UI so contributors should continue to use Pull Requests and descriptive commit messages when updating content.

View File

@ -0,0 +1,32 @@
---
title: "欢迎使用内容中心"
summary: "了解如何在 Dashboard 中渲染版本化的 Markdown 内容。"
version: "v0.1.0"
updatedAt: "2025-02-01T08:00:00Z"
tags:
- announcements
- knowledge-base
status: "published"
author: "XControl Docs"
links:
- label: "内容同步指南"
href: "https://example.com/content-sync"
---
# 欢迎来到内容中心
XControl 现在内置了一个轻量的内容引擎,可以直接从 `content/` 目录加载 Markdown 文档,并通过 API 渲染到 Dashboard 插槽中。借助 Git 的提交历史,你可以在界面中查看最近一次内容更新的作者、提交信息和时间。
## 内容写作规范
- 使用上方的 Frontmatter 字段维护标题、版本号、标签和更新时间。
- Markdown 正文支持 **GitHub Flavored Markdown**,包括表格、任务列表和脚注。
- 需要引用外部链接时,可以在 `links` 列表中添加 `label``href`
## 版本化流程
1. 在本地创建或更新 Markdown 文件。
2. 提交 Pull Request确保描述内容更新的目的。
3. 合并后Git 提交历史会自动显示在 Dashboard 的版本信息卡片中。
> 小贴士:使用 `scripts/sync-content.sh` 可以把 `content/` 目录推送到外部文档仓库,保持多端同步。

View File

@ -5,6 +5,7 @@ out/
# Dependencies
node_modules/
.yarn/
# Logs
*.log

View File

@ -0,0 +1,195 @@
import fs from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import matter from 'gray-matter'
import { remark } from 'remark'
import html from 'remark-html'
import gfm from 'remark-gfm'
import slug from 'remark-slug'
import { visit } from 'unist-util-visit'
import { toString } from 'mdast-util-to-string'
import type { Root } from 'mdast'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import {
CONTENT_GIT_HISTORY_LIMIT,
REPO_ROOT,
resolveContentFile,
resolveRepoPath,
} from '../../../../config/content'
import type {
ContentDocumentResponse,
ContentFrontmatter,
ContentHeading,
} from '../../../../types/content'
export const runtime = 'nodejs'
const execFileAsync = promisify(execFile)
interface GitCommitRow {
hash: string
shortHash: string
author: string
date: string
message: string
}
function isValidSlug(slugParam: unknown): slugParam is string[] {
return Array.isArray(slugParam) && slugParam.every((segment) => typeof segment === 'string')
}
function createHeadingCollector(headings: ContentHeading[]) {
return () => (tree: Root) => {
visit(tree, 'heading', (node) => {
const text = toString(node)
const depth = node.depth ?? 0
const id = (node.data as any)?.id ?? (node as any).data?.hProperties?.id
headings.push({
id: typeof id === 'string' && id ? id : text.toLowerCase().replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, ''),
text,
depth,
})
})
}
}
async function renderMarkdown(source: string): Promise<{ html: string; headings: ContentHeading[] }> {
const headings: ContentHeading[] = []
const processor = remark().use(gfm).use(slug).use(createHeadingCollector(headings)).use(html, {
sanitize: false,
})
const result = await processor.process(source)
return { html: String(result), headings }
}
async function readGitHistory(relativePath: string): Promise<GitCommitRow[]> {
const args = [
'log',
`-n`,
String(CONTENT_GIT_HISTORY_LIMIT),
'--date=iso-strict',
"--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s",
'--',
relativePath,
]
try {
const { stdout } = await execFileAsync('git', args, { cwd: REPO_ROOT })
if (!stdout.trim()) {
return []
}
return stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [hash, shortHash, author, date, message] = line.split('\u001f')
return { hash, shortHash, author, date, message }
})
.filter((commit): commit is GitCommitRow =>
Boolean(commit.hash && commit.shortHash && commit.author && commit.date && commit.message),
)
} catch (error) {
console.warn('[content] failed to read git history', error)
return []
}
}
function mergeFrontmatter(
slug: string,
frontmatter: Record<string, unknown>,
gitHistory: GitCommitRow[],
): ContentDocumentResponse {
const normalizedFrontmatter: ContentFrontmatter = { ...frontmatter }
const latest = gitHistory[0]
const updatedAt =
typeof normalizedFrontmatter.updatedAt === 'string' && normalizedFrontmatter.updatedAt
? normalizedFrontmatter.updatedAt
: latest?.date ?? null
const version = typeof normalizedFrontmatter.version === 'string' ? normalizedFrontmatter.version : null
const slugOverride =
typeof normalizedFrontmatter.slug === 'string' && normalizedFrontmatter.slug.trim()
? normalizedFrontmatter.slug.trim()
: null
return {
slug: slugOverride ?? slug,
html: '',
headings: [],
frontmatter: normalizedFrontmatter,
versionInfo: {
updatedAt,
version,
latestCommit: latest
? {
hash: latest.hash,
shortHash: latest.shortHash,
author: latest.author,
date: latest.date,
message: latest.message,
}
: null,
history: gitHistory.map((commit) => ({
hash: commit.hash,
shortHash: commit.shortHash,
author: commit.author,
date: commit.date,
message: commit.message,
})),
},
}
}
export async function GET(
_request: NextRequest,
context: { params: { slug?: string[] } },
): Promise<NextResponse<ContentDocumentResponse | { error: string }>> {
const { slug } = context.params
if (!isValidSlug(slug) || slug.length === 0) {
return NextResponse.json({ error: 'Missing content slug' }, { status: 400 })
}
let contentFile
try {
contentFile = resolveContentFile(slug)
} catch (error) {
return NextResponse.json({ error: 'Invalid content slug' }, { status: 400 })
}
const { absolutePath, relativePath } = contentFile
try {
const file = await fs.readFile(absolutePath, 'utf8')
const parsed = matter(file)
const slugName = slug.join('/')
const gitHistory = await readGitHistory(resolveRepoPath(relativePath))
const baseResponse = mergeFrontmatter(slugName, parsed.data ?? {}, gitHistory)
const rendered = await renderMarkdown(parsed.content)
const response: ContentDocumentResponse = {
...baseResponse,
html: rendered.html,
headings: rendered.headings,
}
if (typeof baseResponse.frontmatter.title !== 'string' || !baseResponse.frontmatter.title) {
response.frontmatter.title = slug[slug.length - 1] ?? 'Untitled'
}
return NextResponse.json(response)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return NextResponse.json({ error: 'Content not found' }, { status: 404 })
}
console.error('[content] failed to load content', error)
return NextResponse.json({ error: 'Failed to load content' }, { status: 500 })
}
}

View File

@ -0,0 +1,5 @@
import MarkdownContentSlot from './MarkdownContentSlot'
export default function KnowledgeBaseSpotlight() {
return <MarkdownContentSlot slug="announcements/welcome" />
}

View File

@ -0,0 +1,169 @@
'use client'
import { useMemo } from 'react'
import DOMPurify from 'dompurify'
import clsx from 'clsx'
import useSWR from 'swr'
import type { ContentDocumentResponse, ContentCommitInfo } from '@types/content'
interface MarkdownContentSlotProps {
slug: string
className?: string
historyLimit?: number
}
interface VersionRowProps {
commit: ContentCommitInfo
}
const fetcher = async (url: string): Promise<ContentDocumentResponse> => {
const response = await fetch(url, { cache: 'no-store' })
if (!response.ok) {
throw new Error(`Failed to load content: ${response.status}`)
}
return response.json()
}
function formatDate(value: string | null | undefined): string | null {
if (!value) return null
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return null
}
return new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(parsed)
}
function VersionRow({ commit }: VersionRowProps) {
const date = formatDate(commit.date)
return (
<li className="space-y-1 rounded-lg border border-slate-200 bg-white/70 p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-xs text-slate-500">{commit.shortHash}</span>
{date ? <span className="text-xs text-slate-500">{date}</span> : null}
</div>
<p className="font-medium text-slate-900">{commit.message}</p>
<p className="text-xs text-slate-500">{commit.author}</p>
</li>
)
}
export default function MarkdownContentSlot({ slug, className, historyLimit = 5 }: MarkdownContentSlotProps) {
const { data, error, isLoading } = useSWR<ContentDocumentResponse>(`/api/content/${slug}`, fetcher, {
revalidateOnFocus: false,
})
const sanitizedHtml = useMemo(() => {
if (!data?.html) {
return ''
}
return DOMPurify.sanitize(data.html)
}, [data?.html])
if (isLoading) {
return <div className={clsx('rounded-xl border border-dashed border-slate-300 p-6 text-sm text-slate-500', className)}></div>
}
if (error || !data) {
return (
<div className={clsx('rounded-xl border border-red-200 bg-red-50/60 p-6 text-sm text-red-600', className)}>
</div>
)
}
const updatedAt = formatDate(data.versionInfo.updatedAt)
const limitedHistory = data.versionInfo.history.slice(0, historyLimit)
const tags = Array.isArray(data.frontmatter.tags)
? data.frontmatter.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0)
: []
const links = Array.isArray(data.frontmatter.links)
? data.frontmatter.links.filter((link): link is { label?: string; href?: string } =>
Boolean(link && typeof link === 'object'),
)
: []
return (
<article className={clsx('space-y-6 rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm', className)}>
<header className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600"></p>
<h2 className="text-2xl font-bold text-slate-900">{data.frontmatter.title}</h2>
{data.frontmatter.summary ? (
<p className="text-sm text-slate-600">{data.frontmatter.summary}</p>
) : null}
<dl className="mt-2 grid gap-2 text-xs text-slate-500 sm:grid-cols-2">
<div>
<dt className="font-semibold uppercase tracking-wide"></dt>
<dd>{data.versionInfo.version ?? '未标记'}</dd>
</div>
<div>
<dt className="font-semibold uppercase tracking-wide"></dt>
<dd>{updatedAt ?? '未知'}</dd>
</div>
{data.frontmatter.author ? (
<div>
<dt className="font-semibold uppercase tracking-wide"></dt>
<dd>{String(data.frontmatter.author)}</dd>
</div>
) : null}
{data.frontmatter.status ? (
<div>
<dt className="font-semibold uppercase tracking-wide"></dt>
<dd>{String(data.frontmatter.status)}</dd>
</div>
) : null}
</dl>
{tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span key={tag} className="rounded-full bg-purple-50 px-3 py-1 text-xs font-medium text-purple-600">
{tag}
</span>
))}
</div>
) : null}
</header>
<div className="prose prose-slate max-w-none" dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
{links.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-semibold text-slate-900"></h3>
<ul className="list-disc space-y-1 pl-5 text-sm text-slate-600">
{links.map((link, index) => (
<li key={`${link?.href ?? index}`}>
{link?.href ? (
<a className="text-purple-600 underline decoration-purple-400 underline-offset-2" href={link.href} target="_blank" rel="noreferrer">
{link?.label ?? link.href}
</a>
) : (
<span>{link?.label}</span>
)}
</li>
))}
</ul>
</section>
) : null}
{limitedHistory.length > 0 ? (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold text-slate-900"></h3>
<p className="text-xs text-slate-500"> {limitedHistory.length} </p>
</div>
<ul className="space-y-2">
{limitedHistory.map((commit) => (
<VersionRow key={commit.hash} commit={commit} />
))}
</ul>
</section>
) : (
<p className="text-xs text-slate-500"></p>
)}
</article>
)
}

View File

@ -0,0 +1,33 @@
import path from 'node:path'
const dashboardRoot = process.cwd()
export const REPO_ROOT = path.resolve(dashboardRoot, '..')
const envOverride = process.env.CONTENT_DIR ? path.resolve(REPO_ROOT, process.env.CONTENT_DIR) : null
export const CONTENT_ROOT = envOverride ?? path.join(REPO_ROOT, 'content')
export const DEFAULT_CONTENT_HISTORY_LIMIT = Number.parseInt(
process.env.CONTENT_HISTORY_LIMIT ?? '10',
10,
)
export function resolveContentFile(slugSegments: string[]): { absolutePath: string; relativePath: string } {
const safeSegments = slugSegments
.filter((segment) => segment && segment !== '.' && segment !== '..')
.map((segment) => segment.replace(/\\/g, '/'))
const normalized = safeSegments.join('/')
const relativePath = `${normalized || 'index'}.md`
const absolutePath = path.join(CONTENT_ROOT, relativePath)
if (!absolutePath.startsWith(CONTENT_ROOT)) {
throw new Error('Invalid content path')
}
return { absolutePath, relativePath }
}
export function resolveRepoPath(relativeContentPath: string): string {
const absolute = path.join(CONTENT_ROOT, relativeContentPath)
return path.relative(REPO_ROOT, absolute)
}
export const CONTENT_GIT_HISTORY_LIMIT = Math.max(DEFAULT_CONTENT_HISTORY_LIMIT, 1)

View File

@ -17,8 +17,10 @@
},
"dependencies": {
"dompurify": "^3.2.6",
"gray-matter": "^4.0.3",
"lucide-react": "^0.319.0",
"marked": "^16.1.2",
"mdast-util-to-string": "^4.0.0",
"next": "14.2.32",
"pdfjs-dist": "^4.2.67",
"prop-types": "^15.8.1",
@ -29,7 +31,12 @@
"react-grid-layout": "^1.4.4",
"react-pdf": "^9.1.0",
"react-resizable": "^3.0.4",
"remark": "^15.0.1",
"remark-gfm": "^4.0.0",
"remark-html": "^16.0.1",
"remark-slug": "^7.0.1",
"swr": "^2.3.0",
"unist-util-visit": "^5.0.0",
"zustand": "^4.5.4"
},
"devDependencies": {
@ -38,9 +45,11 @@
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/mdast": "^4.0.4",
"@types/node": "24.0.3",
"@types/react": "19.1.8",
"@types/react-grid-layout": "^1.3.5",
"@types/unist": "^3.0.2",
"autoprefixer": "^10.4.16",
"eslint": "8.57.0",
"eslint-config-next": "^15.5.3",

View File

@ -1,6 +1,7 @@
import ArticleFeed from '@components/home/ArticleFeed'
import ProductMatrix from '@components/home/ProductMatrix'
import Sidebar from '@components/home/Sidebar'
import KnowledgeBaseSpotlight from '@components/content/KnowledgeBaseSpotlight'
import type { HomePageTemplateProps, TemplateDefinition } from '../types'
@ -8,9 +9,19 @@ function DefaultHomePageTemplate({ slots }: HomePageTemplateProps) {
const ProductMatrixComponent = slots.ProductMatrix ?? ProductMatrix
const ArticleFeedComponent = slots.ArticleFeed ?? ArticleFeed
const SidebarComponent = slots.Sidebar ?? Sidebar
const KnowledgeBaseComponent = slots.KnowledgeBase ?? KnowledgeBaseSpotlight
return (
<main className="bg-slate-950">
{KnowledgeBaseComponent ? (
<section className="bg-gradient-to-br from-slate-900 via-slate-950 to-slate-900 pb-16 pt-16 text-white">
<div className="px-4">
<div className="mx-auto max-w-5xl">
<KnowledgeBaseComponent />
</div>
</div>
</section>
) : null}
<section className="pb-24 pt-24">
<div className="px-4">
<div className="mx-auto max-w-6xl">

View File

@ -15,6 +15,7 @@ export interface HomePageTemplateSlots extends TemplateSlots {
ProductMatrix: ComponentType
ArticleFeed: ComponentType
Sidebar: ComponentType
KnowledgeBase?: ComponentType
}
export type HomePageTemplateProps = TemplateRenderProps<HomePageTemplateSlots>

View File

@ -0,0 +1,41 @@
export interface ContentFrontmatter {
[key: string]: unknown
title?: string
summary?: string
version?: string
updatedAt?: string
tags?: string[]
status?: string
author?: string
links?: Array<{ label?: string; href?: string }>
slug?: string
}
export interface ContentHeading {
id: string
text: string
depth: number
}
export interface ContentCommitInfo {
hash: string
shortHash: string
author: string
date: string
message: string
}
export interface ContentVersionInfo {
version: string | null
updatedAt: string | null
latestCommit: ContentCommitInfo | null
history: ContentCommitInfo[]
}
export interface ContentDocumentResponse {
slug: string
html: string
headings: ContentHeading[]
frontmatter: ContentFrontmatter
versionInfo: ContentVersionInfo
}

File diff suppressed because it is too large Load Diff

67
scripts/sync-content.sh Executable file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONTENT_DIR="${REPO_ROOT}/content"
if [[ ! -d "${CONTENT_DIR}" ]]; then
echo "[sync-content] content directory not found at ${CONTENT_DIR}" >&2
exit 1
fi
if [[ -z "${CONTENT_REPO_URL:-}" ]]; then
echo "[sync-content] CONTENT_REPO_URL environment variable is required" >&2
exit 1
fi
TARGET_BRANCH="${CONTENT_REPO_BRANCH:-main}"
WORKDIR="${CONTENT_SYNC_WORKDIR:-$(mktemp -d)}"
cleanup() {
if [[ -z "${CONTENT_SYNC_WORKDIR:-}" && -d "${WORKDIR}" ]]; then
rm -rf "${WORKDIR}"
fi
}
trap cleanup EXIT
echo "[sync-content] Cloning ${CONTENT_REPO_URL} (branch ${TARGET_BRANCH})"
if git clone --depth 1 --branch "${TARGET_BRANCH}" "${CONTENT_REPO_URL}" "${WORKDIR}"; then
echo "[sync-content] Repository cloned into ${WORKDIR}"
else
echo "[sync-content] Failed to clone repository" >&2
exit 1
fi
mkdir -p "${WORKDIR}/content"
rsync -av --delete "${CONTENT_DIR}/" "${WORKDIR}/content/"
pushd "${WORKDIR}" >/dev/null
if [[ -n "${CONTENT_REPO_SUBDIR:-}" ]]; then
mkdir -p "${CONTENT_REPO_SUBDIR}"
rsync -av --delete "content/" "${CONTENT_REPO_SUBDIR}/"
pushd "${CONTENT_REPO_SUBDIR}" >/dev/null
fi
if [[ -z "$(git status --porcelain)" ]]; then
echo "[sync-content] No changes detected, skipping commit"
popd >/dev/null 2>&1 || true
popd >/dev/null 2>&1 || true
exit 0
fi
git config user.name "${GIT_AUTHOR_NAME:-Content Sync Bot}"
git config user.email "${GIT_AUTHOR_EMAIL:-content-sync@example.com}"
git add .
COMMIT_MESSAGE="chore: sync content from XControl $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
git commit -m "${COMMIT_MESSAGE}"
git push origin "${TARGET_BRANCH}"
echo "[sync-content] Content synchronized successfully"
if [[ -n "${CONTENT_REPO_SUBDIR:-}" ]]; then
popd >/dev/null || true
fi
popd >/dev/null || true