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> = { 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 = { [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 { 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() 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() 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) }