export type DocsGroup = 'Overview' | '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: '概览', Manual: '使用手册', Earth: '地球可视化', Frontend: '前端', Backend: '后端', Agents: '智能体', Ops: '运维', Other: '其他', }, en: { Overview: 'Overview', Manual: 'Manual', Earth: 'Earth', 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: 1 }, en: { title: 'Quickstart', group: 'Manual', order: 1 }, }, 'manual.md': { zh: { title: 'Planet 使用手册', group: 'Manual', order: 2 }, en: { title: 'Planet Manual', group: 'Manual', order: 2 }, }, 'location-pipeline-user.md': { zh: { title: 'Earth 位置候选采集使用手册', group: 'Manual', order: 3 }, en: { title: 'Earth Location Candidate Collection User Guide', group: 'Manual', order: 3 }, }, 'earth-frontend-context.md': { zh: { title: 'Earth 前端结构', group: 'Earth', order: 10 }, en: { title: 'Earth Frontend Context', group: 'Earth', order: 10 }, }, 'earth-layer-style-reference.md': { zh: { title: 'Earth 图层样式属性索引', group: 'Earth', order: 11 }, en: { title: 'Earth Layer Style Reference', group: 'Earth', order: 11 }, }, 'earth-render-layer-order.md': { zh: { title: 'Earth 渲染图层顺序', group: 'Earth', order: 12 }, en: { title: 'Earth Render Layer Order', group: 'Earth', order: 12 }, }, 'earth-satellite-footprint-policy.md': { zh: { title: 'Earth 卫星覆盖策略', group: 'Earth', order: 13 }, en: { title: 'Earth 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: 'Earth', order: 15 }, en: { title: 'News Live Streams Collector Format', group: 'Earth', order: 15 }, }, 'earth-interactable-usage.md': { zh: { title: 'Earth 可交互图标接入', group: 'Earth', order: 16 }, en: { title: 'Earth Interactable Usage', group: 'Earth', order: 16 }, }, 'earth-toolbar-overlay-coordination.md': { zh: { title: 'Earth 工具栏与浮层协同', group: 'Earth', order: 17 }, en: { title: 'Earth Toolbar and Overlay Coordination', group: 'Earth', order: 17 }, }, '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: 'Frontend', order: 22 }, en: { title: 'Docs Gatekeeper Development Guide', group: 'Frontend', order: 22 }, }, '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 }, }, 'location-pipeline-development.md': { zh: { title: '通用位置估算管线开发说明', group: 'Backend', order: 34 }, en: { title: 'Shared Location Resolution Pipeline Development Guide', group: 'Backend', order: 34 }, }, 'agents-aiprovider.md': { zh: { title: 'AI Provider 指南', group: 'Agents', order: 40 }, en: { title: 'AI Provider Guide', group: 'Agents', order: 40 }, }, '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', 'Earth', 'Frontend', 'Backend', 'Agents', 'Ops', 'Other'] 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, '/') const filename = normalized.split('/').pop() if (!filename?.endsWith('.md')) { return null } return slugFromFilename(filename) }