release: bump version to 0.42.0

This commit is contained in:
rayd1o
2026-04-28 04:27:18 +08:00
parent eeee788530
commit b4e8afb272
53 changed files with 6863 additions and 87 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.41.2",
"version": "0.42.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -725,8 +725,8 @@
<span class="earth-mobile-settings-subtitle">可选自身发光或真实地表覆盖两种选中表现</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择卫星显示风格">
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="self_glow" aria-pressed="true">自身发光</button>
<button type="button" class="earth-mobile-settings-pill" data-satellite-display-style="ground_footprint" aria-pressed="false">真实地表覆盖</button>
<button type="button" class="earth-mobile-settings-pill" data-satellite-display-style="self_glow" aria-pressed="false">自身发光</button>
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="ground_footprint" aria-pressed="true">真实地表覆盖</button>
</div>
</div>
</div>
@@ -926,17 +926,17 @@
<div class="earth-settings-segmented" role="group" aria-label="选择卫星显示风格">
<button
type="button"
class="earth-settings-segmented-btn is-active"
class="earth-settings-segmented-btn"
data-satellite-display-style="self_glow"
aria-pressed="true"
aria-pressed="false"
>
自身发光
</button>
<button
type="button"
class="earth-settings-segmented-btn"
class="earth-settings-segmented-btn is-active"
data-satellite-display-style="ground_footprint"
aria-pressed="false"
aria-pressed="true"
>
真实地表覆盖
</button>

View File

@@ -31,7 +31,7 @@ export const SATELLITE_DISPLAY_STYLES = {
};
export const DEFAULT_SATELLITE_DISPLAY_STYLE =
SATELLITE_DISPLAY_STYLES.SELF_GLOW;
SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT;
export const CRUISE_CONFIG = {
dwellMs: 7_000,
@@ -159,11 +159,11 @@ export const TERRAIN_CONFIG = {
exaggeration: 34,
landRevealFadeMeters: 220,
maxConcurrentRequests: 10,
opacity: 0.62,
color: 0x7f9d7f,
emissive: 0x061008,
specular: 0x233126,
shininess: 10,
opacity: 0.68,
color: 0x8aa884,
emissive: 0x030704,
specular: 0x344438,
shininess: 16,
urlTemplate:
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
};
@@ -459,6 +459,8 @@ export const EARTH_MATERIAL_CONFIG = {
textureOverlayAltitudeOffset: 0.1,
textureOverlayOpacity: 0.88,
textureOverlayRenderOrder: 0.96,
textureOverlaySpecular: 0x05080d,
textureOverlayShininess: 4,
// Depth-mask occluder keeps far-side objects hidden behind the earth
occluderRadiusFactor: 0.999,
@@ -472,11 +474,19 @@ export const EARTH_MATERIAL_CONFIG = {
atmosInnerIntensity: 0.18,
// Fresnel atmosphere glow — outer corona
atmosOuterRadiusFactor: 1.016,
atmosOuterRadiusFactor: 1.0025,
atmosOuterSegments: 48,
atmosOuterColor: [0.18, 0.45, 0.9],
atmosOuterRimPower: 5.0,
atmosOuterIntensity: 0.02,
atmosOuterRimPower: 9.0,
atmosOuterIntensity: 0.0025,
// Subtle Fresnel edge cue shown when the high-res texture is hidden or unavailable.
rimGlowColor: [0.42, 0.72, 1.0],
rimGlowRadiusFactor: 1.0035,
rimGlowPower: 3.4,
rimGlowIntensity: 0.24,
rimGlowSegments: 96,
rimGlowRenderOrder: 1.08,
// Texture candidates — tried in order, first success wins
textureUrls: [
@@ -488,12 +498,12 @@ export const EARTH_MATERIAL_CONFIG = {
dayNight: {
enabled: true,
sunDirection: { x: 1, y: 0.2, z: 0.4 },
nightFloor: 0.32,
dayBoost: 0.94,
twilightWidth: 0.24,
nightFloor: 0.24,
dayBoost: 1.12,
twilightWidth: 0.2,
twilightIntensity: 0.14,
twilightColor: 0x4ea0ff,
nightTintColor: 0x0b1830,
nightTintIntensity: 0.05,
nightTintIntensity: 0.08,
},
};

View File

@@ -110,6 +110,10 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const EARTH_SETTINGS_VERSION = 5;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
let settingsModalTimer = null;
let settingsSheetAnimation = null;
@@ -126,6 +130,7 @@ let activeMobileDrawerId = null;
let mobileDrawerOpen = false;
let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null;
let toolbarHubController = null;
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES),
@@ -722,7 +727,7 @@ function captureEarthSettingsDefaults() {
const panelVisibility = getCurrentPanelVisibilitySnapshot();
const shared = getCurrentSharedSettingsSnapshot();
earthSettingsDefaults = {
version: 2,
version: EARTH_SETTINGS_VERSION,
shared: {
...shared,
layerVisibility: getDefaultLayerVisibilitySnapshot(),
@@ -742,7 +747,7 @@ function captureEarthSettingsDefaults() {
function cloneEarthSettings(settings) {
return {
version: 3,
version: EARTH_SETTINGS_VERSION,
shared: {
rotationMode: settings.shared.rotationMode,
cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)],
@@ -815,9 +820,12 @@ function normalizeEarthSettings(rawSettings, defaults) {
}
});
if ((rawSettings?.version || 0) < 3 && inputLayerVisibility.gridLines === true) {
if ((rawSettings?.version || 0) < GRID_LINES_DEFAULT_VERSION && inputLayerVisibility.gridLines === true) {
normalizedLayerVisibility.gridLines = defaults.shared.layerVisibility.gridLines;
}
if ((rawSettings?.version || 0) < MEDIA_PANEL_DEFAULT_VERSION) {
normalizedDesktopPanelVisibility["media-panel"] = true;
}
const nextRotationMode =
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
@@ -831,11 +839,17 @@ function normalizeEarthSettings(rawSettings, defaults) {
requestedCruiseModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId)),
),
);
const nextSatelliteDisplayStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(
let nextSatelliteDisplayStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(
sharedSettings?.satelliteDisplayStyle,
)
? sharedSettings.satelliteDisplayStyle
: defaults.shared.satelliteDisplayStyle;
if (
(rawSettings?.version || 0) < SATELLITE_DISPLAY_DEFAULT_VERSION &&
nextSatelliteDisplayStyle === SATELLITE_DISPLAY_STYLES.SELF_GLOW
) {
nextSatelliteDisplayStyle = defaults.shared.satelliteDisplayStyle;
}
const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity);
const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean"
? sharedSettings.dayNightEnabled
@@ -845,7 +859,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
);
return {
version: 3,
version: EARTH_SETTINGS_VERSION,
shared: {
rotationMode: nextRotationMode,
cruiseModules: nextCruiseModules.length > 0
@@ -3051,6 +3065,11 @@ function setupKeyboardControls() {
return;
}
if (toolbarHubController?.isOpen?.()) {
toolbarHubController.close();
return;
}
clearLockedObjectAndInfo();
});
}
@@ -3241,6 +3260,12 @@ function setupToolbarHubCluster() {
scheduleExpandedToolbarBoundsRefresh();
};
const closePinnedToolbar = () => {
hubPinnedOpen = false;
cancelCollapse();
setExpanded(false);
};
const scheduleCollapse = () => {
if (hubPinnedOpen) return;
if (collapseTimer) clearTimeout(collapseTimer);
@@ -3263,6 +3288,9 @@ function setupToolbarHubCluster() {
cancelAnimationFrame(refreshBoundsFrameId);
refreshBoundsFrameId = 0;
}
if (toolbarHubController?.cluster === cluster) {
toolbarHubController = null;
}
});
// Start collapsed — hub acts as the hover target to reveal the arc
@@ -3285,13 +3313,12 @@ function setupToolbarHubCluster() {
event.preventDefault();
event.stopPropagation();
cancelCollapse();
if (isMobileLayout()) {
hubPinnedOpen = !cluster.classList.contains("is-expanded");
setExpanded(hubPinnedOpen);
return;
if (hubPinnedOpen) {
closePinnedToolbar();
} else {
hubPinnedOpen = true;
setExpanded(true);
}
hubPinnedOpen = !cluster.classList.contains("is-expanded");
setExpanded(hubPinnedOpen);
});
const HOVER_PADDING_PX = 12;
@@ -3404,9 +3431,14 @@ function setupToolbarHubCluster() {
if (!hubPinnedOpen) return;
if (!(event.target instanceof Element)) return;
if (event.target.closest("#toolbar-cluster")) return;
hubPinnedOpen = false;
setExpanded(false);
closePinnedToolbar();
});
toolbarHubController = {
cluster,
isOpen: () => hubPinnedOpen || cluster.classList.contains("is-expanded"),
close: closePinnedToolbar,
};
}
export function teardownControls() {

View File

@@ -22,10 +22,11 @@ const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null;
let _earthTextureOverlay = null;
let _earthTextureOverlayMaterial = null;
let _earthShader = null;
let _earthShaders = [];
let _dayNightEnabled = true;
let _loadedTexture = null;
let _textureVisible = true;
let _earthRimGlow = null;
const _earthSunDirection = new THREE.Vector3(
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
@@ -39,7 +40,7 @@ function applyEarthDayNightShader(material) {
const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor);
material.onBeforeCompile = (shader) => {
_earthShader = shader;
_earthShaders.push(shader);
shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() };
shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor };
shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost };
@@ -92,6 +93,7 @@ uniform float uDayNightEnabled;`,
dnLight *= mix(uNightFloor, uDayBoost, daylight);
dnLight += uTwilightColor * twilight * uTwilightIntensity;
dnLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
dnLight = dnLight / (vec3(1.0) + max(dnLight - vec3(0.68), vec3(0.0)) * 0.86);
// dn=0: emissive base (from material, set in JS) * camera-facing boost → always readable
// dn=1: full day/night solar lighting
@@ -107,6 +109,7 @@ uniform float uDayNightEnabled;`,
}
export function createEarth(scene) {
_earthShaders = [];
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
const C = EARTH_MATERIAL_CONFIG;
@@ -137,14 +140,15 @@ export function createEarth(scene) {
);
_earthTextureOverlayMaterial = new THREE.MeshPhongMaterial({
color: 0xffffff,
specular: C.specular,
shininess: C.shininess,
specular: C.textureOverlaySpecular,
shininess: C.textureOverlayShininess,
transparent: true,
opacity: C.textureOverlayOpacity,
side: THREE.FrontSide,
depthWrite: false,
depthTest: true,
});
applyEarthDayNightShader(_earthTextureOverlayMaterial);
_earthTextureOverlay = new THREE.Mesh(
textureOverlayGeometry,
_earthTextureOverlayMaterial,
@@ -169,7 +173,9 @@ export function createEarth(scene) {
occluder.renderOrder = -1;
earth.add(occluder);
// Shared Fresnel vertex shader for both atmosphere layers
// Keep the original atmosphere shells on the legacy camera-facing shader so
// they stay as a soft edge cue instead of becoming a visible transparent hull
// at close zoom levels.
const ATMOS_VERTEX_SHADER = `
varying vec3 vNormal;
void main() {
@@ -178,6 +184,17 @@ export function createEarth(scene) {
}
`;
const RIM_VERTEX_SHADER = `
varying vec3 vNormal;
varying vec3 vViewDirection;
void main() {
vNormal = normalize(normalMatrix * normal);
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewDirection = normalize(-mvPosition.xyz);
gl_Position = projectionMatrix * mvPosition;
}
`;
// Fresnel atmosphere — inner rim
const [ir, ig, ib] = C.atmosInnerColor;
const atmosInnerGeo = new THREE.SphereGeometry(
@@ -230,6 +247,39 @@ export function createEarth(scene) {
atmosOuter.renderOrder = 1;
earth.add(atmosOuter);
// Fresnel rim cue: an outer shell keeps the edge tied to the globe while bypassing
// the darker fill layers that can otherwise hide a same-radius glow. Unlike the
// legacy atmosphere shells, this one uses the real view direction so its highlight
// stays attached to the visible globe edge while zooming.
const [rr, rg, rb] = C.rimGlowColor;
const rimGlowGeo = new THREE.SphereGeometry(
CONFIG.earthRadius * C.rimGlowRadiusFactor,
C.rimGlowSegments,
C.rimGlowSegments,
);
const rimGlowMat = new THREE.ShaderMaterial({
vertexShader: RIM_VERTEX_SHADER,
fragmentShader: `
varying vec3 vNormal;
varying vec3 vViewDirection;
void main() {
float viewFacing = max(dot(normalize(vNormal), normalize(vViewDirection)), 0.0);
float rim = 1.0 - viewFacing;
float alpha = pow(rim, ${C.rimGlowPower.toFixed(1)}) * ${C.rimGlowIntensity.toFixed(2)};
gl_FragColor = vec4(${rr.toFixed(2)}, ${rg.toFixed(2)}, ${rb.toFixed(2)}, alpha);
}
`,
blending: THREE.AdditiveBlending,
side: THREE.FrontSide,
transparent: true,
depthTest: false,
depthWrite: false,
});
_earthRimGlow = new THREE.Mesh(rimGlowGeo, rimGlowMat);
_earthRimGlow.name = "earth-rim-glow";
_earthRimGlow.renderOrder = C.rimGlowRenderOrder;
earth.add(_earthRimGlow);
// Texture is loaded separately via loadEarthTexture() for staged loading
return earth;
}
@@ -435,21 +485,26 @@ export function clearEarthTexture() {
if (_earthTextureOverlay) {
_earthTextureOverlay.visible = false;
}
if (_earthRimGlow) {
_earthRimGlow.visible = true;
}
}
export function setEarthSunDirection(direction) {
if (!direction) return;
_earthSunDirection.copy(direction).normalize();
if (_earthShader?.uniforms?.uSunDirectionWorld) {
_earthShader.uniforms.uSunDirectionWorld.value.copy(_earthSunDirection);
}
_earthShaders.forEach((shader) => {
shader?.uniforms?.uSunDirectionWorld?.value?.copy(_earthSunDirection);
});
}
export function setDayNightEnabled(enabled) {
_dayNightEnabled = enabled;
if (_earthShader?.uniforms?.uDayNightEnabled) {
_earthShader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
}
_earthShaders.forEach((shader) => {
if (shader?.uniforms?.uDayNightEnabled) {
shader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
}
});
if (_earthMaterial) {
if (enabled) {
// Restore normal Phong lighting + custom day/night shader
@@ -491,6 +546,9 @@ export function loadEarthTexture() {
if (_earthTextureOverlay) {
_earthTextureOverlay.visible = _textureVisible;
}
if (_earthRimGlow) {
_earthRimGlow.visible = !_textureVisible;
}
resolve();
},
null,
@@ -503,13 +561,17 @@ export function loadEarthTexture() {
export function setEarthTextureVisible(visible) {
_textureVisible = Boolean(visible);
const textureShowing = _textureVisible && Boolean(_loadedTexture);
if (_earthTextureOverlay) {
_earthTextureOverlay.visible = _textureVisible && Boolean(_loadedTexture);
_earthTextureOverlay.visible = textureShowing;
}
if (_earthTextureOverlayMaterial) {
_earthTextureOverlayMaterial.map = _loadedTexture || null;
_earthTextureOverlayMaterial.needsUpdate = true;
}
if (_earthRimGlow) {
_earthRimGlow.visible = !textureShowing;
}
}
export function getEarthTextureVisible() {

View File

@@ -18,11 +18,22 @@ const Settings = lazy(() => import('./pages/Settings/Settings'))
const BGP = lazy(() => import('./pages/BGP/BGP'))
const Playground = lazy(() => import('./pages/Playground/Playground'))
const Logs = lazy(() => import('./pages/Logs/Logs'))
const Docs = lazy(() => import('./pages/Docs/Docs'))
const ROOT_ROUTE = '/'
const EARTH_ROUTE = '/earth'
const DOCS_ROUTE = '/docs'
const DOCS_ROUTE_PATTERN = '/docs/:slug'
const DOCS_ROUTE_PREFIX = `${DOCS_ROUTE}/`
const PUBLIC_EXACT_ROUTES = new Set([ROOT_ROUTE, EARTH_ROUTE, DOCS_ROUTE])
function isPublicPath(pathname: string) {
return PUBLIC_EXACT_ROUTES.has(pathname) || pathname.startsWith(DOCS_ROUTE_PREFIX)
}
function App() {
const { token } = useAuthStore()
const publicPaths = new Set(['/', '/earth'])
const isPublicRoute = publicPaths.has(window.location.pathname)
const isPublicRoute = isPublicPath(window.location.pathname)
if (!token && !isPublicRoute) {
return <Login />
@@ -38,8 +49,10 @@ function App() {
>
<Routes>
<Route path="/admin" element={<Dashboard />} />
<Route path="/" element={<Navigate to="/earth" replace />} />
<Route path="/earth" element={<Earth />} />
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
<Route path={EARTH_ROUTE} element={<Earth />} />
<Route path={DOCS_ROUTE} element={<Docs />} />
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
<Route path="/users" element={<Users />} />
<Route path="/datasources" element={<DataSources />} />
<Route path="/data" element={<DataList />} />

View File

@@ -1,11 +1,21 @@
import { memo } from 'react'
import type { ReactNode } from 'react'
import Scrollbar from '../Scrollbar/Scrollbar'
interface MarkdownRendererProps {
markdown: string
className?: string
getHeadingId?: () => (text: string, level: number) => string | undefined
transformLink?: (href: string) => MarkdownLink | null
}
function renderInlineMarkdown(text: string): ReactNode[] {
interface MarkdownLink {
href: string
external?: boolean
}
function renderInlineMarkdown(text: string, transformLink?: MarkdownRendererProps['transformLink']): ReactNode[] {
const result: ReactNode[] = []
const pattern = /(\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g
let lastIndex = 0
@@ -22,8 +32,17 @@ function renderInlineMarkdown(text: string): ReactNode[] {
if (matchedText.startsWith('[')) {
const linkMatch = matchedText.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (linkMatch) {
const resolvedLink = transformLink?.(linkMatch[2])
const href = resolvedLink?.href || linkMatch[2]
const isExternal = resolvedLink?.external ?? true
result.push(
<a key={`inline-${key}`} href={linkMatch[2]} target="_blank" rel="noreferrer">
<a
key={`inline-${key}`}
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noreferrer' : undefined}
>
{linkMatch[1]}
</a>,
)
@@ -62,7 +81,13 @@ function renderInlineMarkdown(text: string): ReactNode[] {
return result
}
export default function MarkdownRenderer({ markdown, className }: MarkdownRendererProps) {
function MarkdownRenderer({
markdown,
className,
getHeadingId,
transformLink,
}: MarkdownRendererProps) {
const resolveHeadingId = getHeadingId?.()
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
const nodes: ReactNode[] = []
let index = 0
@@ -92,9 +117,11 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
index += 1
}
nodes.push(
<pre key={`block-${index}`}>
<code>{codeLines.join('\n')}</code>
</pre>,
<Scrollbar key={`block-${index}`} className="markdown-renderer__code-scroll">
<pre>
<code>{codeLines.join('\n')}</code>
</pre>
</Scrollbar>,
)
continue
}
@@ -108,11 +135,13 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
if (headingMatch) {
const level = headingMatch[1].length
const content = renderInlineMarkdown(headingMatch[2])
if (level === 1) nodes.push(<h1 key={`block-${index}`}>{content}</h1>)
else if (level === 2) nodes.push(<h2 key={`block-${index}`}>{content}</h2>)
else if (level === 3) nodes.push(<h3 key={`block-${index}`}>{content}</h3>)
else if (level === 4) nodes.push(<h4 key={`block-${index}`}>{content}</h4>)
const headingText = headingMatch[2]
const content = renderInlineMarkdown(headingText, transformLink)
const headingId = resolveHeadingId?.(headingText, level)
if (level === 1) nodes.push(<h1 key={`block-${index}`} id={headingId}>{content}</h1>)
else if (level === 2) nodes.push(<h2 key={`block-${index}`} id={headingId}>{content}</h2>)
else if (level === 3) nodes.push(<h3 key={`block-${index}`} id={headingId}>{content}</h3>)
else if (level === 4) nodes.push(<h4 key={`block-${index}`} id={headingId}>{content}</h4>)
else nodes.push(<p key={`block-${index}`} className="markdown-renderer__heading-fallback">{content}</p>)
index += 1
continue
@@ -140,7 +169,7 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
nodes.push(
<ul key={`block-${index}`}>
{items.map((item, itemIndex) => (
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item)}</li>
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item, transformLink)}</li>
))}
</ul>,
)
@@ -159,7 +188,7 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
nodes.push(
<ol key={`block-${index}`}>
{items.map((item, itemIndex) => (
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item)}</li>
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item, transformLink)}</li>
))}
</ol>,
)
@@ -186,12 +215,12 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
}
nodes.push(
<div key={`block-${index}`} className="markdown-renderer__table-wrap">
<Scrollbar key={`block-${index}`} className="markdown-renderer__table-wrap">
<table className="markdown-renderer__table">
<thead>
<tr>
{tableHeaderCells.map((cell, cellIndex) => (
<th key={`head-${cellIndex}`}>{renderInlineMarkdown(cell)}</th>
<th key={`head-${cellIndex}`}>{renderInlineMarkdown(cell, transformLink)}</th>
))}
</tr>
</thead>
@@ -199,13 +228,13 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
{bodyRows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`cell-${rowIndex}-${cellIndex}`}>{renderInlineMarkdown(cell)}</td>
<td key={`cell-${rowIndex}-${cellIndex}`}>{renderInlineMarkdown(cell, transformLink)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>,
</Scrollbar>,
)
continue
}
@@ -215,12 +244,14 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
paragraphLines.push(lines[index].trim())
index += 1
}
nodes.push(<p key={`block-${index}`}>{renderInlineMarkdown(paragraphLines.join(' '))}</p>)
nodes.push(<p key={`block-${index}`}>{renderInlineMarkdown(paragraphLines.join(' '), transformLink)}</p>)
}
return <div className={className ? `markdown-renderer ${className}` : 'markdown-renderer'}>{nodes}</div>
}
export default memo(MarkdownRenderer)
function parseTableRow(line: string): string[] | null {
if (!line.includes('|')) {
return null

View File

@@ -1,6 +1,7 @@
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useRef,
@@ -25,6 +26,7 @@ interface ScrollbarProps {
children: ReactNode
className?: string
minThumbSize?: number
viewportRef?: RefObject<HTMLDivElement>
}
interface DragState {
@@ -49,8 +51,10 @@ function Scrollbar({
children,
className = '',
minThumbSize = 28,
viewportRef: externalViewportRef,
}: ScrollbarProps) {
const viewportRef = useRef<HTMLDivElement | null>(null)
const internalViewportRef = useRef<HTMLDivElement | null>(null)
const viewportRef = externalViewportRef ?? internalViewportRef
const trackXRef = useRef<HTMLDivElement | null>(null)
const trackYRef = useRef<HTMLDivElement | null>(null)
const dragStateRef = useRef<DragState | null>(null)

View File

@@ -0,0 +1,91 @@
.segmented-control {
position: relative;
display: flex;
align-items: center;
min-width: 0;
height: calc(42px * var(--segmented-control-scale, 1));
padding: calc(4px * var(--segmented-control-scale, 1));
border: 1px solid var(--segmented-control-border, var(--d-border, #d9e1ec));
border-radius: var(--segmented-control-radius, calc(14px * var(--segmented-control-scale, 1)));
background: var(--segmented-control-bg, var(--d-segment-bg, #eef3f9));
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.06);
}
.segmented-control__slider {
position: absolute;
top: calc(4px * var(--segmented-control-scale, 1));
left: calc(4px * var(--segmented-control-scale, 1));
z-index: 1;
width: calc((100% - (8px * var(--segmented-control-scale, 1))) / var(--segmented-control-items, 2));
height: calc(100% - (8px * var(--segmented-control-scale, 1)));
border-radius: var(--segmented-control-slider-radius, calc(10px * var(--segmented-control-scale, 1)));
background: var(--segmented-control-slider-bg, var(--d-segment-slider, #ffffff));
box-shadow: var(--segmented-control-slider-shadow, var(--d-segment-shadow, 0 2px 8px rgba(15, 23, 42, 0.12)));
transform: translateX(calc(var(--segmented-control-index, 0) * 100%));
transition:
transform 0.46s cubic-bezier(0.34, 1.56, 0.64, 1),
background 0.22s ease,
box-shadow 0.22s ease;
}
.segmented-control__button {
position: relative;
z-index: 2;
flex: 1 1 0;
min-width: 0;
height: 100%;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--segmented-control-button-gap, calc(2px * var(--segmented-control-scale, 1)));
border: 0;
border-radius: var(--segmented-control-button-radius, calc(10px * var(--segmented-control-scale, 1)));
background: none;
color: var(--segmented-control-color, var(--d-lang-btn, #4a5568));
font: inherit;
font-size: var(--segmented-control-font-size, calc(10px * var(--segmented-control-scale, 1)));
font-weight: var(--segmented-control-font-weight, 800);
letter-spacing: var(--segmented-control-letter-spacing, 0.04em);
cursor: pointer;
transition: color 0.18s ease, transform 0.18s ease;
}
.segmented-control__button:hover {
color: var(--segmented-control-hover, var(--d-nav-hover, #0d4f9f));
}
.segmented-control__button:active .segmented-control__icon {
transform: scale(0.86);
}
.segmented-control__button--active {
color: var(--segmented-control-active, var(--d-nav-active, #0b5fc1));
}
.segmented-control__icon {
width: var(--segmented-control-icon-size, calc(15px * var(--segmented-control-scale, 1)));
height: var(--segmented-control-icon-size, calc(15px * var(--segmented-control-scale, 1)));
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.segmented-control__icon svg {
width: var(--segmented-control-icon-size, calc(15px * var(--segmented-control-scale, 1)));
height: var(--segmented-control-icon-size, calc(15px * var(--segmented-control-scale, 1)));
stroke-width: 2.1;
}
.segmented-control__button--active .segmented-control__icon svg {
stroke-width: 2.45;
}
.segmented-control__label {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,69 @@
import type { CSSProperties, ReactNode } from 'react'
import './SegmentedControl.css'
export interface SegmentedControlOption<T extends string> {
value: T
label: ReactNode
icon?: ReactNode
title?: string
}
interface SegmentedControlProps<T extends string> {
ariaLabel: string
className?: string
options: SegmentedControlOption<T>[]
scale?: number
value: T
onChange: (value: T) => void
}
function SegmentedControl<T extends string>({
ariaLabel,
className = '',
options,
scale = 1,
value,
onChange,
}: SegmentedControlProps<T>) {
const activeIndex = Math.max(0, options.findIndex((option) => option.value === value))
const style = {
'--segmented-control-items': options.length,
'--segmented-control-index': activeIndex,
'--segmented-control-scale': scale,
} as CSSProperties
return (
<div
className={`segmented-control ${className}`.trim()}
role="group"
aria-label={ariaLabel}
style={style}
>
<span className="segmented-control__slider" aria-hidden="true" />
{options.map((option) => {
const isActive = option.value === value
return (
<button
key={option.value}
type="button"
className={isActive ? 'segmented-control__button segmented-control__button--active' : 'segmented-control__button'}
data-value={option.value}
onClick={() => onChange(option.value)}
title={option.title}
aria-pressed={isActive}
>
{option.icon ? (
<span className="segmented-control__icon" aria-hidden="true">
{option.icon}
</span>
) : null}
<span className="segmented-control__label">{option.label}</span>
</button>
)
})}
</div>
)
}
export default SegmentedControl

View File

@@ -2104,6 +2104,7 @@ body {
.markdown-renderer ol,
.markdown-renderer blockquote,
.markdown-renderer pre,
.markdown-renderer__code-scroll,
.markdown-renderer hr,
.markdown-renderer__table-wrap {
margin: 0 0 0.9em;
@@ -2127,20 +2128,35 @@ body {
}
.markdown-renderer pre {
overflow: auto;
overflow: visible;
padding: 12px 14px;
border-radius: 10px;
background: #0f172a;
color: #e2e8f0;
}
.markdown-renderer__code-scroll {
max-width: 100%;
border-radius: 10px;
}
.markdown-renderer__code-scroll > .scrollbar__viewport,
.markdown-renderer__table-wrap > .scrollbar__viewport {
padding: 0;
}
.markdown-renderer__code-scroll pre {
min-width: max-content;
margin: 0;
}
.markdown-renderer hr {
border: 0;
border-top: 1px solid rgba(15, 23, 42, 0.12);
}
.markdown-renderer__table-wrap {
overflow-x: auto;
max-width: 100%;
}
.markdown-renderer__table {

View File

@@ -0,0 +1,810 @@
/* ─── Design tokens ─────────────────────────────────────────────────────────── */
.docs-page {
/* Light theme (default) */
--d-bg: #f6f8fb;
--d-text: #172033;
--d-border: #d9e1ec;
--d-sidebar-bg: #ffffff;
--d-brand-border: #e5ebf3;
--d-muted: #6b778c;
--d-nav-group: #7a8699;
--d-nav-link: #3a4658;
--d-nav-hover-bg: #edf3fb;
--d-nav-hover: #0d4f9f;
--d-nav-active-bg: #e5f0ff;
--d-nav-active: #0b5fc1;
--d-header-bg: rgba(255, 255, 255, 0.96);
--d-heading: #121a2a;
--d-input-border: #c8d3e2;
--d-input-focus: #2f80ed;
--d-input-focus-shadow: rgba(47, 128, 237, 0.14);
--d-dropdown-border: #d8e0eb;
--d-dropdown-shadow: 0 20px 48px rgba(35, 47, 68, 0.18);
--d-result-hover: #f0f5fc;
--d-excerpt: #69778b;
--d-toc-border: #d8e0eb;
--d-toc-text: #59667a;
--d-toc-empty: #8792a2;
--d-toc-active: #0b5fc1;
--d-link: #0b5fc1;
--d-state-border: #d9e1ec;
--d-state-bg: #ffffff;
--d-state-text: #59667a;
--d-brand-mark-bg: #172033;
--d-brand-mark-text: #ffffff;
--d-code-bg: #f0f4f9;
--d-code-border: #dbe4ef;
--d-code-text: #172033;
--d-table-head-bg: #f0f4f9;
--d-table-border: #dde4ef;
--d-sublink: #536070;
--d-sublink-hover: #0b5fc1;
--d-sublink-active: #0b5fc1;
--d-sublink-active-bg: #eef5ff;
--d-footer-bg: #f0f4f9;
--d-footer-border: #dde4ef;
--d-lang-btn: #4a5568;
--d-lang-btn-active-bg: #172033;
--d-lang-btn-active-text: #ffffff;
--d-theme-btn: #4a5568;
--d-segment-bg: #eef3f9;
--d-segment-slider: #ffffff;
--d-segment-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
--d-status-bg: #e8eef6;
}
.docs-page[data-theme="dark"] {
--d-bg: #0c0f16;
--d-text: #dce4f0;
--d-border: #1e2535;
--d-sidebar-bg: #111622;
--d-brand-border: #1a2030;
--d-muted: #7a8a9f;
--d-nav-group: #6e7e95;
--d-nav-link: #b0c0d4;
--d-nav-hover-bg: #1a2436;
--d-nav-hover: #4d9cff;
--d-nav-active-bg: #152040;
--d-nav-active: #5ba5ff;
--d-header-bg: rgba(13, 18, 28, 0.96);
--d-heading: #e8eef8;
--d-input-border: #263040;
--d-input-focus: #4d9cff;
--d-input-focus-shadow: rgba(77, 156, 255, 0.18);
--d-dropdown-border: #1e2a3c;
--d-dropdown-shadow: 0 20px 48px rgba(0, 0, 0, 0.5);
--d-result-hover: #162030;
--d-excerpt: #7a8a9f;
--d-toc-border: #1e2a3c;
--d-toc-text: #8a9bb8;
--d-toc-empty: #5e6e84;
--d-toc-active: #5ba5ff;
--d-link: #5ba5ff;
--d-state-border: #1e2535;
--d-state-bg: #111622;
--d-state-text: #8a9bb8;
--d-brand-mark-bg: #dce4f0;
--d-brand-mark-text: #111622;
--d-code-bg: #141a26;
--d-code-border: #1e2a3c;
--d-code-text: #c8d8f0;
--d-table-head-bg: #141a26;
--d-table-border: #1e2535;
--d-sublink: #7a8a9f;
--d-sublink-hover: #5ba5ff;
--d-sublink-active: #5ba5ff;
--d-sublink-active-bg: #0f1e38;
--d-footer-bg: #0e1420;
--d-footer-border: #1e2535;
--d-lang-btn: #8a9bb8;
--d-lang-btn-active-bg: #dce4f0;
--d-lang-btn-active-text: #111622;
--d-theme-btn: #8a9bb8;
--d-segment-bg: rgba(0, 0, 0, 0.28);
--d-segment-slider: #202938;
--d-segment-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
--d-status-bg: rgba(32, 41, 56, 0.72);
}
/* ─── Scrollbar theme overrides ──────────────────────────────────────────────── */
.docs-page .scrollbar__thumb {
background: rgba(160, 175, 200, 0.38);
}
.docs-page .scrollbar__thumb:hover,
.docs-page .scrollbar__thumb:focus-visible {
background: rgba(110, 130, 165, 0.72);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.docs-page .scrollbar__track--dragging .scrollbar__thumb {
background: rgba(85, 110, 150, 0.88);
}
.docs-page[data-theme="dark"] .scrollbar__thumb {
background: rgba(70, 95, 140, 0.52);
}
.docs-page[data-theme="dark"] .scrollbar__thumb:hover,
.docs-page[data-theme="dark"] .scrollbar__thumb:focus-visible {
background: rgba(100, 135, 195, 0.72);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.docs-page[data-theme="dark"] .scrollbar__track--dragging .scrollbar__thumb {
background: rgba(120, 160, 220, 0.88);
}
/* ─── Page shell ─────────────────────────────────────────────────────────────── */
.docs-page {
height: 100vh;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
background: var(--d-bg);
color: var(--d-text);
overflow: hidden;
}
/* ─── Sidebar ────────────────────────────────────────────────────────────────── */
.docs-sidebar {
min-height: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--d-border);
background: var(--d-sidebar-bg);
}
.docs-brand {
display: flex;
align-items: center;
gap: 12px;
min-height: 76px;
padding: 18px 22px;
color: inherit;
text-decoration: none;
border-bottom: 1px solid var(--d-brand-border);
}
.docs-brand__mark {
width: 36px;
height: 36px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--d-brand-mark-bg);
color: var(--d-brand-mark-text);
font-weight: 700;
}
.docs-brand__title,
.docs-brand__subtitle {
display: block;
}
.docs-brand__title {
font-size: 15px;
font-weight: 700;
}
.docs-brand__subtitle {
margin-top: 2px;
color: var(--d-muted);
font-size: 12px;
}
.docs-nav {
/* flex: 1 1 auto; min-height: 0; overflow: hidden — inherited from .scrollbar */
}
.docs-nav > .scrollbar__viewport {
padding: 18px 14px 12px;
}
.docs-nav__group + .docs-nav__group {
margin-top: 22px;
}
.docs-nav__heading {
margin: 0 0 8px;
padding: 0 8px;
color: var(--d-nav-group);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.docs-nav__item {
display: contents;
}
.docs-nav__link {
display: block;
padding: 8px 10px;
border-radius: 7px;
color: var(--d-nav-link);
font-size: 13px;
line-height: 1.35;
text-decoration: none;
}
.docs-nav__link:hover {
background: var(--d-nav-hover-bg);
color: var(--d-nav-hover);
}
.docs-nav__link--active {
background: var(--d-nav-active-bg);
color: var(--d-nav-active);
font-weight: 650;
}
/* Sidebar sub-items (Manual section headings) */
.docs-nav__subitems {
display: grid;
margin: 2px 0 6px;
padding-left: 10px;
border-left: 2px solid var(--d-border);
margin-left: 10px;
}
.docs-nav__sublink {
display: block;
padding: 5px 8px 5px 10px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--d-sublink);
font: inherit;
font-size: 12px;
line-height: 1.4;
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.docs-nav__sublink:hover {
color: var(--d-sublink-hover);
background: var(--d-nav-hover-bg);
}
.docs-nav__sublink--active {
color: var(--d-sublink-active);
background: var(--d-sublink-active-bg);
font-weight: 600;
}
/* Sidebar footer: language + theme toggles */
.docs-sidebar-footer {
display: grid;
grid-template-columns: minmax(76px, 0.76fr) minmax(126px, 1.08fr);
align-items: end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--d-footer-border);
background: var(--d-footer-bg);
}
.docs-footer-row {
display: grid;
}
.docs-theme-toggle {
--segmented-control-icon-size: calc(18px * var(--segmented-control-scale, 1));
}
.docs-theme-toggle .segmented-control__button {
flex-direction: row;
gap: 0;
}
.docs-theme-toggle .segmented-control__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.docs-theme-toggle .segmented-control__button[data-value="light"] {
color: #f59e0b;
}
.docs-theme-toggle .segmented-control__button[data-value="system"] {
color: #3b82f6;
}
.docs-theme-toggle .segmented-control__button[data-value="dark"] {
color: #8b5cf6;
}
.docs-theme-toggle .segmented-control__button:not(.segmented-control__button--active) {
opacity: 0.62;
}
.docs-theme-toggle .segmented-control__button--active {
opacity: 1;
}
/* ─── Main shell ─────────────────────────────────────────────────────────────── */
.docs-shell {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ─── Header ─────────────────────────────────────────────────────────────────── */
.docs-header {
position: relative;
z-index: 5;
min-height: 88px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 430px);
gap: 24px;
align-items: center;
padding: 18px 32px;
border-bottom: 1px solid var(--d-border);
background: var(--d-header-bg);
backdrop-filter: blur(4px);
}
.docs-header__eyebrow {
margin: 0 0 4px;
color: var(--d-muted);
font-size: 12px;
font-weight: 650;
}
.docs-header__title {
margin: 0;
color: var(--d-heading);
font-size: 24px;
line-height: 1.22;
}
/* ─── Search ─────────────────────────────────────────────────────────────────── */
.docs-search {
position: relative;
}
.docs-search__label {
display: block;
margin-bottom: 6px;
color: var(--d-muted);
font-size: 12px;
font-weight: 650;
}
.docs-search__input {
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid var(--d-input-border);
border-radius: 7px;
background: var(--d-state-bg);
color: var(--d-text);
font: inherit;
outline: none;
}
.docs-search__input:focus {
border-color: var(--d-input-focus);
box-shadow: 0 0 0 3px var(--d-input-focus-shadow);
}
.docs-search__results {
--docs-search-results-max-height: 420px;
--docs-search-results-padding: 8px;
--docs-search-results-scroll-max-height: calc(
var(--docs-search-results-max-height) - (var(--docs-search-results-padding) * 2)
);
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(520px, 76vw);
max-height: var(--docs-search-results-max-height);
overflow: hidden;
display: flex;
flex-direction: column;
padding: var(--docs-search-results-padding);
border: 1px solid var(--d-dropdown-border);
border-radius: 8px;
background: var(--d-state-bg);
box-shadow: var(--d-dropdown-shadow);
z-index: 20;
}
.docs-search__results > .scrollbar {
flex: 0 1 auto;
min-height: 0;
}
.docs-search__results-scroll {
max-height: var(--docs-search-results-scroll-max-height);
}
.docs-search__results-scroll > .scrollbar__viewport {
height: auto;
max-height: var(--docs-search-results-scroll-max-height);
}
.docs-search__result {
width: 100%;
display: grid;
gap: 3px;
padding: 10px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.docs-search__result:hover,
.docs-search__result:focus-visible {
background: var(--d-result-hover);
outline: none;
}
.docs-search__result-title {
color: var(--d-heading);
font-size: 13px;
font-weight: 700;
}
.docs-search__result-meta {
color: var(--d-nav-active);
font-size: 12px;
font-weight: 650;
}
.docs-search__result-excerpt,
.docs-search__empty {
color: var(--d-excerpt);
font-size: 12px;
line-height: 1.45;
}
.docs-search__empty {
padding: 12px;
}
/* ─── Content layout ─────────────────────────────────────────────────────────── */
.docs-content-layout {
flex: 1 1 0;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
overflow: hidden;
}
.docs-article {
min-width: 0;
/* overflow: hidden — from .scrollbar */
}
.docs-article > .scrollbar__viewport {
padding: 36px clamp(28px, 5vw, 72px) 72px;
}
/* ─── Markdown content ───────────────────────────────────────────────────────── */
.docs-markdown {
max-width: 920px;
font-size: 15px;
line-height: 1.78;
color: var(--d-text);
}
.docs-markdown.markdown-renderer h1 {
margin-bottom: 20px;
font-size: 34px;
letter-spacing: 0;
color: var(--d-heading);
}
.docs-markdown.markdown-renderer h2 {
margin-top: 42px;
padding-top: 8px;
font-size: 24px;
color: var(--d-heading);
}
.docs-markdown.markdown-renderer h3 {
margin-top: 30px;
font-size: 18px;
color: var(--d-heading);
}
.docs-markdown.markdown-renderer h1,
.docs-markdown.markdown-renderer h2,
.docs-markdown.markdown-renderer h3 {
scroll-margin-top: 24px;
}
.docs-markdown.markdown-renderer a {
color: var(--d-link);
}
.docs-markdown.markdown-renderer code {
padding: 1px 5px;
border: 1px solid var(--d-code-border);
border-radius: 4px;
background: var(--d-code-bg);
color: var(--d-code-text);
font-size: 0.88em;
}
.docs-markdown.markdown-renderer pre {
border: 1px solid var(--d-code-border);
border-radius: 8px;
background: var(--d-code-bg);
overflow: visible;
}
.docs-markdown .markdown-renderer__code-scroll {
max-width: 100%;
margin: 0 0 0.9em;
border-radius: 8px;
}
.docs-markdown .markdown-renderer__code-scroll > .scrollbar__viewport,
.docs-markdown .markdown-renderer__table-wrap > .scrollbar__viewport {
padding: 0;
}
.docs-markdown .markdown-renderer__code-scroll pre {
min-width: max-content;
margin: 0;
}
.docs-markdown.markdown-renderer pre code {
border: 0;
border-radius: 0;
background: transparent;
font-size: 0.88em;
}
.docs-markdown.markdown-renderer blockquote {
border-left: 3px solid var(--d-border);
background: var(--d-code-bg);
border-radius: 0 8px 8px 0;
color: var(--d-toc-text);
}
.docs-markdown.markdown-renderer hr {
border: 0;
border-top: 1px solid var(--d-border);
}
.docs-markdown .markdown-renderer__table {
min-width: 680px;
border-collapse: collapse;
background: var(--d-state-bg);
color: var(--d-text);
}
.docs-markdown .markdown-renderer__table-wrap {
max-width: 100%;
border-radius: 8px;
border: 1px solid var(--d-table-border);
}
.docs-markdown .markdown-renderer__table th {
background: var(--d-table-head-bg);
color: var(--d-heading);
font-weight: 650;
}
.docs-markdown .markdown-renderer__table th,
.docs-markdown .markdown-renderer__table td {
padding: 8px 12px;
border: 1px solid var(--d-table-border);
font-size: 13px;
line-height: 1.5;
}
.docs-markdown .markdown-renderer__table td {
background: var(--d-state-bg);
color: var(--d-text);
}
/* ─── TOC ────────────────────────────────────────────────────────────────────── */
.docs-toc {
overflow: hidden;
display: flex;
flex-direction: column;
}
.docs-toc__inner {
/* flex: 1 1 auto; min-height: 0; overflow: hidden — from .scrollbar */
}
.docs-toc__inner .scrollbar__viewport {
padding: 34px 22px 40px 0;
}
.docs-toc__title {
margin: 0 0 12px;
color: var(--d-toc-empty);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.docs-toc__nav {
display: grid;
gap: 2px;
border-left: 1px solid var(--d-toc-border);
}
.docs-toc__link {
display: block;
padding: 6px 8px 6px 14px;
border: 0;
background: transparent;
color: var(--d-toc-text);
font: inherit;
font-size: 12px;
line-height: 1.35;
text-align: left;
cursor: pointer;
border-radius: 0 5px 5px 0;
transition: color 0.1s, background 0.1s;
}
.docs-toc__link:hover,
.docs-toc__link:focus-visible {
color: var(--d-toc-active);
outline: none;
}
.docs-toc__link--active {
color: var(--d-toc-active);
font-weight: 650;
background: var(--d-nav-active-bg);
}
.docs-toc__link--level-3 {
padding-left: 26px;
}
.docs-toc__empty {
margin: 0;
color: var(--d-toc-empty);
font-size: 12px;
}
/* ─── State blocks ───────────────────────────────────────────────────────────── */
.docs-state,
.docs-not-found {
max-width: 720px;
padding: 28px;
border: 1px solid var(--d-state-border);
border-radius: 8px;
background: var(--d-state-bg);
color: var(--d-state-text);
}
.docs-not-found h2 {
margin: 0 0 8px;
color: var(--d-heading);
}
.docs-not-found p {
margin: 0 0 16px;
}
.docs-not-found a {
color: var(--d-link);
font-weight: 650;
text-decoration: none;
}
/* ─── Responsive ─────────────────────────────────────────────────────────────── */
@media (max-width: 1120px) {
.docs-page {
grid-template-columns: 240px minmax(0, 1fr);
}
.docs-content-layout {
grid-template-columns: minmax(0, 1fr);
}
.docs-toc {
display: none;
}
}
@media (max-width: 820px) {
.docs-page {
display: flex;
flex-direction: column;
overflow: hidden;
}
.docs-sidebar {
min-height: auto;
border-right: 0;
border-bottom: 1px solid var(--d-border);
}
.docs-brand {
min-height: 64px;
}
.docs-nav {
flex: 0 0 auto;
max-height: 220px;
}
.docs-nav > .scrollbar__viewport {
display: flex;
gap: 16px;
padding: 12px 14px;
}
.docs-nav__group {
min-width: 190px;
}
.docs-nav__group + .docs-nav__group {
margin-top: 0;
}
.docs-nav__subitems {
display: none;
}
.docs-shell {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.docs-header {
grid-template-columns: 1fr;
gap: 14px;
padding: 18px;
}
.docs-content-layout {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.docs-article {
min-height: 0;
}
.docs-article > .scrollbar__viewport {
padding: 24px 18px 56px;
}
.docs-markdown.markdown-renderer h1 {
font-size: 28px;
}
}

View File

@@ -0,0 +1,437 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
import {
createHeadingIdResolver,
defaultDocsSlug,
extractHeadings,
getDocsEntries,
getDocsEntry,
groupDocsEntries,
slugFromDocsHref,
} from './docs-content'
import type { DocsHeading, DocsLang } from './docs-content'
import { buildDocsSearchRecords, searchDocs } from './docs-search'
import type { DocsSearchRecord } from './docs-search'
import './Docs.css'
type DocsThemeMode = 'system' | 'light' | 'dark'
const MIN_TOC_HEADING_LEVEL = 2
const MAX_TOC_HEADING_LEVEL = 3
const ACTIVE_HEADING_TOP_OFFSET_PX = 72
const TOC_SCROLL_OFFSET_PX = 24
const FOOTER_CONTROL_SCALE = 0.86
function getHashFromHref(href: string): string {
const hashIndex = href.indexOf('#')
return hashIndex >= 0 ? href.slice(hashIndex) : ''
}
function readStoredLang(): DocsLang {
const stored = localStorage.getItem('docs-lang')
return stored === 'en' ? 'en' : 'zh'
}
function readStoredThemeMode(): DocsThemeMode {
const stored = localStorage.getItem('docs-theme')
if (stored === 'system' || stored === 'light' || stored === 'dark') {
return stored
}
return 'system'
}
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return 'light'
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export default function Docs() {
const { slug } = useParams()
const navigate = useNavigate()
const [lang, setLang] = useState<DocsLang>(readStoredLang)
const [themeMode, setThemeMode] = useState<DocsThemeMode>(readStoredThemeMode)
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
const [markdown, setMarkdown] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [searchRecords, setSearchRecords] = useState<DocsSearchRecord[]>([])
const [activeHeadingId, setActiveHeadingId] = useState<string>('')
const articleRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLDivElement>(null)
const docsEntries = useMemo(() => getDocsEntries(lang), [lang])
const activeSlug = slug || defaultDocsSlug
const activeEntry = useMemo(() => getDocsEntry(activeSlug, lang), [activeSlug, lang])
const groupedEntries = useMemo(() => groupDocsEntries(docsEntries), [docsEntries])
const effectiveTheme = themeMode === 'system' ? systemTheme : themeMode
const langOptions = useMemo(() => [
{ value: 'zh' as const, label: '中文' },
{ value: 'en' as const, label: 'EN' },
], [])
const themeOptions = useMemo(() => [
{
value: 'light' as const,
label: '浅色',
title: '浅色',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
),
},
{
value: 'system' as const,
label: '系统',
title: '跟随系统',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<path d="M8 21h8M12 17v4" />
</svg>
),
},
{
value: 'dark' as const,
label: '深色',
title: '深色',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
),
},
], [])
const handleLangChange = useCallback((newLang: DocsLang) => {
setLang(newLang)
localStorage.setItem('docs-lang', newLang)
}, [])
const handleThemeModeChange = useCallback((nextMode: DocsThemeMode) => {
setThemeMode(nextMode)
localStorage.setItem('docs-theme', nextMode)
}, [])
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
setSystemTheme(mediaQuery.matches ? 'dark' : 'light')
}
handleChange()
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
useEffect(() => {
let isCancelled = false
if (!activeEntry) {
setMarkdown('')
return
}
setIsLoading(true)
activeEntry.loader()
.then((content) => {
if (!isCancelled) setMarkdown(content)
})
.finally(() => {
if (!isCancelled) setIsLoading(false)
})
return () => { isCancelled = true }
}, [activeEntry])
useEffect(() => {
if (!markdown || !window.location.hash) return
window.requestAnimationFrame(() => {
document.getElementById(decodeURIComponent(window.location.hash.slice(1)))?.scrollIntoView({ block: 'start' })
})
}, [markdown])
useEffect(() => {
let isCancelled = false
buildDocsSearchRecords(docsEntries).then((records) => {
if (!isCancelled) setSearchRecords(records)
})
return () => { isCancelled = true }
}, [docsEntries])
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!searchRef.current?.contains(event.target as Node)) {
setIsSearchOpen(false)
}
}
window.addEventListener('pointerdown', handlePointerDown)
return () => window.removeEventListener('pointerdown', handlePointerDown)
}, [])
const headings = useMemo<DocsHeading[]>(() => {
return markdown
? extractHeadings(markdown).filter((h) =>
h.level >= MIN_TOC_HEADING_LEVEL && h.level <= MAX_TOC_HEADING_LEVEL
)
: []
}, [markdown])
const makeHeadingIdResolver = useMemo(() => {
return markdown ? createHeadingIdResolver(markdown) : undefined
}, [markdown])
// Scroll spy: track which heading is currently at the top of the article
useEffect(() => {
const article = articleRef.current
if (!article || headings.length === 0) {
setActiveHeadingId('')
return
}
const handleScroll = () => {
const articleTop = article.getBoundingClientRect().top
let active = headings[0]?.id || ''
for (const { id } of headings) {
const el = document.getElementById(id)
if (el && el.getBoundingClientRect().top - articleTop < ACTIVE_HEADING_TOP_OFFSET_PX) {
active = id
}
}
setActiveHeadingId(active)
}
article.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => article.removeEventListener('scroll', handleScroll)
}, [headings])
const searchResults = useMemo(() => searchDocs(searchRecords, searchQuery), [searchQuery, searchRecords])
const shouldShowSearchResults = isSearchOpen && Boolean(searchQuery.trim())
const transformLink = useCallback((href: string) => {
const docsSlug = slugFromDocsHref(href)
if (docsSlug) return { href: `/docs/${docsSlug}${getHashFromHref(href)}`, external: false }
if (href.startsWith('#')) return { href, external: false }
return { href, external: true }
}, [])
const handleSearchSelect = useCallback((resultSlug: string) => {
setSearchQuery('')
setIsSearchOpen(false)
navigate(`/docs/${resultSlug}`)
}, [navigate])
const handleTocClick = useCallback((headingId: string) => {
const el = document.getElementById(headingId)
if (el && articleRef.current) {
const articleTop = articleRef.current.getBoundingClientRect().top
const elTop = el.getBoundingClientRect().top
articleRef.current.scrollBy({ top: elTop - articleTop - TOC_SCROLL_OFFSET_PX, behavior: 'smooth' })
}
}, [])
// Sidebar H2 sub-items for active Manual doc
const sidebarSubHeadings = useMemo(() => {
if (activeEntry?.group !== 'Manual' || headings.length === 0) return []
return headings.filter((h) => h.level === 2)
}, [activeEntry, headings])
return (
<main className="docs-page" data-theme={effectiveTheme}>
<aside className="docs-sidebar" aria-label="Documentation navigation">
<Link className="docs-brand" to="/docs">
<span className="docs-brand__mark">P</span>
<span>
<span className="docs-brand__title">Planet Docs</span>
<span className="docs-brand__subtitle">Developer guide</span>
</span>
</Link>
<Scrollbar className="docs-nav">
{groupedEntries.map((group) => (
<section key={group.group} className="docs-nav__group">
<h2 className="docs-nav__heading">{group.group}</h2>
{group.entries.map((entry) => {
const isActive = entry.slug === activeSlug
return (
<div key={entry.slug} className="docs-nav__item">
<Link
className={isActive ? 'docs-nav__link docs-nav__link--active' : 'docs-nav__link'}
to={`/docs/${entry.slug}`}
>
{entry.title}
</Link>
{isActive && sidebarSubHeadings.length > 0 && (
<div className="docs-nav__subitems">
{sidebarSubHeadings.map((h) => (
<button
key={h.id}
type="button"
className={h.id === activeHeadingId ? 'docs-nav__sublink docs-nav__sublink--active' : 'docs-nav__sublink'}
onClick={() => handleTocClick(h.id)}
>
{h.text}
</button>
))}
</div>
)}
</div>
)
})}
</section>
))}
</Scrollbar>
<footer className="docs-sidebar-footer">
<div className="docs-footer-row docs-footer-row--language">
<SegmentedControl
ariaLabel="Language"
className="docs-lang-toggle"
options={langOptions}
scale={FOOTER_CONTROL_SCALE}
value={lang}
onChange={handleLangChange}
/>
</div>
<div className="docs-footer-row">
<SegmentedControl
ariaLabel="Theme"
className="docs-theme-toggle"
options={themeOptions}
scale={FOOTER_CONTROL_SCALE}
value={themeMode}
onChange={handleThemeModeChange}
/>
</div>
</footer>
</aside>
<section className="docs-shell">
<header className="docs-header">
<div>
<p className="docs-header__eyebrow">{activeEntry?.group || 'Docs'}</p>
<h1 className="docs-header__title">{activeEntry?.title || 'Document not found'}</h1>
</div>
<div className="docs-search" ref={searchRef}>
<label className="docs-search__label" htmlFor="docs-search-input">
{lang === 'zh' ? '搜索文档' : 'Search docs'}
</label>
<input
id="docs-search-input"
className="docs-search__input"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
setIsSearchOpen(Boolean(event.target.value.trim()))
}}
onFocus={() => {
if (searchQuery.trim()) {
setIsSearchOpen(true)
}
}}
placeholder={lang === 'zh' ? '搜索文档...' : 'Search guides, APIs, layers...'}
type="search"
/>
{shouldShowSearchResults && (
<div className="docs-search__results" role="listbox">
<Scrollbar className="docs-search__results-scroll">
{searchResults.length > 0 ? (
searchResults.map((result) => (
<button
key={result.entry.slug}
className="docs-search__result"
type="button"
onClick={() => handleSearchSelect(result.entry.slug)}
>
<span className="docs-search__result-title">{result.entry.title}</span>
<span className="docs-search__result-meta">{result.entry.group}</span>
<span className="docs-search__result-excerpt">{result.excerpt}</span>
</button>
))
) : (
<div className="docs-search__empty">
{lang === 'zh' ? '未找到匹配文档' : 'No matching docs'}
</div>
)}
</Scrollbar>
</div>
)}
</div>
</header>
<div className="docs-content-layout">
<Scrollbar className="docs-article" viewportRef={articleRef}>
{activeEntry ? (
isLoading ? (
<div className="docs-state">
{lang === 'zh' ? '加载中...' : 'Loading document...'}
</div>
) : (
<MarkdownRenderer
markdown={markdown}
className="docs-markdown"
getHeadingId={makeHeadingIdResolver}
transformLink={transformLink}
/>
)
) : (
<div className="docs-not-found">
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2>
<p>
{lang === 'zh'
? '请求的文档不在公开文档集中。'
: 'The requested guide is not part of the public technical documentation set.'}
</p>
<Link to="/docs">
{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}
</Link>
</div>
)}
</Scrollbar>
<aside className="docs-toc" aria-label="Document table of contents">
<Scrollbar className="docs-toc__inner">
<h2 className="docs-toc__title">
{lang === 'zh' ? '本页目录' : 'On this page'}
</h2>
{headings.length > 0 ? (
<nav className="docs-toc__nav">
{headings.map((heading) => (
<button
key={heading.id}
className={[
'docs-toc__link',
`docs-toc__link--level-${heading.level}`,
heading.id === activeHeadingId ? 'docs-toc__link--active' : '',
].filter(Boolean).join(' ')}
type="button"
onClick={() => handleTocClick(heading.id)}
>
{heading.text}
</button>
))}
</nav>
) : (
<p className="docs-toc__empty">
{lang === 'zh' ? '暂无章节' : 'No sections'}
</p>
)}
</Scrollbar>
</aside>
</div>
</section>
</main>
)
}

View File

@@ -0,0 +1,214 @@
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
loader: () => Promise<string>
}
export interface DocsHeading {
id: string
level: number
text: string
}
interface DocsMetadataEntry {
zh: { title: string; group: DocsGroup; order: number }
en: { title: string; group: DocsGroup; order: number }
}
const DOCS_README_FILENAME = 'README.md'
const FALLBACK_DOCS_ORDER = 999
const MAX_HEADING_ID_LENGTH = 80
export const defaultDocsSlug = 'overview'
const zhModules = import.meta.glob('../../../../docs/technical/zh/*.md', {
query: '?raw',
import: 'default',
})
const enModules = import.meta.glob('../../../../docs/technical/en/*.md', {
query: '?raw',
import: 'default',
})
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: 'Quickstart', 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 },
},
'earth-frontend-context.md': {
zh: { title: 'Earth Frontend Context', 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 Satellite Footprint Policy', group: 'Earth', order: 13 },
en: { title: 'Earth Satellite Footprint Policy', group: 'Earth', order: 13 },
},
'earth-bgp-context.md': {
zh: { title: 'BGP Context', group: 'Earth', order: 14 },
en: { title: 'BGP Context', group: 'Earth', order: 14 },
},
'earth-news-live-streams-collector-format.md': {
zh: { title: 'News Live Streams Collector Format', group: 'Earth', order: 15 },
en: { title: 'News Live Streams Collector Format', group: 'Earth', order: 15 },
},
'frontend-admin-frontend-context.md': {
zh: { title: 'Admin Frontend Context', group: 'Frontend', order: 20 },
en: { title: 'Admin Frontend Context', group: 'Frontend', order: 20 },
},
'frontend-layout-guidelines.md': {
zh: { title: 'Frontend Layout Guidelines', group: 'Frontend', order: 21 },
en: { title: 'Frontend Layout Guidelines', group: 'Frontend', order: 21 },
},
'backend-collectors.md': {
zh: { title: '数据采集系统', group: 'Backend', order: 30 },
en: { title: 'Data Collectors', group: 'Backend', order: 30 },
},
'backend-system-service-control.md': {
zh: { title: 'System Service Control', group: 'Backend', order: 31 },
en: { title: 'System Service Control', group: 'Backend', order: 31 },
},
'agents-aiprovider.md': {
zh: { title: 'AI Provider Guide', 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 },
},
}
const GROUP_ORDER: DocsGroup[] = ['Overview', 'Manual', 'Earth', 'Frontend', 'Backend', 'Agents', 'Ops', 'Other']
const ALL_KNOWN_SLUGS = new Set(
Object.keys(DOCS_METADATA).map((filename) =>
filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
)
)
function filenameFromPath(path: string): string {
return path.split('/').pop() || path
}
export function slugFromFilename(filename: string): string {
return filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
}
function fallbackTitleFromFilename(filename: string): string {
return filename
.replace(/\.md$/, '')
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
export function getDocsEntries(lang: DocsLang): DocsEntry[] {
const modules = lang === 'zh' ? zhModules : enModules
return Object.entries(modules)
.map(([path, loader]) => {
const filename = filenameFromPath(path)
const meta = DOCS_METADATA[filename]
const langMeta = meta?.[lang]
return {
slug: slugFromFilename(filename),
filename,
title: langMeta?.title || fallbackTitleFromFilename(filename),
group: (langMeta?.group || 'Other') as DocsGroup,
order: langMeta?.order ?? FALLBACK_DOCS_ORDER,
loader: loader as () => Promise<string>,
}
})
.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))
}
export function getDocsEntry(slug: string | undefined, lang: DocsLang): DocsEntry | undefined {
const normalizedSlug = slug || defaultDocsSlug
return getDocsEntries(lang).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 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, '/')
const filename = normalized.split('/').pop()
if (!filename?.endsWith('.md')) {
return null
}
const slug = slugFromFilename(filename)
return ALL_KNOWN_SLUGS.has(slug) ? slug : null
}

View File

@@ -0,0 +1,102 @@
import type { DocsEntry, DocsHeading } from './docs-content'
import { extractHeadings } from './docs-content'
export interface DocsSearchRecord {
entry: DocsEntry
markdown: string
headings: DocsHeading[]
plainText: string
}
export interface DocsSearchResult {
entry: DocsEntry
score: number
excerpt: string
}
const DEFAULT_EXCERPT_LENGTH = 160
const EXCERPT_CONTEXT_BEFORE = 56
const EXCERPT_CONTEXT_AFTER = 104
const MAX_SEARCH_RESULTS = 12
const SEARCH_SCORE = {
title: 80,
slug: 36,
group: 24,
headings: 32,
body: 8,
}
function stripMarkdown(markdown: string): string {
return markdown
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[#>*_\-|]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function createExcerpt(text: string, query: string): string {
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
const matchIndex = lowerText.indexOf(lowerQuery)
if (matchIndex < 0) {
return text.slice(0, DEFAULT_EXCERPT_LENGTH)
}
const start = Math.max(0, matchIndex - EXCERPT_CONTEXT_BEFORE)
const end = Math.min(text.length, matchIndex + query.length + EXCERPT_CONTEXT_AFTER)
const prefix = start > 0 ? '...' : ''
const suffix = end < text.length ? '...' : ''
return `${prefix}${text.slice(start, end)}${suffix}`
}
export async function buildDocsSearchRecords(entries: DocsEntry[]): Promise<DocsSearchRecord[]> {
const records = await Promise.all(
entries.map(async (entry) => {
const markdown = await entry.loader()
return {
entry,
markdown,
headings: extractHeadings(markdown),
plainText: stripMarkdown(markdown),
}
}),
)
return records
}
export function searchDocs(records: DocsSearchRecord[], rawQuery: string): DocsSearchResult[] {
const query = rawQuery.trim().toLowerCase()
if (!query) {
return []
}
return records
.map((record) => {
const title = record.entry.title.toLowerCase()
const slug = record.entry.slug.toLowerCase()
const group = record.entry.group.toLowerCase()
const headings = record.headings.map((heading) => heading.text).join(' ').toLowerCase()
const body = record.plainText.toLowerCase()
let score = 0
if (title.includes(query)) score += SEARCH_SCORE.title
if (slug.includes(query)) score += SEARCH_SCORE.slug
if (group.includes(query)) score += SEARCH_SCORE.group
if (headings.includes(query)) score += SEARCH_SCORE.headings
if (body.includes(query)) score += SEARCH_SCORE.body
return {
entry: record.entry,
score,
excerpt: createExcerpt(record.plainText, rawQuery.trim()),
}
})
.filter((result) => result.score > 0)
.sort((a, b) => b.score - a.score || a.entry.order - b.entry.order)
.slice(0, MAX_SEARCH_RESULTS)
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />