feat: add markdown content pipeline (#547)
This commit is contained in:
parent
b5fdae1d57
commit
f8bd1f64b8
11
README.md
11
README.md
@ -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
35
content/README.md
Normal 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.
|
||||
32
content/announcements/welcome.md
Normal file
32
content/announcements/welcome.md
Normal 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/` 目录推送到外部文档仓库,保持多端同步。
|
||||
1
dashboard/.gitignore
vendored
1
dashboard/.gitignore
vendored
@ -5,6 +5,7 @@ out/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.yarn/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
195
dashboard/app/api/content/[...slug]/route.ts
Normal file
195
dashboard/app/api/content/[...slug]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
5
dashboard/components/content/KnowledgeBaseSpotlight.tsx
Normal file
5
dashboard/components/content/KnowledgeBaseSpotlight.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import MarkdownContentSlot from './MarkdownContentSlot'
|
||||
|
||||
export default function KnowledgeBaseSpotlight() {
|
||||
return <MarkdownContentSlot slug="announcements/welcome" />
|
||||
}
|
||||
169
dashboard/components/content/MarkdownContentSlot.tsx
Normal file
169
dashboard/components/content/MarkdownContentSlot.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
dashboard/config/content.ts
Normal file
33
dashboard/config/content.ts
Normal 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)
|
||||
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -15,6 +15,7 @@ export interface HomePageTemplateSlots extends TemplateSlots {
|
||||
ProductMatrix: ComponentType
|
||||
ArticleFeed: ComponentType
|
||||
Sidebar: ComponentType
|
||||
KnowledgeBase?: ComponentType
|
||||
}
|
||||
|
||||
export type HomePageTemplateProps = TemplateRenderProps<HomePageTemplateSlots>
|
||||
|
||||
41
dashboard/types/content.ts
Normal file
41
dashboard/types/content.ts
Normal 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
|
||||
}
|
||||
1106
dashboard/yarn.lock
1106
dashboard/yarn.lock
File diff suppressed because it is too large
Load Diff
67
scripts/sync-content.sh
Executable file
67
scripts/sync-content.sh
Executable 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
|
||||
Loading…
Reference in New Issue
Block a user