282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
export type DocsGroup = 'Overview' | 'Architecture' | 'Manual' | 'Earth' | 'Frontend' | 'Backend' | 'Agents' | 'Ops' | 'Other'
|
|
export type DocsLang = 'zh' | 'en'
|
|
|
|
export interface DocsEntry {
|
|
slug: string
|
|
filename: string
|
|
title: string
|
|
group: DocsGroup
|
|
order: number
|
|
access: DocsAccess
|
|
}
|
|
|
|
export type DocsAccess = 'public' | 'docs_user' | 'docs_developer' | 'docs_admin'
|
|
|
|
export interface DocsCatalogItem {
|
|
slug: string
|
|
filename: string
|
|
lang: DocsLang
|
|
title: string
|
|
group: DocsGroup
|
|
order: number
|
|
access: DocsAccess
|
|
}
|
|
|
|
export interface DocsHeading {
|
|
id: string
|
|
level: number
|
|
text: string
|
|
}
|
|
|
|
export interface DocsMetadataEntry {
|
|
zh: { title: string; group: DocsGroup; order: number }
|
|
en: { title: string; group: DocsGroup; order: number }
|
|
}
|
|
|
|
const DOCS_GROUP_LABELS: Record<DocsLang, Record<DocsGroup, string>> = {
|
|
zh: {
|
|
Overview: '概览',
|
|
Architecture: '业务架构',
|
|
Manual: '使用手册',
|
|
Earth: '智能星球',
|
|
Frontend: '前端',
|
|
Backend: '后端',
|
|
Agents: '智能体',
|
|
Ops: '运维',
|
|
Other: '其他',
|
|
},
|
|
en: {
|
|
Overview: 'Overview',
|
|
Architecture: 'Architecture',
|
|
Manual: 'Manual',
|
|
Earth: 'Intelligent Planet',
|
|
Frontend: 'Frontend',
|
|
Backend: 'Backend',
|
|
Agents: 'Agents',
|
|
Ops: 'Ops',
|
|
Other: 'Other',
|
|
},
|
|
}
|
|
|
|
const DOCS_README_FILENAME = 'README.md'
|
|
const MAX_HEADING_ID_LENGTH = 80
|
|
export const defaultDocsSlug = 'overview'
|
|
|
|
export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
|
[DOCS_README_FILENAME]: {
|
|
zh: { title: '技术文档', group: 'Overview', order: 0 },
|
|
en: { title: 'Technical Docs', group: 'Overview', order: 0 },
|
|
},
|
|
'quickstart.md': {
|
|
zh: { title: '快速开始', group: 'Manual', order: 2 },
|
|
en: { title: 'Quickstart', group: 'Manual', order: 2 },
|
|
},
|
|
'manual.md': {
|
|
zh: { title: '智能星球使用手册', group: 'Manual', order: 1 },
|
|
en: { title: 'Intelligent Planet Manual', group: 'Manual', order: 1 },
|
|
},
|
|
'faq.md': {
|
|
zh: { title: '常见问题', group: 'Manual', order: 3 },
|
|
en: { title: 'FAQ', group: 'Manual', order: 3 },
|
|
},
|
|
'platform-data-flows.md': {
|
|
zh: { title: '业务架构与数据流转', group: 'Architecture', order: 5 },
|
|
en: { title: 'Business Architecture and Data Flows', group: 'Architecture', order: 5 },
|
|
},
|
|
'earth-frontend-context.md': {
|
|
zh: { title: '智能星球前端结构', group: 'Earth', order: 10 },
|
|
en: { title: 'Intelligent Planet Frontend Context', group: 'Earth', order: 10 },
|
|
},
|
|
'earth-layer-style-reference.md': {
|
|
zh: { title: '智能星球图层样式属性索引', group: 'Earth', order: 11 },
|
|
en: { title: 'Intelligent Planet Layer Style Reference', group: 'Earth', order: 11 },
|
|
},
|
|
'earth-render-layer-order.md': {
|
|
zh: { title: '智能星球渲染图层顺序', group: 'Earth', order: 12 },
|
|
en: { title: 'Intelligent Planet Render Layer Order', group: 'Earth', order: 12 },
|
|
},
|
|
'earth-satellite-footprint-policy.md': {
|
|
zh: { title: '智能星球卫星覆盖策略', group: 'Earth', order: 13 },
|
|
en: { title: 'Intelligent Planet Satellite Footprint Policy', group: 'Earth', order: 13 },
|
|
},
|
|
'earth-bgp-context.md': {
|
|
zh: { title: 'BGP 态势上下文', group: 'Earth', order: 14 },
|
|
en: { title: 'BGP Context', group: 'Earth', order: 14 },
|
|
},
|
|
'earth-news-live-streams-collector-format.md': {
|
|
zh: { title: '新闻直播采集格式', group: 'Backend', order: 36 },
|
|
en: { title: 'News Live Streams Collector Format', group: 'Backend', order: 36 },
|
|
},
|
|
'earth-interactable-usage.md': {
|
|
zh: { title: '智能星球可交互图标接入', group: 'Earth', order: 16 },
|
|
en: { title: 'Intelligent Planet Interactable Usage', group: 'Earth', order: 16 },
|
|
},
|
|
'earth-interactable-clustering.md': {
|
|
zh: { title: '智能星球可交互图标聚类策略', group: 'Earth', order: 17 },
|
|
en: { title: 'Intelligent Planet Interactable Clustering', group: 'Earth', order: 17 },
|
|
},
|
|
'earth-toolbar-overlay-coordination.md': {
|
|
zh: { title: '智能星球工具栏与浮层协同', group: 'Earth', order: 18 },
|
|
en: { title: 'Intelligent Planet Toolbar and Overlay Coordination', group: 'Earth', order: 18 },
|
|
},
|
|
'frontend-admin-frontend-context.md': {
|
|
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
|
|
en: { title: 'Admin Frontend Context', group: 'Frontend', order: 20 },
|
|
},
|
|
'frontend-layout-guidelines.md': {
|
|
zh: { title: '前端布局指南', group: 'Frontend', order: 21 },
|
|
en: { title: 'Frontend Layout Guidelines', group: 'Frontend', order: 21 },
|
|
},
|
|
'docs-gatekeeper-development.md': {
|
|
zh: { title: 'Docs Gatekeeper 开发说明', group: 'Backend', order: 37 },
|
|
en: { title: 'Docs Gatekeeper Development Guide', group: 'Backend', order: 37 },
|
|
},
|
|
'naming-glossary.md': {
|
|
zh: { title: '命名与术语对照', group: 'Architecture', order: 6 },
|
|
en: { title: 'Naming Glossary', group: 'Architecture', order: 6 },
|
|
},
|
|
'tactile-ui-components.md': {
|
|
zh: { title: 'Tactile UI 组件库', group: 'Frontend', order: 24 },
|
|
en: { title: 'Tactile UI Components', group: 'Frontend', order: 24 },
|
|
},
|
|
'backend-collectors.md': {
|
|
zh: { title: '数据采集系统', group: 'Backend', order: 30 },
|
|
en: { title: 'Data Collectors', group: 'Backend', order: 30 },
|
|
},
|
|
'backend-system-service-control.md': {
|
|
zh: { title: '系统服务控制', group: 'Backend', order: 31 },
|
|
en: { title: 'System Service Control', group: 'Backend', order: 31 },
|
|
},
|
|
'datasource-collector-settings-connectivity.md': {
|
|
zh: { title: '数据源、采集器设置与连接验证', group: 'Backend', order: 32 },
|
|
en: { title: 'Datasource Collector Settings and Connectivity', group: 'Backend', order: 32 },
|
|
},
|
|
'backend-datasources-api-performance.md': {
|
|
zh: { title: '数据源 API 性能', group: 'Backend', order: 33 },
|
|
en: { title: 'Datasource API Performance', group: 'Backend', order: 33 },
|
|
},
|
|
'data-job-earth-sync-architecture.md': {
|
|
zh: { title: '数据作业与 Outbox 技术架构', group: 'Backend', order: 34 },
|
|
en: { title: 'Data Jobs and Outbox Architecture', group: 'Backend', order: 34 },
|
|
},
|
|
'location-pipeline-development.md': {
|
|
zh: { title: '通用位置估算管线开发说明', group: 'Backend', order: 35 },
|
|
en: { title: 'Shared Location Resolution Pipeline Development Guide', group: 'Backend', order: 35 },
|
|
},
|
|
'agents-aiprovider.md': {
|
|
zh: { title: 'AI Provider 指南', group: 'Agents', order: 40 },
|
|
en: { title: 'AI Provider Guide', group: 'Agents', order: 40 },
|
|
},
|
|
'ops-runbook.md': {
|
|
zh: { title: '智能星球运维手册', group: 'Ops', order: 49 },
|
|
en: { title: 'Intelligent Planet Ops Runbook', group: 'Ops', order: 49 },
|
|
},
|
|
'ops-docker-compose-buildx-upgrade.md': {
|
|
zh: { title: 'Docker + Compose + Buildx 升级', group: 'Ops', order: 50 },
|
|
en: { title: 'Docker + Compose + Buildx Upgrade', group: 'Ops', order: 50 },
|
|
},
|
|
'ops-planet-sh-startup.md': {
|
|
zh: { title: 'planet.sh 启动机制', group: 'Ops', order: 51 },
|
|
en: { title: 'planet.sh Startup', group: 'Ops', order: 51 },
|
|
},
|
|
}
|
|
|
|
const GROUP_ORDER: DocsGroup[] = ['Overview', 'Manual', 'Architecture', 'Earth', 'Frontend', 'Backend', 'Agents', 'Ops', 'Other']
|
|
const PUBLIC_DOCS_FILENAMES = new Set(Object.keys(DOCS_METADATA))
|
|
|
|
export function slugFromFilename(filename: string): string {
|
|
return filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
|
|
}
|
|
|
|
export function getDocsEntries(lang: DocsLang, catalogItems: DocsCatalogItem[]): DocsEntry[] {
|
|
return catalogItems
|
|
.filter((item) => item.lang === lang)
|
|
.map((item) => ({
|
|
slug: item.slug,
|
|
filename: item.filename,
|
|
title: item.title,
|
|
group: item.group,
|
|
order: item.order,
|
|
access: item.access,
|
|
}))
|
|
.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))
|
|
}
|
|
|
|
export function getDocsEntry(slug: string | undefined, entries: DocsEntry[]): DocsEntry | undefined {
|
|
const normalizedSlug = slug || defaultDocsSlug
|
|
return entries.find((entry) => entry.slug === normalizedSlug)
|
|
}
|
|
|
|
export function groupDocsEntries(entries: DocsEntry[]): Array<{ group: DocsGroup; entries: DocsEntry[] }> {
|
|
return GROUP_ORDER.map((group) => ({
|
|
group,
|
|
entries: entries.filter((entry) => entry.group === group),
|
|
})).filter((group) => group.entries.length > 0)
|
|
}
|
|
|
|
export function getDocsGroupLabel(group: DocsGroup, lang: DocsLang): string {
|
|
return DOCS_GROUP_LABELS[lang][group]
|
|
}
|
|
|
|
export function createHeadingId(text: string, usedIds: Map<string, number>): string {
|
|
const base = text
|
|
.toLowerCase()
|
|
.replace(/`([^`]+)`/g, '$1')
|
|
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
|
.trim()
|
|
.replace(/\s+/g, '-')
|
|
.slice(0, MAX_HEADING_ID_LENGTH) || 'section'
|
|
const count = usedIds.get(base) || 0
|
|
usedIds.set(base, count + 1)
|
|
return count === 0 ? base : `${base}-${count + 1}`
|
|
}
|
|
|
|
export function extractHeadings(markdown: string): DocsHeading[] {
|
|
const usedIds = new Map<string, number>()
|
|
return markdown
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim().match(/^(#{1,3})\s+(.+)$/))
|
|
.filter((match): match is RegExpMatchArray => Boolean(match))
|
|
.map((match) => {
|
|
const text = match[2].trim()
|
|
return {
|
|
id: createHeadingId(text, usedIds),
|
|
level: match[1].length,
|
|
text,
|
|
}
|
|
})
|
|
}
|
|
|
|
// Returns a factory — call factory() inside MarkdownRenderer to get a fresh resolver
|
|
// per render. This is necessary because StrictMode double-invokes renders, which
|
|
// would exhaust a shared stateful closure and cause heading IDs to become undefined.
|
|
export function createHeadingIdResolver(markdown: string): () => (text: string, level: number) => string | undefined {
|
|
const headings = extractHeadings(markdown)
|
|
|
|
return () => {
|
|
const indexByKey = new Map<string, number>()
|
|
return (text: string, level: number) => {
|
|
const key = `${level}:${text}`
|
|
const currentIndex = indexByKey.get(key) || 0
|
|
indexByKey.set(key, currentIndex + 1)
|
|
const matchingHeadings = headings.filter((heading) => heading.level === level && heading.text === text)
|
|
return matchingHeadings[currentIndex]?.id
|
|
}
|
|
}
|
|
}
|
|
|
|
export function slugFromDocsHref(href: string): string | null {
|
|
const normalized = decodeURIComponent(href).split('#')[0].replace(/\\/g, '/')
|
|
if (!/\/docs\/technical\/(zh|en)\//.test(normalized)) {
|
|
return null
|
|
}
|
|
|
|
const filename = normalized.split('/').pop()
|
|
|
|
if (!filename?.endsWith('.md') || !PUBLIC_DOCS_FILENAMES.has(filename)) {
|
|
return null
|
|
}
|
|
|
|
return slugFromFilename(filename)
|
|
}
|