diff --git a/cli/assets/viewer-app.js b/cli/assets/viewer-app.js new file mode 100644 index 0000000..d5c2fb6 --- /dev/null +++ b/cli/assets/viewer-app.js @@ -0,0 +1,243 @@ +import { createDetailRenderer } from "./viewer-detail.js"; +import { createGraphController } from "./viewer-graph.js"; +import { colorOf, createTopicColors, documentName, matchesSearch, STATUS_COLOR } from "./viewer-model.js"; + +const elements = { + repo: document.getElementById("repo"), + statDocs: document.getElementById("stat-docs"), + statBaseline: document.getElementById("stat-baseline"), + statValidate: document.getElementById("stat-validate"), + statDelta: document.getElementById("stat-delta"), + refresh: document.getElementById("refresh"), + modeTopics: document.getElementById("mode-topics"), + modeDocs: document.getElementById("mode-docs"), + search: document.getElementById("search"), + documentList: document.getElementById("doc-list"), + graphWrap: document.getElementById("graph-wrap"), + graph: document.getElementById("graph"), + graphHint: document.getElementById("graph-hint"), + legend: document.getElementById("legend"), + graphEmpty: document.getElementById("graph-empty"), + detail: document.getElementById("detail"), + detailContainer: document.querySelector("#detail .inner"), + detailClose: document.getElementById("detail-close"), + error: document.getElementById("load-error"), + zoomIn: document.getElementById("zoom-in"), + zoomOut: document.getElementById("zoom-out"), + zoomReset: document.getElementById("zoom-reset") +}; + +let state = null; +let selectedDocument = null; +let selectedTopic = null; +let mode = "topics"; +let topicColors = new Map(); +let resizeTimer = null; + +const graph = createGraphController({ + svg: elements.graph, + hint: elements.graphHint, + legend: elements.legend, + empty: elements.graphEmpty, + onSelectTopic: selectTopic, + onSelectDocument: selectDocument +}); + +const detail = createDetailRenderer({ + panel: elements.detail, + container: elements.detailContainer, + onSelectDocument: selectDocument, + getState: () => state +}); + +async function loadState() { + elements.refresh.disabled = true; + elements.refresh.setAttribute("aria-busy", "true"); + hideError(); + try { + const response = await fetch("/api/state", { headers: { accept: "application/json" } }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const nextState = await response.json(); + if (!Array.isArray(nextState.nodes) || !Array.isArray(nextState.edges)) throw new Error("服务返回了无效状态"); + state = nextState; + topicColors = createTopicColors(state.nodes); + if (selectedDocument && !state.nodes.some((node) => node.path === selectedDocument)) selectedDocument = null; + if (selectedTopic && !state.nodes.some((node) => node.topic === selectedTopic)) selectedTopic = null; + renderHeader(); + renderSidebar(); + renderGraph(); + if (selectedDocument) await detail.showDocument(selectedDocument, state); + else if (selectedTopic) detail.showTopic(selectedTopic, state, topicColors); + } catch (error) { + showError(error instanceof Error ? error.message : String(error)); + } finally { + elements.refresh.disabled = false; + elements.refresh.removeAttribute("aria-busy"); + } +} + +function renderHeader() { + elements.repo.textContent = `/ ${state.repository}`; + elements.statDocs.textContent = `${state.nodes.length} docs · ~${state.growth.currentTotalEstimatedTokens} tokens`; + + const baseline = state.baseline; + const shortRevision = baseline.revision?.slice(0, 7); + if (!baseline.revision) { + setChip(elements.statBaseline, "baseline 缺失", "bad"); + } else if (baseline.degradedReason || baseline.relevantBehindHead === null) { + setChip(elements.statBaseline, `baseline ${shortRevision} · 状态未知`, "warn"); + } else if (baseline.relevantBehindHead > 0) { + setChip(elements.statBaseline, `baseline ${shortRevision} · ${baseline.relevantBehindHead} 个源码提交待复核`, "warn"); + } else if (baseline.metadataOnlyBehind) { + setChip(elements.statBaseline, `baseline ${shortRevision} · metadata-only,知识干净`, "ok"); + } else { + setChip(elements.statBaseline, `baseline ${shortRevision} · 最新`, "ok"); + } + + const validation = state.validate; + setChip( + elements.statValidate, + validation.ok ? `validate ok${validation.warnings ? ` · ${validation.warnings} warn` : ""}` : `validate ${validation.errors} errors`, + validation.ok ? "ok" : "bad" + ); + + const stale = state.nodes.filter((node) => node.status !== "fresh").length; + const unmapped = state.delta.unmappedCommittedPaths.length + state.delta.unmappedDirtyPaths.length; + if (stale) setChip(elements.statDelta, `${stale} 份待同步 · 建议 ${state.delta.suggestedMode}`, "warn"); + else if (unmapped) setChip(elements.statDelta, `${unmapped} 个未映射变化 · 建议 ${state.delta.suggestedMode}`, "warn"); + else setChip(elements.statDelta, "知识面新鲜", "ok"); +} + +function setChip(chip, text, stateClass) { + chip.textContent = text; + chip.className = `chip ${stateClass}`; +} + +function renderSidebar() { + if (!state) return; + const query = elements.search.value; + const matchingNodes = state.nodes.filter((node) => matchesSearch(node, query)); + const groups = new Map([["", matchingNodes.filter((node) => !node.topic)]]); + for (const node of matchingNodes) { + if (!node.topic) continue; + const documents = groups.get(node.topic) ?? []; + documents.push(node); + groups.set(node.topic, documents); + } + + const fragment = document.createDocumentFragment(); + for (const [topic, nodes] of groups) { + if (!nodes.length) continue; + const label = document.createElement(topic ? "button" : "div"); + label.className = "group-label"; + if (topic) { + label.type = "button"; + const swatch = element("span", "swatch"); + swatch.style.backgroundColor = colorOf(topicColors, topic); + label.append(swatch, `${topic}/`); + label.addEventListener("click", () => selectTopic(topic)); + } else { + label.textContent = "root"; + } + fragment.append(label); + + for (const node of nodes) { + const item = element("button", `doc-item${selectedDocument === node.path ? " active" : ""}`); + item.type = "button"; + item.title = node.path; + item.setAttribute("aria-current", selectedDocument === node.path ? "true" : "false"); + const dot = element("span", "dot"); + dot.style.backgroundColor = STATUS_COLOR[node.status]; + dot.style.color = STATUS_COLOR[node.status]; + item.append(dot, element("span", "name", documentName(node.path)), element("span", "kind", node.kind)); + item.addEventListener("click", () => selectDocument(node.path)); + fragment.append(item); + } + } + if (!fragment.childNodes.length) fragment.append(element("div", "empty-list", "没有匹配的文档")); + elements.documentList.replaceChildren(fragment); +} + +function renderGraph() { + if (!state) return; + graph.render(graphInput()); +} + +function graphInput() { + return { state, mode, selected: selectedDocument, topicColors }; +} + +function setMode(nextMode, { render = true } = {}) { + mode = nextMode; + elements.modeTopics.className = mode === "topics" ? "on" : ""; + elements.modeDocs.className = mode === "docs" ? "on" : ""; + elements.modeTopics.setAttribute("aria-pressed", String(mode === "topics")); + elements.modeDocs.setAttribute("aria-pressed", String(mode === "docs")); + if (render && state) renderGraph(); +} + +function selectTopic(topic) { + selectedTopic = topic; + selectedDocument = null; + renderSidebar(); + if (mode !== "topics") setMode("topics"); + graph.highlight(`topic:${topic}`); + detail.showTopic(topic, state, topicColors); +} + +async function selectDocument(path) { + selectedDocument = path; + selectedTopic = null; + renderSidebar(); + if (mode !== "docs") setMode("docs"); + else graph.highlight(path); + await detail.showDocument(path, state); +} + +function showError(message) { + elements.error.textContent = `Viewer 加载失败:${message}`; + elements.error.hidden = false; +} + +function hideError() { + elements.error.hidden = true; + elements.error.textContent = ""; +} + +function element(tagName, className, text) { + const node = document.createElement(tagName); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +elements.search.addEventListener("input", renderSidebar); +elements.refresh.addEventListener("click", loadState); +elements.modeTopics.addEventListener("click", () => setMode("topics")); +elements.modeDocs.addEventListener("click", () => setMode("docs")); +elements.zoomIn.addEventListener("click", () => graph.zoomBy(1.15)); +elements.zoomOut.addEventListener("click", () => graph.zoomBy(.87)); +elements.zoomReset.addEventListener("click", graph.resetView); +elements.detailClose.addEventListener("click", detail.closePanel); + +window.addEventListener("keydown", (event) => { + const target = event.target; + const isEditing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable; + if (event.key === "/" && !isEditing) { + event.preventDefault(); + elements.search.focus(); + } else if (event.key === "Escape") { + detail.closePanel(); + if (document.activeElement === elements.search) elements.search.blur(); + } +}); + +const resizeObserver = new ResizeObserver(() => { + if (!state) return; + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => graph.resize(graphInput()), 120); +}); +resizeObserver.observe(elements.graphWrap); + +setMode("topics", { render: false }); +loadState(); diff --git a/cli/assets/viewer-detail.js b/cli/assets/viewer-detail.js new file mode 100644 index 0000000..87dd04d --- /dev/null +++ b/cli/assets/viewer-detail.js @@ -0,0 +1,223 @@ +import { colorOf, documentName, resolveRelativeDocumentPath, STATUS_COLOR, worstStatus } from "./viewer-model.js"; + +const ALLOWED_TAGS = new Set([ + "A", "BLOCKQUOTE", "BR", "CODE", "DEL", "EM", "H1", "H2", "H3", "H4", "H5", "H6", + "HR", "IMG", "LI", "OL", "P", "PRE", "STRONG", "TABLE", "TBODY", "TD", "TH", "THEAD", "TR", "UL" +]); +const DROP_TAGS = new Set(["EMBED", "FORM", "IFRAME", "INPUT", "LINK", "META", "OBJECT", "SCRIPT", "STYLE"]); + +export function createDetailRenderer({ panel, container, onSelectDocument, getState }) { + let activeRequest = null; + + function showPlaceholder(message = "选择一个 topic 或文档查看详情") { + activeRequest?.abort(); + const placeholder = element("div", "placeholder", message); + container.replaceChildren(placeholder); + panel.classList.remove("open"); + } + + function showTopic(topic, state, topicColors) { + activeRequest?.abort(); + const documents = state.nodes.filter((node) => node.topic === topic); + const title = element("h2"); + const swatch = element("span", "swatch"); + swatch.style.backgroundColor = colorOf(topicColors, topic); + swatch.style.width = "12px"; + swatch.style.height = "12px"; + swatch.style.marginRight = "7px"; + title.append(swatch, `${topic}/`); + + const tokenCount = documents.reduce((sum, node) => sum + node.estimatedTokens, 0); + const description = element( + "div", + "desc", + `${documents.length} docs · ~${tokenCount} tokens · 状态最差 ${worstStatus(documents)}` + ); + const cards = documents.map((node) => { + const card = element("button", "topic-doc-card"); + card.type = "button"; + card.addEventListener("click", () => onSelectDocument(node.path)); + const cardTitle = element("span", "title"); + const dot = element("span", "dot"); + dot.style.backgroundColor = STATUS_COLOR[node.status]; + cardTitle.append(dot, documentName(node.path), element("span", "kind", node.kind)); + card.append(cardTitle, element("span", "description", node.description)); + return card; + }); + container.replaceChildren(title, description, ...cards); + openPanel(); + } + + async function showDocument(path, state) { + activeRequest?.abort(); + activeRequest = new AbortController(); + const request = activeRequest; + container.replaceChildren(element("div", "placeholder", "正在加载文档…")); + openPanel(); + + try { + const response = await fetch(`/api/doc?path=${encodeURIComponent(path)}`, { signal: request.signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const documentData = await response.json(); + if (request !== activeRequest) return; + renderDocument(path, documentData, state); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + const message = error instanceof Error ? error.message : String(error); + container.replaceChildren(element("div", "placeholder", `加载失败:${message}`)); + } + } + + function renderDocument(path, documentData, state) { + const node = state.nodes.find((item) => item.path === path); + const frontmatter = documentData.frontmatter ?? {}; + const title = element("h2", null, path); + const description = element("div", "desc", frontmatter.description ?? ""); + const meta = element("div", "meta-row"); + meta.append( + element("span", "chip", frontmatter.kind ?? "?"), + statusChip(node?.status ?? "fresh"), + element("span", "chip", `~${documentData.estimatedTokens} tokens · ${documentData.lineCount} lines`) + ); + + const content = [title, description, meta]; + const relations = frontmatter.relations ?? {}; + const requires = relationBlock("requires", relations.requires); + const related = relationBlock("related", relations.related); + if (requires) content.push(requires); + if (related) content.push(related); + + const codePaths = frontmatter.code?.paths ?? []; + if (codePaths.length) { + const paths = element("div", "meta-row"); + paths.append(...codePaths.map((codePath) => element("span", "code-path", codePath))); + content.push(paths); + } + + const body = element("div"); + body.id = "doc-body"; + renderMarkdown(body, documentData.body ?? "", path, getState(), onSelectDocument); + content.push(body); + container.replaceChildren(...content); + panel.scrollTop = 0; + } + + function relationBlock(label, targets) { + if (!targets?.length) return null; + const block = element("div", "rel-block"); + const heading = element("b", null, `${label}: `); + block.append(heading); + targets.forEach((target, index) => { + if (index > 0) block.append(" · "); + const link = element("button", "rel-link", target); + link.type = "button"; + link.addEventListener("click", () => onSelectDocument(target)); + block.append(link); + }); + return block; + } + + function openPanel() { + panel.classList.add("open"); + } + + function closePanel() { + panel.classList.remove("open"); + } + + return { showPlaceholder, showTopic, showDocument, closePanel }; +} + +function statusChip(status) { + const chip = element("span", "chip", status); + chip.style.color = STATUS_COLOR[status] ?? STATUS_COLOR.fresh; + return chip; +} + +function renderMarkdown(target, source, documentPath, state, onSelectDocument) { + const normalized = replaceCodeReferences(String(source)); + let rendered; + try { + rendered = window.marked.parse(normalized); + } catch { + const fallback = element("pre", null, source); + target.replaceChildren(fallback); + return; + } + + const template = document.createElement("template"); + template.innerHTML = rendered; + sanitizeFragment(template.content); + for (const code of template.content.querySelectorAll("code")) { + if (code.textContent.startsWith("⌁ ")) code.classList.add("coderef"); + } + for (const link of template.content.querySelectorAll("a[href]")) { + const href = link.getAttribute("href") ?? ""; + if (/^(https?:|mailto:)/i.test(href)) { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + continue; + } + if (href.startsWith("#")) continue; + const targetPath = resolveRelativeDocumentPath(documentPath, href); + if (state.nodes.some((node) => node.path === targetPath)) { + link.addEventListener("click", (event) => { + event.preventDefault(); + onSelectDocument(targetPath); + }); + } + } + target.replaceChildren(template.content); +} + +function replaceCodeReferences(source) { + return source.replace(/]*?)\/>/g, (_match, attributes) => { + const path = /path="([^"]*)"/.exec(attributes)?.[1] ?? ""; + const symbol = /symbol="([^"]*)"/.exec(attributes)?.[1] ?? ""; + const label = `⌁ ${path}${symbol ? ` · ${symbol}` : ""}`.replace(/`/g, "ˋ"); + return `\`${label}\``; + }); +} + +function sanitizeFragment(fragment) { + const elements = [...fragment.querySelectorAll("*")].reverse(); + for (const node of elements) { + if (DROP_TAGS.has(node.tagName)) { + node.remove(); + continue; + } + if (!ALLOWED_TAGS.has(node.tagName)) { + node.replaceWith(...node.childNodes); + continue; + } + for (const attribute of [...node.attributes]) { + if (!isAllowedAttribute(node, attribute.name, attribute.value)) node.removeAttribute(attribute.name); + } + } +} + +function isAllowedAttribute(node, name, value) { + const normalized = name.toLowerCase(); + if (node.tagName === "A" && ["href", "title"].includes(normalized)) { + return normalized !== "href" || isSafeUrl(value); + } + if (node.tagName === "IMG" && ["src", "alt", "title"].includes(normalized)) { + return normalized !== "src" || isSafeUrl(value); + } + if (["TD", "TH"].includes(node.tagName) && ["colspan", "rowspan"].includes(normalized)) { + return /^\d{1,2}$/.test(value); + } + return false; +} + +function isSafeUrl(value) { + const trimmed = value.trim(); + return /^(https?:|mailto:|#|\/|\.\.?\/)/i.test(trimmed) || !/^[a-z][a-z\d+.-]*:/i.test(trimmed); +} + +function element(tagName, className, text) { + const node = document.createElement(tagName); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} diff --git a/cli/assets/viewer-graph.js b/cli/assets/viewer-graph.js new file mode 100644 index 0000000..9b532e1 --- /dev/null +++ b/cli/assets/viewer-graph.js @@ -0,0 +1,471 @@ +import { buildDocumentGraph, buildTopicGraph, STATUS_COLOR } from "./viewer-model.js"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); + +export function layoutGraph(graph, width, height, mode) { + const nodes = graph.nodes.map((node) => ({ ...node, x: 0, y: 0, vx: 0, vy: 0 })); + const edges = graph.edges.map((edge) => ({ ...edge })); + if (!nodes.length) return { nodes, edges }; + + const margin = 54; + const usableWidth = Math.max(160, width - margin * 2); + const usableHeight = Math.max(160, height - margin * 2); + const centerX = width / 2; + const centerY = height / 2; + const byId = new Map(nodes.map((node) => [node.id, node])); + const indexById = new Map(nodes.map((node, index) => [node.id, index])); + + if (mode === "docs") initializeDocumentClusters(nodes, centerX, centerY, usableWidth, usableHeight); + else initializeRadial(nodes, centerX, centerY, Math.min(usableWidth, usableHeight) * .43); + + const iterations = nodes.length > 500 ? 22 : nodes.length > 200 ? 30 : nodes.length > 80 ? 42 : 64; + const cellSize = Math.max(56, Math.min(110, Math.min(usableWidth, usableHeight) / 5)); + const clusterCenters = buildClusterCenters(nodes, centerX, centerY, usableWidth, usableHeight); + + for (let iteration = 0; iteration < iterations; iteration += 1) { + const cells = new Map(); + for (const node of nodes) { + const key = cellKey(node.x, node.y, cellSize); + const bucket = cells.get(key) ?? []; + bucket.push(node); + cells.set(key, bucket); + } + + for (const node of nodes) { + const cellX = Math.floor(node.x / cellSize); + const cellY = Math.floor(node.y / cellSize); + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + for (let offsetY = -1; offsetY <= 1; offsetY += 1) { + const bucket = cells.get(`${cellX + offsetX}:${cellY + offsetY}`) ?? []; + for (const other of bucket) { + if ((indexById.get(other.id) ?? 0) <= (indexById.get(node.id) ?? 0)) continue; + pushApart(node, other, cellSize); + } + } + } + } + + for (const edge of edges) { + const source = byId.get(edge.from); + const target = byId.get(edge.to); + if (!source || !target) continue; + const dx = target.x - source.x; + const dy = target.y - source.y; + const distance = Math.hypot(dx, dy) || 1; + const desired = source.radius + target.radius + (mode === "topics" ? 110 : 68); + const pull = (distance - desired) * (mode === "topics" ? .008 : .0055); + const fx = dx / distance * pull; + const fy = dy / distance * pull; + source.vx += fx; + source.vy += fy; + target.vx -= fx; + target.vy -= fy; + } + + for (const node of nodes) { + const clusterCenter = node.cluster ? clusterCenters.get(node.cluster) : null; + const gravityX = clusterCenter?.x ?? centerX; + const gravityY = clusterCenter?.y ?? centerY; + const gravity = node.cluster ? .009 : .0035; + node.vx += (gravityX - node.x) * gravity; + node.vy += (gravityY - node.y) * gravity; + node.x += node.vx *= .74; + node.y += node.vy *= .74; + node.x = Math.min(width - margin, Math.max(margin, node.x)); + node.y = Math.min(height - margin, Math.max(margin, node.y)); + } + } + + return { nodes, edges }; +} + +function initializeRadial(nodes, centerX, centerY, spread) { + const ordered = [...nodes].sort((left, right) => left.id.localeCompare(right.id)); + ordered.forEach((node, index) => { + const radius = spread * Math.sqrt((index + .65) / ordered.length); + const angle = index * GOLDEN_ANGLE + stableUnit(node.id) * .35; + node.x = centerX + Math.cos(angle) * radius; + node.y = centerY + Math.sin(angle) * radius; + }); +} + +function initializeDocumentClusters(nodes, centerX, centerY, width, height) { + const groups = new Map(); + for (const node of nodes) { + const bucket = groups.get(node.cluster) ?? []; + bucket.push(node); + groups.set(node.cluster, bucket); + } + const clusterCenters = ellipseCenters([...groups.keys()].sort(), centerX, centerY, width, height); + for (const [cluster, groupedNodes] of groups) { + const center = clusterCenters.get(cluster); + [...groupedNodes].sort((left, right) => left.id.localeCompare(right.id)).forEach((node, index) => { + const radius = 18 + 23 * Math.sqrt(index); + const angle = index * GOLDEN_ANGLE + stableUnit(node.id) * .4; + node.x = center.x + Math.cos(angle) * radius; + node.y = center.y + Math.sin(angle) * radius; + }); + } +} + +function buildClusterCenters(nodes, centerX, centerY, width, height) { + const clusters = [...new Set(nodes.map((node) => node.cluster).filter(Boolean))].sort(); + return ellipseCenters(clusters, centerX, centerY, width, height); +} + +function ellipseCenters(clusters, centerX, centerY, width, height) { + const result = new Map(); + clusters.forEach((cluster, index) => { + if (clusters.length === 1) { + result.set(cluster, { x: centerX, y: centerY }); + return; + } + const angle = index / clusters.length * Math.PI * 2 - Math.PI / 2; + result.set(cluster, { + x: centerX + Math.cos(angle) * width * .31, + y: centerY + Math.sin(angle) * height * .31 + }); + }); + return result; +} + +function pushApart(left, right, cellSize) { + let dx = right.x - left.x; + let dy = right.y - left.y; + let distance = Math.hypot(dx, dy); + if (distance === 0) { + const angle = stableUnit(`${left.id}:${right.id}`) * Math.PI * 2; + dx = Math.cos(angle); + dy = Math.sin(angle); + distance = 1; + } + const desired = Math.min(cellSize * .92, left.radius + right.radius + 28); + if (distance >= desired) return; + const force = Math.min(5, (desired - distance) * .055); + const fx = dx / distance * force; + const fy = dy / distance * force; + left.vx -= fx; + left.vy -= fy; + right.vx += fx; + right.vy += fy; +} + +function cellKey(x, y, cellSize) { + return `${Math.floor(x / cellSize)}:${Math.floor(y / cellSize)}`; +} + +function stableUnit(value) { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 4294967295; +} + +export function createGraphController(options) { + const { svg, hint, legend, empty, onSelectTopic, onSelectDocument } = options; + let current = null; + let view = { x: 0, y: 0, scale: 1 }; + let interaction = null; + let highlightedId = null; + + const onWheel = (event) => { + event.preventDefault(); + zoomBy(event.deltaY < 0 ? 1.12 : .9, event.offsetX, event.offsetY); + }; + + const onPointerDown = (event) => { + if (event.button !== 0 || !current) return; + const nodeElement = event.target.closest?.(".node"); + const node = nodeElement ? current.nodesById.get(nodeElement.dataset.nodeId) : null; + interaction = node + ? { type: "node", node, nodeElement, startX: event.clientX, startY: event.clientY, nodeX: node.x, nodeY: node.y, moved: false } + : { type: "pan", startX: event.clientX, startY: event.clientY, viewX: view.x, viewY: view.y, moved: false }; + svg.classList.add("dragging"); + svg.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event) => { + if (!interaction || !current) return; + const dx = event.clientX - interaction.startX; + const dy = event.clientY - interaction.startY; + interaction.moved ||= Math.hypot(dx, dy) > 3; + if (interaction.type === "node") { + interaction.node.x = interaction.nodeX + dx / view.scale; + interaction.node.y = interaction.nodeY + dy / view.scale; + current.position(); + } else { + view.x = interaction.viewX + dx; + view.y = interaction.viewY + dy; + applyView(); + } + }; + + const onPointerUp = (event) => { + if (!interaction) return; + if (!interaction.moved) { + if (interaction.type === "node") selectTarget(interaction.node.target); + else highlight(null); + } + interaction = null; + svg.classList.remove("dragging"); + if (svg.hasPointerCapture(event.pointerId)) svg.releasePointerCapture(event.pointerId); + }; + + const onKeyDown = (event) => { + const nodeElement = event.target.closest?.(".node"); + if (nodeElement && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + const node = current?.nodesById.get(nodeElement.dataset.nodeId); + if (node) selectTarget(node.target); + return; + } + if (event.key === "0") { + event.preventDefault(); + resetView(); + } else if (event.key === "+" || event.key === "=") { + event.preventDefault(); + zoomBy(1.15); + } else if (event.key === "-") { + event.preventDefault(); + zoomBy(.87); + } + }; + + svg.addEventListener("wheel", onWheel, { passive: false }); + svg.addEventListener("pointerdown", onPointerDown); + svg.addEventListener("pointermove", onPointerMove); + svg.addEventListener("pointerup", onPointerUp); + svg.addEventListener("pointercancel", onPointerUp); + svg.addEventListener("keydown", onKeyDown); + + function render(input) { + const bounds = svg.getBoundingClientRect(); + const width = Math.max(240, bounds.width); + const height = Math.max(240, bounds.height); + const source = input.mode === "topics" + ? buildTopicGraph(input.state, input.topicColors) + : buildDocumentGraph(input.state, input.topicColors); + const graph = layoutGraph(source, width, height, input.mode); + svg.replaceChildren(); + empty.hidden = graph.nodes.length > 0; + setGraphChrome(input.mode); + + const defs = svgElement("defs"); + defs.append(createArrowMarker(), createShadowFilter()); + const root = svgElement("g"); + svg.append(defs, root); + + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + const edgeElements = graph.edges.map((edge) => { + const path = svgElement("path"); + path.setAttribute("class", "edge-path"); + path.setAttribute("stroke", edge.style === "link" ? "#c8cfd8" : "#9aa4b2"); + path.setAttribute("stroke-width", String(edge.width ?? (edge.style === "link" ? 1 : 1.7))); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-opacity", edge.style === "link" ? ".75" : ".85"); + if (edge.style === "related") path.setAttribute("stroke-dasharray", "5 5"); + if (edge.style === "requires") path.setAttribute("marker-end", "url(#arrow)"); + root.append(path); + return { edge, element: path }; + }); + + const nodeElements = graph.nodes.map((node) => { + const group = svgElement("g"); + group.setAttribute("class", "node"); + group.setAttribute("data-node-id", node.id); + group.setAttribute("tabindex", "0"); + group.setAttribute("role", "button"); + group.setAttribute("aria-label", node.tooltip.replace(/\n/g, ",")); + const circle = svgElement("circle"); + circle.setAttribute("r", String(node.radius)); + circle.setAttribute("fill", node.fill); + circle.setAttribute("fill-opacity", node.isTopic ? ".92" : ".88"); + circle.setAttribute("stroke", node.status === "fresh" ? "#ffffff" : STATUS_COLOR[node.status]); + circle.setAttribute("stroke-width", node.status === "fresh" ? "2" : "3.5"); + circle.setAttribute("filter", "url(#soft)"); + group.append(circle); + if (node.isTopic) { + group.append( + textElement(node.label, 0, 4, 12.5, 700, "#ffffff"), + textElement(node.subLabel, 0, node.radius + 15, 10.5, 500, null, "sub") + ); + } else { + group.append(textElement(node.label, 0, node.radius + 13, 10, 500)); + } + const title = svgElement("title"); + title.textContent = node.tooltip; + group.append(title); + root.append(group); + return { node, element: group }; + }); + + const position = () => { + for (const { edge, element } of edgeElements) { + const sourceNode = nodesById.get(edge.from); + const targetNode = nodesById.get(edge.to); + if (!sourceNode || !targetNode) continue; + element.setAttribute("d", edgePath(sourceNode, targetNode, edge.style)); + } + for (const { node, element } of nodeElements) { + element.setAttribute("transform", `translate(${node.x},${node.y})`); + } + }; + + current = { root, graph, nodesById, nodeElements, edgeElements, position }; + view = { x: 0, y: 0, scale: 1 }; + position(); + applyView(); + highlight(input.mode === "docs" ? input.selected : highlightedId); + } + + function resize(input) { + render(input); + } + + function selectTarget(target) { + if (target.type === "topic") onSelectTopic(target.value); + else onSelectDocument(target.value); + } + + function setGraphChrome(mode) { + hint.textContent = mode === "topics" + ? "Topic 拓扑 · 边粗细表示跨域引用数 · 点击下钻" + : "文档全图 · 点击查看详情 · 拖动画布 / 滚轮缩放"; + legend.replaceChildren(); + const edgeItems = mode === "topics" + ? [["", "跨域引用"]] + : [["", "requires"], ["related", "related"], ["link", "link"]]; + for (const [className, label] of edgeItems) { + const item = document.createElement("span"); + const sample = document.createElement("i"); + if (className) sample.className = className; + item.append(sample, label); + legend.append(item); + } + for (const [status, label] of [["dirty", "dirty"], ["impacted", "impacted"], ["needs-review", "review"]]) { + const item = document.createElement("span"); + const bullet = document.createElement("b"); + bullet.textContent = "●"; + bullet.style.color = STATUS_COLOR[status]; + item.append(bullet, ` ${label}`); + legend.append(item); + } + } + + function highlight(id) { + highlightedId = id; + if (!current) return; + const neighbors = new Set(); + if (id) { + neighbors.add(id); + for (const edge of current.graph.edges) { + if (edge.from === id) neighbors.add(edge.to); + if (edge.to === id) neighbors.add(edge.from); + } + } + for (const { node, element } of current.nodeElements) { + element.setAttribute("class", `node${id && !neighbors.has(node.id) ? " dim" : ""}`); + } + for (const { edge, element } of current.edgeElements) { + element.setAttribute("class", `edge-path${id && edge.from !== id && edge.to !== id ? " dim" : ""}`); + } + } + + function zoomBy(factor, anchorX, anchorY) { + const bounds = svg.getBoundingClientRect(); + const x = anchorX ?? bounds.width / 2; + const y = anchorY ?? bounds.height / 2; + const next = Math.min(4, Math.max(.3, view.scale * factor)); + view.x = x - (x - view.x) / view.scale * next; + view.y = y - (y - view.y) / view.scale * next; + view.scale = next; + applyView(); + } + + function resetView() { + view = { x: 0, y: 0, scale: 1 }; + applyView(); + } + + function applyView() { + current?.root.setAttribute("transform", `translate(${view.x},${view.y}) scale(${view.scale})`); + } + + function destroy() { + svg.removeEventListener("wheel", onWheel); + svg.removeEventListener("pointerdown", onPointerDown); + svg.removeEventListener("pointermove", onPointerMove); + svg.removeEventListener("pointerup", onPointerUp); + svg.removeEventListener("pointercancel", onPointerUp); + svg.removeEventListener("keydown", onKeyDown); + } + + return { render, resize, highlight, zoomBy, resetView, destroy }; +} + +function edgePath(source, target, style) { + const dx = target.x - source.x; + const dy = target.y - source.y; + const distance = Math.hypot(dx, dy) || 1; + const sourceX = source.x + dx / distance * source.radius; + const sourceY = source.y + dy / distance * source.radius; + const arrowPadding = style === "requires" ? 4 : 0; + const targetX = target.x - dx / distance * (target.radius + arrowPadding); + const targetY = target.y - dy / distance * (target.radius + arrowPadding); + const middleX = (sourceX + targetX) / 2 - dy / distance * distance * .09; + const middleY = (sourceY + targetY) / 2 + dx / distance * distance * .09; + return `M ${sourceX} ${sourceY} Q ${middleX} ${middleY} ${targetX} ${targetY}`; +} + +function svgElement(name) { + return document.createElementNS(SVG_NS, name); +} + +function textElement(content, x, y, size, weight, fill, className) { + const element = svgElement("text"); + element.setAttribute("text-anchor", "middle"); + element.setAttribute("x", String(x)); + element.setAttribute("y", String(y)); + element.setAttribute("font-size", String(size)); + element.setAttribute("font-weight", String(weight)); + if (fill) element.style.fill = fill; + if (className) element.setAttribute("class", className); + element.textContent = content; + return element; +} + +function createArrowMarker() { + const marker = svgElement("marker"); + marker.setAttribute("id", "arrow"); + marker.setAttribute("viewBox", "0 0 8 8"); + marker.setAttribute("refX", "7.5"); + marker.setAttribute("refY", "4"); + marker.setAttribute("markerWidth", "5.5"); + marker.setAttribute("markerHeight", "5.5"); + marker.setAttribute("orient", "auto"); + const path = svgElement("path"); + path.setAttribute("d", "M0 .8 L8 4 L0 7.2 z"); + path.setAttribute("fill", "#9aa4b2"); + marker.append(path); + return marker; +} + +function createShadowFilter() { + const filter = svgElement("filter"); + filter.setAttribute("id", "soft"); + filter.setAttribute("x", "-40%"); + filter.setAttribute("y", "-40%"); + filter.setAttribute("width", "180%"); + filter.setAttribute("height", "180%"); + const shadow = svgElement("feDropShadow"); + shadow.setAttribute("dx", "0"); + shadow.setAttribute("dy", "1.5"); + shadow.setAttribute("stdDeviation", "2.5"); + shadow.setAttribute("flood-color", "#101828"); + shadow.setAttribute("flood-opacity", ".18"); + filter.append(shadow); + return filter; +} diff --git a/cli/assets/viewer-model.js b/cli/assets/viewer-model.js new file mode 100644 index 0000000..746d620 --- /dev/null +++ b/cli/assets/viewer-model.js @@ -0,0 +1,127 @@ +export const PALETTE = ["#4c5fd5", "#0e8f6f", "#c26a12", "#7a4bc9", "#0d84ab", "#b13d72", "#5f8016", "#96591f"]; + +export const STATUS_COLOR = { + fresh: "#aab2bd", + impacted: "#e8890c", + dirty: "#d6453d", + "needs-review": "#c9a227" +}; + +const STATUS_RANK = { fresh: 0, "needs-review": 1, impacted: 2, dirty: 3 }; + +export function createTopicColors(nodes) { + const topics = [...new Set(nodes.map((node) => node.topic).filter(Boolean))].sort(); + return new Map(topics.map((topic, index) => [topic, PALETTE[index % PALETTE.length]])); +} + +export function colorOf(topicColors, topic) { + return topic ? topicColors.get(topic) ?? PALETTE[0] : "#3c4657"; +} + +export function worstStatus(nodes) { + return nodes.reduce( + (worst, node) => (STATUS_RANK[node.status] > STATUS_RANK[worst] ? node.status : worst), + "fresh" + ); +} + +export function documentName(path) { + return path.split("/").pop()?.replace(/\.mdx$/, "") ?? path; +} + +export function compactLabel(label, limit = 24) { + return label.length > limit ? `${label.slice(0, limit - 1)}…` : label; +} + +export function matchesSearch(node, rawQuery) { + const query = rawQuery.trim().toLocaleLowerCase(); + if (!query) return true; + return [node.path, node.title, node.description, node.kind, node.topic] + .filter(Boolean) + .some((value) => String(value).toLocaleLowerCase().includes(query)); +} + +export function buildTopicGraph(state, topicColors) { + const topics = [...new Set(state.nodes.map((node) => node.topic).filter(Boolean))].sort(); + const nodes = []; + + for (const topic of topics) { + const documents = state.nodes.filter((node) => node.topic === topic); + const tokens = documents.reduce((sum, node) => sum + node.estimatedTokens, 0); + nodes.push({ + id: `topic:${topic}`, + isTopic: true, + topic, + label: compactLabel(topic), + subLabel: `${documents.length} docs · ~${tokens} tk`, + radius: 26 + Math.min(26, Math.sqrt(tokens)), + fill: colorOf(topicColors, topic), + status: worstStatus(documents), + tooltip: `${topic}/\n${documents.map((document) => `· ${documentName(document.path)}`).join("\n")}`, + target: { type: "topic", value: topic } + }); + } + + for (const node of state.nodes.filter((item) => !item.topic)) { + nodes.push({ + id: `doc:${node.path}`, + isTopic: false, + label: compactLabel(node.path.replace(/\.mdx$/, "")), + radius: 10 + Math.min(9, Math.sqrt(node.estimatedTokens)), + fill: colorOf(topicColors, null), + status: node.status, + tooltip: `${node.path}\n${node.description}`, + target: { type: "document", value: node.path } + }); + } + + const groupByPath = new Map( + state.nodes.map((node) => [node.path, node.topic ? `topic:${node.topic}` : `doc:${node.path}`]) + ); + const aggregated = new Map(); + for (const edge of state.edges) { + const left = groupByPath.get(edge.from); + const right = groupByPath.get(edge.to); + if (!left || !right || left === right) continue; + const [from, to] = [left, right].sort(); + const key = `${from}\u0000${to}`; + const current = aggregated.get(key) ?? { from, to, count: 0, style: "aggregate" }; + current.count += 1; + aggregated.set(key, current); + } + + const edges = [...aggregated.values()] + .sort((left, right) => `${left.from}\u0000${left.to}`.localeCompare(`${right.from}\u0000${right.to}`)) + .map((edge) => ({ ...edge, width: 1.4 + Math.min(6, edge.count * 1.1) })); + return { nodes, edges }; +} + +export function buildDocumentGraph(state, topicColors) { + const nodes = state.nodes.map((node) => ({ + id: node.path, + isTopic: false, + topic: node.topic, + cluster: node.topic || "·root", + label: compactLabel(documentName(node.path)), + radius: 8 + Math.min(11, Math.sqrt(node.estimatedTokens)), + fill: colorOf(topicColors, node.topic), + status: node.status, + tooltip: `${node.path}\n${node.description}`, + target: { type: "document", value: node.path } + })); + const edges = state.edges.map((edge) => ({ ...edge, style: edge.type })); + return { nodes, edges }; +} + +export function resolveRelativeDocumentPath(documentPath, href) { + const rawTarget = href.split(/[?#]/)[0] ?? ""; + const base = documentPath.split("/").slice(0, -1); + const parts = [...base, ...rawTarget.split("/")]; + const normalized = []; + for (const part of parts) { + if (!part || part === ".") continue; + if (part === "..") normalized.pop(); + else normalized.push(part); + } + return normalized.join("/"); +} diff --git a/cli/assets/viewer.css b/cli/assets/viewer.css new file mode 100644 index 0000000..a639bba --- /dev/null +++ b/cli/assets/viewer.css @@ -0,0 +1,343 @@ +:root { + --bg: #f4f5f7; + --panel: #ffffff; + --border: #e5e8ec; + --border-strong: #d6dade; + --text: #171c26; + --muted: #667085; + --faint: #98a2b3; + --accent: #4c5fd5; + --accent-soft: #eef0fc; + --fresh: #aab2bd; + --impacted: #e8890c; + --dirty: #d6453d; + --review: #c9a227; + --shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 4px 14px rgba(16, 24, 40, .06); + --radius: 12px; +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + color: var(--text); + background: var(--bg); + font: 13px/1.6 -apple-system, "SF Pro Text", "Segoe UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; + display: flex; + flex-direction: column; + overflow: hidden; + -webkit-font-smoothing: antialiased; +} +button, input { font: inherit; } +button { color: inherit; } +button:focus-visible, input:focus-visible, #graph:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.app-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + background: var(--panel); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; + z-index: 5; +} +.app-header h1 { font-size: 14.5px; margin: 0 8px 0 0; font-weight: 700; letter-spacing: -.01em; } +.app-header h1 .repo { color: var(--muted); font-weight: 450; } +.health-strip { display: contents; } +.chip { + padding: 3px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: #fafbfc; + font-size: 11.5px; + color: var(--muted); + white-space: nowrap; +} +.chip.ok { color: #147a3d; border-color: #bfe4cb; background: #f0faf3; } +.chip.bad { color: var(--dirty); border-color: #f0c7c4; background: #fdf2f1; } +.chip.warn { color: #a86105; border-color: #f0dcb4; background: #fdf8ec; } +.chip.button { cursor: pointer; user-select: none; } +.chip.button:hover { border-color: var(--border-strong); color: var(--text); } +.toggle { + display: flex; + border: 1px solid var(--border); + border-radius: 9px; + overflow: hidden; + margin-left: auto; +} +.toggle button { + border: 0; + background: var(--panel); + color: var(--muted); + font-size: 12px; + padding: 5px 14px; + cursor: pointer; +} +.toggle button.on { background: var(--accent); color: #fff; font-weight: 600; } +.toggle button:not(.on):hover { background: var(--accent-soft); color: var(--accent); } +.load-error { + padding: 8px 18px; + color: #a42620; + background: #fdf2f1; + border-bottom: 1px solid #f0c7c4; +} + +main { + flex: 1; + display: grid; + grid-template-columns: minmax(224px, 272px) minmax(320px, 1fr) minmax(320px, 408px); + min-height: 0; +} + +#sidebar { + background: var(--panel); + border-right: 1px solid var(--border); + overflow-y: auto; + padding: 12px; +} +.search-box { position: relative; display: block; } +.search-box input { + width: 100%; + padding: 7px 34px 7px 11px; + border: 1px solid var(--border); + border-radius: 9px; + font-size: 12.5px; + margin-bottom: 8px; + outline: none; + background: #fafbfc; +} +.search-box input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 3px rgba(76, 95, 213, .12); } +.search-box kbd { + position: absolute; + right: 9px; + top: 7px; + min-width: 19px; + text-align: center; + color: var(--faint); + border: 1px solid var(--border); + border-radius: 5px; + background: var(--panel); + line-height: 19px; + font: 10px ui-monospace, "SF Mono", Menlo, monospace; +} +.group-label { + width: 100%; + border: 0; + background: transparent; + padding: 0; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: .07em; + color: var(--faint); + margin: 14px 6px 5px; + display: flex; + align-items: center; + gap: 7px; + font-weight: 600; + cursor: default; + text-align: left; +} +button.group-label { cursor: pointer; } +.group-label:hover { color: var(--muted); } +.swatch { width: 10px; height: 10px; border-radius: 3.5px; display: inline-block; flex: none; } +.doc-item { + width: 100%; + border: 0; + background: transparent; + display: flex; + align-items: center; + gap: 8px; + padding: 5.5px 9px; + border-radius: 8px; + cursor: pointer; + transition: background .1s; + text-align: left; +} +.doc-item:hover { background: #f2f4f7; } +.doc-item.active { background: var(--accent-soft); } +.doc-item.active .name { color: var(--accent); font-weight: 600; } +.dot { width: 7px; height: 7px; border-radius: 50%; flex: none; } +.doc-item .dot { box-shadow: 0 0 0 2.5px color-mix(in srgb, currentColor 18%, transparent); } +.doc-item .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12.5px; } +.doc-item .kind { margin-left: auto; font-size: 10px; color: var(--faint); flex: none; } +.empty-list { padding: 24px 8px; color: var(--faint); text-align: center; } + +#graph-wrap { + position: relative; + min-width: 0; + background: radial-gradient(circle at 1px 1px, #dfe3e8 1px, transparent 0) 0 0 / 26px 26px; + overflow: hidden; +} +#graph { width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; } +#graph.dragging { cursor: grabbing; } +#legend, #graph-hint, #graph-toolbar { + position: absolute; + background: color-mix(in srgb, var(--panel) 92%, transparent); + backdrop-filter: blur(6px); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow); +} +#legend { + left: 14px; + bottom: 14px; + padding: 8px 14px; + font-size: 11px; + color: var(--muted); + display: flex; + flex-wrap: wrap; + gap: 7px 15px; +} +#legend i { display: inline-block; width: 18px; border-top: 2px solid #9aa4b2; vertical-align: middle; margin-right: 5px; border-radius: 2px; } +#legend i.related { border-top-style: dashed; } +#legend i.link { border-top-width: 1px; border-top-color: #c8cfd8; } +#graph-hint { top: 14px; left: 14px; font-size: 11.5px; color: var(--muted); padding: 6px 12px; } +#graph-toolbar { top: 14px; right: 14px; display: flex; overflow: hidden; } +#graph-toolbar button { + min-width: 33px; + height: 31px; + padding: 0 9px; + border: 0; + border-right: 1px solid var(--border); + background: transparent; + color: var(--muted); + cursor: pointer; +} +#graph-toolbar button:last-child { border-right: 0; } +#graph-toolbar button:hover { background: var(--accent-soft); color: var(--accent); } +#graph-empty { + position: absolute; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + color: var(--faint); +} +.edge-path { fill: none; transition: opacity .15s; } +.node { transition: opacity .15s; outline: none; } +.node circle { cursor: pointer; transition: filter .12s, stroke-width .12s; } +.node:hover circle, .node:focus-visible circle { filter: brightness(1.06) saturate(1.1); stroke-width: 4px; } +.node text { fill: var(--text); pointer-events: none; } +.node .sub { fill: var(--muted); } +.node.dim, .edge-path.dim { opacity: .1; } + +#detail { position: relative; background: var(--panel); border-left: 1px solid var(--border); overflow-y: auto; } +#detail .inner { padding: 18px 20px 32px; } +#detail-close { + display: none; + position: sticky; + top: 8px; + float: right; + margin: 8px 8px -40px 0; + z-index: 2; + width: 30px; + height: 30px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + cursor: pointer; +} +#detail .placeholder { color: var(--faint); margin-top: 48px; text-align: center; font-size: 12.5px; } +#detail h2 { font-size: 15px; margin: 0 0 3px; overflow-wrap: anywhere; letter-spacing: -.01em; } +#detail .desc { color: var(--muted); font-size: 12.5px; } +#detail .meta-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 6px; } +.rel-block { font-size: 12.5px; margin: 3px 0; } +.rel-link { border: 0; padding: 0; background: transparent; color: var(--accent); cursor: pointer; text-decoration: none; } +.rel-link:hover { text-decoration: underline; } +.code-path { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11px; + background: #f6f7f9; + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px 7px; + overflow-wrap: anywhere; +} +.coderef { + font-family: ui-monospace, Menlo, monospace; + font-size: 11px; + color: #6d3fc0; + background: #f6f1fd; + border: 1px solid #e4d6f8; + border-radius: 6px; + padding: 1px 7px; +} +#doc-body { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 14px; font-size: 13.5px; overflow-wrap: anywhere; } +#doc-body h1 { font-size: 17px; } +#doc-body h2 { font-size: 14.5px; margin-top: 22px; } +#doc-body h3 { font-size: 13.5px; } +#doc-body pre { background: #f6f7f9; border: 1px solid var(--border); padding: 11px 13px; border-radius: 9px; overflow-x: auto; font-size: 12px; } +#doc-body code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; background: #f3f4f6; padding: 1px 5px; border-radius: 5px; } +#doc-body pre code { background: none; padding: 0; } +#doc-body table { width: 100%; border-collapse: collapse; font-size: 12.5px; display: block; overflow-x: auto; } +#doc-body td, #doc-body th { border: 1px solid var(--border); padding: 5px 9px; } +#doc-body a { color: var(--accent); } +#doc-body img { max-width: 100%; height: auto; border-radius: 8px; } +#doc-body blockquote { margin: 0; padding: 2px 14px; border-left: 3px solid var(--border-strong); color: var(--muted); } +.topic-doc-card { + width: 100%; + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 13px; + margin: 8px 0; + background: var(--panel); + cursor: pointer; + transition: border-color .12s, box-shadow .12s; + text-align: left; +} +.topic-doc-card:hover { border-color: var(--accent); box-shadow: var(--shadow); } +.topic-doc-card .title { font-weight: 600; font-size: 12.5px; display: flex; gap: 8px; align-items: center; } +.topic-doc-card .kind { margin-left: auto; color: var(--faint); font-size: 10px; font-weight: 500; } +.topic-doc-card .description { color: var(--muted); font-size: 12px; margin-top: 3px; } + +@media (max-width: 1100px) { + main { grid-template-columns: 236px minmax(300px, 1fr) 340px; } + .health-strip { display: none; } +} + +@media (max-width: 820px) { + main { grid-template-columns: 220px minmax(0, 1fr); } + #detail { + position: fixed; + z-index: 10; + top: 0; + right: 0; + bottom: 0; + width: min(440px, calc(100vw - 40px)); + border-left: 1px solid var(--border); + box-shadow: -12px 0 30px rgba(16, 24, 40, .13); + transform: translateX(105%); + visibility: hidden; + transition: transform .18s ease; + } + #detail.open { transform: translateX(0); visibility: visible; } + #detail-close { display: block; } +} + +@media (max-width: 600px) { + .app-header { padding: 9px 12px; } + .app-header .chip.button { margin-left: auto; } + .toggle { order: 3; width: 100%; margin-left: 0; } + .toggle button { flex: 1; } + main { grid-template-columns: 1fr; grid-template-rows: minmax(150px, 30%) 1fr; } + #sidebar { border-right: 0; border-bottom: 1px solid var(--border); } + #graph-hint { max-width: calc(100% - 112px); } + #legend { right: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; } +} diff --git a/cli/assets/viewer.html b/cli/assets/viewer.html index ce7b6da..8cc7d24 100644 --- a/cli/assets/viewer.html +++ b/cli/assets/viewer.html @@ -1,554 +1,58 @@ - - -llmdoc viewer - - + + + + llmdoc viewer + + + -
-

llmdoc

- - - - - ↻ 刷新 -
- - -
-
-
- -
- -
-
-
- -
- +
+

llmdoc

+
+ + + + +
+ +
+ + +
+
+ + + +
+ + +
+ +
+
+ + + +
+
+ +
+ + +
diff --git a/cli/src/commands/serve.ts b/cli/src/commands/serve.ts index c18403b..8826942 100644 Binary files a/cli/src/commands/serve.ts and b/cli/src/commands/serve.ts differ diff --git a/cli/src/commands/status.ts b/cli/src/commands/status.ts index a1522b4..95f259e 100644 --- a/cli/src/commands/status.ts +++ b/cli/src/commands/status.ts @@ -1,5 +1,5 @@ -import { readCommitsWithChangedPathsSince } from "../lib/git.js"; -import { computeGrowthState, analyzeDelta, isImplementationSurfacePath, loadIgnorePatterns } from "../lib/state.js"; +import { readRepositoryRevisionHealth } from "../lib/repository-health.js"; +import { computeGrowthState, analyzeDelta } from "../lib/state.js"; import { loadWorkspace } from "../lib/workspace.js"; interface StatusOptions { @@ -11,7 +11,11 @@ export function runStatus(options: StatusOptions): unknown { const workspace = loadWorkspace(options.cwd); const delta = analyzeDelta(workspace); const growth = computeGrowthState(workspace); - const relevantCommitsBehindHead = countRelevantCommitsBehindHead(workspace.rootDir, workspace.meta?.baseline.revision ?? null, delta); + const relevantCommitsBehindHead = readRepositoryRevisionHealth( + workspace.rootDir, + workspace.meta?.baseline.revision ?? null, + delta.git + ).relevantCommitsBehindHead; if (options.json) { return { @@ -58,27 +62,6 @@ export function runStatus(options: StatusOptions): unknown { return lines.join("\n"); } -// "有效源码落后"计数:baseline..HEAD 中至少触碰一个 implementation surface 路径的提交数。 -// 只改 llmdoc/** 的提交(尤其 commit 收尾的 meta follow-up)不代表知识过期,不应计入。 -function countRelevantCommitsBehindHead( - rootDir: string, - baselineRevision: string | null, - delta: ReturnType -): number | null { - if (!baselineRevision || !delta.git.headRevision || delta.git.baselineBehindHead === null) { - return null; - } - if (delta.git.baselineBehindHead === 0) { - return 0; - } - const commits = readCommitsWithChangedPathsSince(rootDir, baselineRevision, delta.git.headRevision); - if (commits === null) { - return null; - } - const ignorePatterns = loadIgnorePatterns(rootDir); - return commits.filter((commit) => commit.paths.some((filePath) => isImplementationSurfacePath(filePath, ignorePatterns))).length; -} - function formatBehindLabel(commitsBehindHead: number | null, relevantCommitsBehindHead: number | null): string { if (commitsBehindHead === null) { return "unknown"; diff --git a/cli/src/lib/repository-health.ts b/cli/src/lib/repository-health.ts new file mode 100644 index 0000000..d22b537 --- /dev/null +++ b/cli/src/lib/repository-health.ts @@ -0,0 +1,41 @@ +import { readCommitsWithChangedPathsSince } from "./git.js"; +import { isImplementationSurfacePath, loadIgnorePatterns } from "./state.js"; +import type { GitState } from "../types.js"; + +export interface RepositoryRevisionHealth { + commitsBehindHead: number | null; + relevantCommitsBehindHead: number | null; + metadataOnlyBehind: boolean; +} + +/** + * 将原始 Git 落后与真正触碰 implementation surface 的落后分开。 + * 只改 llmdoc/** 的收尾 commit 仍属于 Git 历史,但不代表知识过期。 + */ +export function readRepositoryRevisionHealth( + rootDir: string, + baselineRevision: string | null, + git: GitState +): RepositoryRevisionHealth { + const commitsBehindHead = git.baselineBehindHead; + if (!baselineRevision || !git.headRevision || commitsBehindHead === null) { + return { commitsBehindHead, relevantCommitsBehindHead: null, metadataOnlyBehind: false }; + } + if (commitsBehindHead === 0) { + return { commitsBehindHead, relevantCommitsBehindHead: 0, metadataOnlyBehind: false }; + } + + const commits = readCommitsWithChangedPathsSince(rootDir, baselineRevision, git.headRevision); + if (commits === null) { + return { commitsBehindHead, relevantCommitsBehindHead: null, metadataOnlyBehind: false }; + } + const ignorePatterns = loadIgnorePatterns(rootDir); + const relevantCommitsBehindHead = commits.filter((commit) => + commit.paths.some((filePath) => isImplementationSurfacePath(filePath, ignorePatterns)) + ).length; + return { + commitsBehindHead, + relevantCommitsBehindHead, + metadataOnlyBehind: commitsBehindHead > 0 && relevantCommitsBehindHead === 0 + }; +} diff --git a/cli/src/lib/viewer-http.ts b/cli/src/lib/viewer-http.ts new file mode 100644 index 0000000..902e905 --- /dev/null +++ b/cli/src/lib/viewer-http.ts @@ -0,0 +1,155 @@ +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { createRequire } from "node:module"; + +import { CliError } from "./errors.js"; +import { packageRootFromImport } from "./package-root.js"; +import { loadViewerState } from "./viewer-state.js"; +import { loadWorkspace } from "./workspace.js"; + +interface StaticAsset { + fileName: string; + contentType: string; +} + +const STATIC_ASSETS: ReadonlyMap = new Map([ + ["/", { fileName: "viewer.html", contentType: "text/html; charset=utf-8" }], + ["/assets/viewer.css", { fileName: "viewer.css", contentType: "text/css; charset=utf-8" }], + ["/assets/viewer-model.js", { fileName: "viewer-model.js", contentType: "text/javascript; charset=utf-8" }], + ["/assets/viewer-graph.js", { fileName: "viewer-graph.js", contentType: "text/javascript; charset=utf-8" }], + ["/assets/viewer-detail.js", { fileName: "viewer-detail.js", contentType: "text/javascript; charset=utf-8" }], + ["/assets/viewer-app.js", { fileName: "viewer-app.js", contentType: "text/javascript; charset=utf-8" }] +]); + +export type ViewerRequestHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void; + +/** 创建只读、显式路由的本地 Viewer handler。不会把 URL 路径拼接进文件系统。 */ +export function createViewerRequestHandler(rootDir: string): ViewerRequestHandler { + const packageRoot = packageRootFromImport(import.meta.url); + const assetsRoot = path.join(packageRoot, "assets"); + + return (request, response): void => { + const headOnly = request.method === "HEAD"; + try { + if (request.method !== "GET" && !headOnly) { + sendJson(response, 405, { error: "method not allowed" }, headOnly, { allow: "GET, HEAD" }); + return; + } + + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const staticAsset = STATIC_ASSETS.get(url.pathname); + if (staticAsset) { + sendStaticAsset(response, assetsRoot, staticAsset, headOnly); + return; + } + if (url.pathname === "/assets/marked.js") { + sendMarked(response, headOnly); + return; + } + if (url.pathname === "/favicon.ico") { + sendBody(response, 204, Buffer.alloc(0), "image/x-icon", headOnly); + return; + } + if (url.pathname === "/api/state") { + sendJson(response, 200, loadViewerState(rootDir), headOnly); + return; + } + if (url.pathname === "/api/doc") { + const docPath = url.searchParams.get("path") ?? ""; + const workspace = loadWorkspace(rootDir); + // 仅按扫描产生的 canonical llmdocPath 查表,不接受任意文件系统路径。 + const document = workspace.documentsByLlmdocPath.get(docPath); + if (!document) { + sendJson(response, 404, { error: `未找到文档: ${docPath}` }, headOnly); + return; + } + sendJson( + response, + 200, + { + path: document.llmdocPath, + frontmatter: document.frontmatter, + body: document.body, + title: document.title, + estimatedTokens: document.estimatedTokens, + lineCount: document.lineCount + }, + headOnly + ); + return; + } + + sendJson(response, 404, { error: "not found" }, headOnly); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendJson(response, 500, { error: message }, headOnly); + } + }; +} + +function sendStaticAsset( + response: http.ServerResponse, + assetsRoot: string, + asset: StaticAsset, + headOnly: boolean +): void { + // fileName 只可能来自上方常量白名单;请求值不会参与路径解析。 + const assetPath = path.join(assetsRoot, asset.fileName); + if (!fs.existsSync(assetPath)) { + throw new CliError(`viewer 资产缺失: ${asset.fileName}`); + } + sendBody(response, 200, fs.readFileSync(assetPath), asset.contentType, headOnly); +} + +function sendMarked(response: http.ServerResponse, headOnly: boolean): void { + try { + const require = createRequire(import.meta.url); + const markedPath = require.resolve("marked/marked.min.js"); + sendBody(response, 200, fs.readFileSync(markedPath), "text/javascript; charset=utf-8", headOnly); + } catch { + // marked 缺失时降级为安全的纯文本展示,Viewer 的导航与状态功能仍可用。 + const fallback = + "window.marked={parse:(text)=>'
'+text.replace(/&/g,'&').replace(/'};";
+    sendBody(response, 200, Buffer.from(fallback), "text/javascript; charset=utf-8", headOnly);
+  }
+}
+
+function sendJson(
+  response: http.ServerResponse,
+  statusCode: number,
+  payload: unknown,
+  headOnly: boolean,
+  extraHeaders: Record = {}
+): void {
+  sendBody(
+    response,
+    statusCode,
+    Buffer.from(JSON.stringify(payload)),
+    "application/json; charset=utf-8",
+    headOnly,
+    extraHeaders
+  );
+}
+
+function sendBody(
+  response: http.ServerResponse,
+  statusCode: number,
+  body: Buffer,
+  contentType: string,
+  headOnly: boolean,
+  extraHeaders: Record = {}
+): void {
+  response.writeHead(statusCode, {
+    "content-type": contentType,
+    "content-length": String(body.byteLength),
+    "cache-control": "no-store",
+    "content-security-policy":
+      "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http: https:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
+    "referrer-policy": "no-referrer",
+    "x-content-type-options": "nosniff",
+    "x-frame-options": "DENY",
+    ...extraHeaders
+  });
+  response.end(headOnly ? undefined : body);
+}
diff --git a/cli/src/lib/viewer-state.ts b/cli/src/lib/viewer-state.ts
new file mode 100644
index 0000000..ad0765c
--- /dev/null
+++ b/cli/src/lib/viewer-state.ts
@@ -0,0 +1,227 @@
+import path from "node:path";
+
+import { resolveDocLink } from "./markdown.js";
+import { readRepositoryRevisionHealth } from "./repository-health.js";
+import {
+  analyzeDelta,
+  computeGrowthState,
+  type DeltaState,
+  type GrowthState
+} from "./state.js";
+import { loadWorkspace, validateWorkspace } from "./workspace.js";
+import type { DocumentKind, ParsedDocument, ValidationIssue, WorkspaceData } from "../types.js";
+
+export type ViewerNodeStatus = "fresh" | "needs-review" | "impacted" | "dirty";
+export type ViewerEdgeType = "requires" | "related" | "link";
+
+export interface ViewerNodeDto {
+  path: string;
+  topic: string | null;
+  title: string | null;
+  kind: DocumentKind | "unknown";
+  description: string;
+  estimatedTokens: number;
+  lineCount: number;
+  codePaths: string[];
+  status: ViewerNodeStatus;
+}
+
+export interface ViewerEdgeDto {
+  from: string;
+  to: string;
+  type: ViewerEdgeType;
+}
+
+export interface ViewerRevisionHealth {
+  relevantCommitsBehindHead: number | null;
+  metadataOnlyBehind: boolean;
+}
+
+export interface ViewerStateDto {
+  repository: string;
+  generatedAt: string;
+  baseline: {
+    revision: string | null;
+    headRevision: string | null;
+    /** 原始 git commit 落后数,保留既有 API 语义。 */
+    behindHead: number | null;
+    /** 只统计触碰 implementation surface 的 commit,供健康度展示。 */
+    relevantBehindHead: number | null;
+    metadataOnlyBehind: boolean;
+    degradedReason: string | null;
+  };
+  growth: GrowthState;
+  validate: {
+    ok: boolean;
+    errors: number;
+    warnings: number;
+    issues: ValidationIssue[];
+  };
+  delta: {
+    suggestedMode: "light" | "deep";
+    reasons: string[];
+    unmappedCommittedPaths: string[];
+    unmappedDirtyPaths: string[];
+  };
+  nodes: ViewerNodeDto[];
+  edges: ViewerEdgeDto[];
+}
+
+export interface ViewerStateProjectionInput {
+  workspace: WorkspaceData;
+  delta: DeltaState;
+  growth: GrowthState;
+  issues: ValidationIssue[];
+  revisionHealth: ViewerRevisionHealth;
+  generatedAt: string;
+}
+
+const EDGE_PRIORITY: Readonly> = {
+  requires: 3,
+  related: 2,
+  link: 1
+};
+
+export function loadViewerState(rootDir: string, generatedAt = new Date().toISOString()): ViewerStateDto {
+  const workspace = loadWorkspace(rootDir);
+  const delta = analyzeDelta(workspace);
+  return projectViewerState({
+    workspace,
+    delta,
+    growth: computeGrowthState(workspace),
+    issues: validateWorkspace(workspace),
+    revisionHealth: readViewerRevisionHealth(workspace, delta),
+    generatedAt
+  });
+}
+
+/**
+ * 把已读取的工作区事实投影成稳定、可序列化的 Viewer DTO。
+ * 此函数不读文件、不调用 git,便于独立验证状态优先级和 API 契约。
+ */
+export function projectViewerState(input: ViewerStateProjectionInput): ViewerStateDto {
+  const { workspace, delta, growth, issues, revisionHealth, generatedAt } = input;
+  const impactedPaths = new Set(delta.impacts.map((impact) => impact.document.llmdocPath));
+  const dirtyPaths = new Set(delta.dirtyDocuments.map((document) => document.llmdocPath));
+  const needsReviewPaths = new Set(delta.needsReview.map((document) => document.llmdocPath));
+
+  const nodes = workspace.documents
+    .map((document): ViewerNodeDto => {
+      const documentPath = document.llmdocPath;
+      return {
+        path: documentPath,
+        topic: document.topic,
+        title: document.title,
+        kind: normalizeDocumentKind(document.frontmatter.kind),
+        description:
+          typeof document.frontmatter.description === "string" ? document.frontmatter.description : "",
+        estimatedTokens: document.estimatedTokens,
+        lineCount: document.lineCount,
+        codePaths: stringList(document.frontmatter.code?.paths),
+        status: dirtyPaths.has(documentPath)
+          ? "dirty"
+          : impactedPaths.has(documentPath)
+            ? "impacted"
+            : needsReviewPaths.has(documentPath)
+              ? "needs-review"
+              : "fresh"
+      };
+    })
+    .sort((left, right) => compareText(left.path, right.path));
+
+  return {
+    repository: path.basename(workspace.rootDir),
+    generatedAt,
+    baseline: {
+      revision: workspace.meta?.baseline.revision ?? null,
+      headRevision: delta.git.headRevision,
+      behindHead: delta.git.baselineBehindHead,
+      relevantBehindHead: revisionHealth.relevantCommitsBehindHead,
+      metadataOnlyBehind: revisionHealth.metadataOnlyBehind,
+      degradedReason: delta.git.degradedReason
+    },
+    growth,
+    validate: {
+      ok: issues.every((issue) => issue.severity !== "error"),
+      errors: issues.filter((issue) => issue.severity === "error").length,
+      warnings: issues.filter((issue) => issue.severity === "warning").length,
+      issues: issues.map((issue) => ({ ...issue }))
+    },
+    delta: {
+      suggestedMode: delta.suggestedMode,
+      reasons: [...delta.reasons],
+      unmappedCommittedPaths: [...delta.unmappedCommittedPaths],
+      unmappedDirtyPaths: [...delta.unmappedDirtyPaths]
+    },
+    nodes,
+    edges: buildViewerEdges(workspace.documents, workspace.documentsByLlmdocPath)
+  };
+}
+
+/**
+ * 构建有向关系图。同一有向文档对只保留最强关系,反向关系保留为独立边。
+ * Map 替换与最终排序让结果不依赖扫描和 frontmatter 中的声明顺序。
+ */
+export function buildViewerEdges(
+  documents: readonly ParsedDocument[],
+  knownDocuments: ReadonlyMap
+): ViewerEdgeDto[] {
+  const edgesByDirection = new Map();
+
+  const addEdge = (from: string, to: string, type: ViewerEdgeType): void => {
+    if (from === to || !knownDocuments.has(to)) {
+      return;
+    }
+    const key = `${from}\u0000${to}`;
+    const current = edgesByDirection.get(key);
+    if (!current || EDGE_PRIORITY[type] > EDGE_PRIORITY[current.type]) {
+      edgesByDirection.set(key, { from, to, type });
+    }
+  };
+
+  for (const document of documents) {
+    const from = document.llmdocPath;
+    for (const target of stringList(document.frontmatter.relations?.requires)) {
+      addEdge(from, target, "requires");
+    }
+    for (const target of stringList(document.frontmatter.relations?.related)) {
+      addEdge(from, target, "related");
+    }
+    for (const target of document.links) {
+      addEdge(from, resolveDocLink(from, target), "link");
+    }
+  }
+
+  return [...edgesByDirection.values()].sort(
+    (left, right) =>
+      compareText(left.from, right.from) || compareText(left.to, right.to) || EDGE_PRIORITY[right.type] - EDGE_PRIORITY[left.type]
+  );
+}
+
+/**
+ * 与 status 命令一致,只把实际触碰 implementation surface 的 commit 计入知识落后。
+ * 纯 llmdoc/meta.json follow-up 会保留原始 behindHead,但 relevant 为 0。
+ */
+export function readViewerRevisionHealth(workspace: WorkspaceData, delta: DeltaState): ViewerRevisionHealth {
+  const health = readRepositoryRevisionHealth(
+    workspace.rootDir,
+    workspace.meta?.baseline.revision ?? null,
+    delta.git
+  );
+  return {
+    relevantCommitsBehindHead: health.relevantCommitsBehindHead,
+    metadataOnlyBehind: health.metadataOnlyBehind
+  };
+}
+
+function compareText(left: string, right: string): number {
+  return left < right ? -1 : left > right ? 1 : 0;
+}
+
+function normalizeDocumentKind(value: unknown): DocumentKind | "unknown" {
+  return value === "architecture" || value === "guide" || value === "reference" ? value : "unknown";
+}
+
+function stringList(value: unknown): string[] {
+  return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
+}
diff --git a/cli/tests/viewer-assets.test.ts b/cli/tests/viewer-assets.test.ts
new file mode 100644
index 0000000..b67c83e
--- /dev/null
+++ b/cli/tests/viewer-assets.test.ts
@@ -0,0 +1,84 @@
+import { performance } from "node:perf_hooks";
+
+import { describe, expect, test } from "vitest";
+
+// Browser assets stay framework-free and are published as native ES modules.
+// @ts-expect-error assets intentionally sit outside the TypeScript build root
+import { buildDocumentGraph, buildTopicGraph, createTopicColors, matchesSearch, resolveRelativeDocumentPath } from "../assets/viewer-model.js";
+// @ts-expect-error assets intentionally sit outside the TypeScript build root
+import { layoutGraph } from "../assets/viewer-graph.js";
+
+function documentNode(path: string, topic: string | null, status = "fresh") {
+  return {
+    path,
+    title: path,
+    topic,
+    kind: "guide",
+    description: `description for ${path}`,
+    estimatedTokens: 100,
+    lineCount: 20,
+    codePaths: [],
+    status
+  };
+}
+
+describe("viewer browser models", () => {
+  test("topic graph deterministically aggregates cross-topic relations", () => {
+    const state = {
+      nodes: [
+        documentNode("alpha/a.mdx", "alpha"),
+        documentNode("alpha/b.mdx", "alpha", "impacted"),
+        documentNode("beta/c.mdx", "beta")
+      ],
+      edges: [
+        { from: "beta/c.mdx", to: "alpha/a.mdx", type: "related" },
+        { from: "alpha/b.mdx", to: "beta/c.mdx", type: "requires" },
+        { from: "alpha/a.mdx", to: "alpha/b.mdx", type: "link" }
+      ]
+    };
+    const graph = buildTopicGraph(state, createTopicColors(state.nodes));
+
+    expect(graph.nodes.map((node: any) => node.id)).toEqual(["topic:alpha", "topic:beta"]);
+    expect(graph.nodes[0].status).toBe("impacted");
+    expect(graph.edges).toEqual([
+      { from: "topic:alpha", to: "topic:beta", count: 2, style: "aggregate", width: 3.6 }
+    ]);
+  });
+
+  test("document graph and search keep the public state projection intact", () => {
+    const nodes = [documentNode("api/retry.mdx", "api")];
+    const state = {
+      nodes,
+      edges: [{ from: "api/retry.mdx", to: "root.mdx", type: "requires" }]
+    };
+    const graph = buildDocumentGraph(state, createTopicColors(nodes));
+
+    expect(graph.nodes[0]).toMatchObject({ id: "api/retry.mdx", cluster: "api", label: "retry" });
+    expect(graph.edges[0]).toMatchObject({ style: "requires" });
+    expect(matchesSearch(nodes[0], "RETRY")).toBe(true);
+    expect(matchesSearch(nodes[0], "missing")).toBe(false);
+    expect(resolveRelativeDocumentPath("api/retry.mdx", "../architecture.mdx#contract")).toBe("architecture.mdx");
+  });
+
+  test("layout is deterministic, bounded, and remains responsive for a large graph", () => {
+    const state = {
+      nodes: Array.from({ length: 1000 }, (_, index) => documentNode(`topic-${index % 20}/doc-${index}.mdx`, `topic-${index % 20}`)),
+      edges: Array.from({ length: 999 }, (_, index) => ({
+        from: `topic-${index % 20}/doc-${index}.mdx`,
+        to: `topic-${(index + 1) % 20}/doc-${index + 1}.mdx`,
+        type: index % 2 ? "related" : "requires"
+      }))
+    };
+    const graph = buildDocumentGraph(state, createTopicColors(state.nodes));
+    const startedAt = performance.now();
+    const first = layoutGraph(graph, 1200, 800, "docs");
+    const elapsed = performance.now() - startedAt;
+    const second = layoutGraph(graph, 1200, 800, "docs");
+
+    expect(elapsed).toBeLessThan(1000);
+    expect(first.nodes).toHaveLength(1000);
+    expect(first.nodes.every((node: any) => Number.isFinite(node.x) && Number.isFinite(node.y))).toBe(true);
+    expect(first.nodes.every((node: any) => node.x >= 54 && node.x <= 1146 && node.y >= 54 && node.y <= 746)).toBe(true);
+    expect(first.nodes.map((node: any) => [node.x, node.y])).toEqual(second.nodes.map((node: any) => [node.x, node.y]));
+  });
+});
diff --git a/cli/tests/viewer-http.test.ts b/cli/tests/viewer-http.test.ts
new file mode 100644
index 0000000..a848c57
--- /dev/null
+++ b/cli/tests/viewer-http.test.ts
@@ -0,0 +1,94 @@
+import fs from "node:fs";
+import path from "node:path";
+
+import { describe, expect, test } from "vitest";
+
+import { startViewerServer } from "../src/commands/serve.js";
+import type { ViewerStateDto } from "../src/lib/viewer-state.js";
+import { commitAll, createFixture, writeRepoFile } from "./helpers.js";
+
+describe("viewer HTTP API", () => {
+  test("serves only the explicit local app/API surface", { timeout: 20000 }, async () => {
+    const rootDir = createFixture();
+    const server = await startViewerServer(rootDir, 0);
+    try {
+      expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
+
+      const home = await fetch(`${server.url}/`);
+      expect(home.status).toBe(200);
+      expect(home.headers.get("content-type")).toContain("text/html");
+      expect(home.headers.get("content-security-policy")).toContain("script-src 'self'");
+      expect(home.headers.get("referrer-policy")).toBe("no-referrer");
+      expect(home.headers.get("x-content-type-options")).toBe("nosniff");
+      expect(home.headers.get("x-frame-options")).toBe("DENY");
+      expect(await home.text()).toContain("llmdoc viewer");
+
+      const head = await fetch(`${server.url}/`, { method: "HEAD" });
+      expect(head.status).toBe(200);
+      expect(await head.text()).toBe("");
+
+      for (const assetName of [
+        "viewer.css",
+        "viewer-model.js",
+        "viewer-graph.js",
+        "viewer-detail.js",
+        "viewer-app.js",
+        "marked.js"
+      ]) {
+        const asset = await fetch(`${server.url}/assets/${assetName}`);
+        expect(asset.status, assetName).toBe(200);
+        expect((await asset.text()).length, assetName).toBeGreaterThan(0);
+      }
+
+      const doc = await fetch(`${server.url}/api/doc?path=${encodeURIComponent("api-client/retry-policy.mdx")}`);
+      expect(doc.status).toBe(200);
+      await expect(doc.json()).resolves.toMatchObject({
+        path: "api-client/retry-policy.mdx",
+        frontmatter: { kind: "guide" }
+      });
+
+      for (const unsafePath of ["/assets/package.json", "/assets/%2e%2e%2fpackage.json", "/api/doc?path=../../etc/passwd"]) {
+        const response = await fetch(`${server.url}${unsafePath}`);
+        expect(response.status, unsafePath).toBe(404);
+      }
+
+      const post = await fetch(`${server.url}/api/state`, { method: "POST" });
+      expect(post.status).toBe(405);
+      expect(post.headers.get("allow")).toBe("GET, HEAD");
+    } finally {
+      await server.close();
+    }
+  });
+
+  test("reports metadata-only follow-ups as knowledge-clean", { timeout: 20000 }, async () => {
+    const rootDir = createFixture();
+    fs.appendFileSync(path.join(rootDir, "llmdoc", "meta.json"), "\n");
+    commitAll(rootDir, "metadata follow-up");
+
+    const server = await startViewerServer(rootDir, 0);
+    try {
+      const metadataOnlyState = await readState(server.url);
+      expect(metadataOnlyState.baseline.behindHead).toBe(1);
+      expect(metadataOnlyState.baseline.relevantBehindHead).toBe(0);
+      expect(metadataOnlyState.baseline.metadataOnlyBehind).toBe(true);
+      expect(metadataOnlyState.nodes.every((node) => node.status === "fresh")).toBe(true);
+
+      writeRepoFile(rootDir, "src/api/retry.ts", "export function isRetryable() { return false; }\n");
+      commitAll(rootDir, "source change");
+
+      const sourceChangedState = await readState(server.url);
+      expect(sourceChangedState.baseline.behindHead).toBe(2);
+      expect(sourceChangedState.baseline.relevantBehindHead).toBe(1);
+      expect(sourceChangedState.baseline.metadataOnlyBehind).toBe(false);
+      expect(sourceChangedState.nodes.some((node) => node.status === "impacted")).toBe(true);
+    } finally {
+      await server.close();
+    }
+  });
+});
+
+async function readState(serverUrl: string): Promise {
+  const response = await fetch(`${serverUrl}/api/state`);
+  expect(response.status).toBe(200);
+  return (await response.json()) as ViewerStateDto;
+}
diff --git a/cli/tests/viewer-state.test.ts b/cli/tests/viewer-state.test.ts
new file mode 100644
index 0000000..e0b0736
--- /dev/null
+++ b/cli/tests/viewer-state.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, test } from "vitest";
+
+import type { DeltaState, GrowthState } from "../src/lib/state.js";
+import { buildViewerEdges, projectViewerState } from "../src/lib/viewer-state.js";
+import type { ParsedDocument, WorkspaceData } from "../src/types.js";
+
+describe("viewer state projection", () => {
+  test("deduplicates directed edges by requires > related > link and keeps reverse edges", () => {
+    const alpha = makeDocument("topic/alpha.mdx", {
+      requires: ["topic/beta.mdx"],
+      related: ["topic/gamma.mdx", "topic/beta.mdx"],
+      links: ["./beta.mdx", "./gamma.mdx", "./alpha.mdx", "./missing.mdx"]
+    });
+    const beta = makeDocument("topic/beta.mdx", { links: ["./alpha.mdx"] });
+    const gamma = makeDocument("topic/gamma.mdx");
+    const documents = [gamma, beta, alpha];
+    const knownDocuments = new Map(documents.map((document) => [document.llmdocPath, document]));
+
+    expect(buildViewerEdges(documents, knownDocuments)).toEqual([
+      { from: "topic/alpha.mdx", to: "topic/beta.mdx", type: "requires" },
+      { from: "topic/alpha.mdx", to: "topic/gamma.mdx", type: "related" },
+      { from: "topic/beta.mdx", to: "topic/alpha.mdx", type: "link" }
+    ]);
+    expect(buildViewerEdges([...documents].reverse(), knownDocuments)).toEqual(buildViewerEdges(documents, knownDocuments));
+  });
+
+  test("projects stable DTOs with dirty > impacted > needs-review > fresh precedence", () => {
+    const dirty = makeDocument("topic/dirty.mdx");
+    const impacted = makeDocument("topic/impacted.mdx");
+    const needsReview = makeDocument("topic/needs-review.mdx");
+    const fresh = makeDocument("architecture.mdx");
+    const documents = [needsReview, fresh, dirty, impacted];
+    const workspace = makeWorkspace(documents);
+    const delta: DeltaState = {
+      git: {
+        available: true,
+        headRevision: "head",
+        detached: false,
+        inProgressOperation: null,
+        baselineBehindHead: 1,
+        committedChangedPaths: [],
+        stagedPaths: [],
+        unstagedPaths: [],
+        untrackedPaths: [],
+        degradedReason: null
+      },
+      impacts: [
+        { document: dirty, changedCommittedPaths: [], dirtyPaths: ["src/dirty.ts"], needsReviewBecauseOf: [] },
+        { document: impacted, changedCommittedPaths: ["src/impacted.ts"], dirtyPaths: [], needsReviewBecauseOf: [] }
+      ],
+      needsReview: [dirty, needsReview],
+      dirtyDocuments: [dirty],
+      unmappedCommittedPaths: [],
+      unmappedDirtyPaths: [],
+      suggestedMode: "deep",
+      reasons: ["存在 dirty"],
+      scopedDocuments: documents
+    };
+    const growth: GrowthState = {
+      currentDocumentCount: 4,
+      currentTotalEstimatedTokens: 40,
+      baselineDocumentCount: 4,
+      baselineTotalEstimatedTokens: 40,
+      documentDelta: 0,
+      tokenDelta: 0,
+      exceedsGate: false
+    };
+
+    const state = projectViewerState({
+      workspace,
+      delta,
+      growth,
+      issues: [{ severity: "warning", code: "test.warning", message: "warning" }],
+      revisionHealth: { relevantCommitsBehindHead: 0, metadataOnlyBehind: true },
+      generatedAt: "2026-08-27T00:00:00.000Z"
+    });
+
+    expect(state.nodes.map((node) => [node.path, node.status])).toEqual([
+      ["architecture.mdx", "fresh"],
+      ["topic/dirty.mdx", "dirty"],
+      ["topic/impacted.mdx", "impacted"],
+      ["topic/needs-review.mdx", "needs-review"]
+    ]);
+    expect(state.baseline).toEqual({
+      revision: "base",
+      headRevision: "head",
+      behindHead: 1,
+      relevantBehindHead: 0,
+      metadataOnlyBehind: true,
+      degradedReason: null
+    });
+    expect(state.validate).toMatchObject({ ok: true, errors: 0, warnings: 1 });
+    expect(state.generatedAt).toBe("2026-08-27T00:00:00.000Z");
+  });
+});
+
+function makeDocument(
+  llmdocPath: string,
+  options: { requires?: string[]; related?: string[]; links?: string[] } = {}
+): ParsedDocument {
+  const topic = llmdocPath.includes("/") ? llmdocPath.split("/")[0]! : null;
+  return {
+    absolutePath: `/repo/llmdoc/${llmdocPath}`,
+    repoPath: `llmdoc/${llmdocPath}`,
+    llmdocPath,
+    topic,
+    basename: llmdocPath.split("/").at(-1)!,
+    frontmatter: {
+      description: llmdocPath,
+      kind: "reference",
+      relations: {
+        requires: options.requires ?? [],
+        related: options.related ?? []
+      }
+    },
+    body: `# ${llmdocPath}\n`,
+    raw: `# ${llmdocPath}\n`,
+    title: llmdocPath,
+    links: options.links ?? [],
+    codeRefs: [],
+    estimatedTokens: 10,
+    lineCount: 1
+  };
+}
+
+function makeWorkspace(documents: ParsedDocument[]): WorkspaceData {
+  const documentsByLlmdocPath = new Map(documents.map((document) => [document.llmdocPath, document]));
+  const topicDocuments = documents.filter((document) => document.topic === "topic");
+  return {
+    rootDir: "/repo",
+    llmdocDir: "/repo/llmdoc",
+    metaPath: "/repo/llmdoc/meta.json",
+    documents,
+    documentsByLlmdocPath,
+    topics: new Map([["topic", topicDocuments]]),
+    rootSingletons: documents.filter((document) => document.topic === null),
+    meta: {
+      schema: "llmdoc.meta/v3",
+      baseline: { revision: "base", verifiedAt: "2026-08-27T00:00:00Z" },
+      documents: Object.fromEntries(documents.map((document) => [document.llmdocPath, { validatedRevision: "base" }])),
+      convergence: {
+        capturedAt: "2026-08-27T00:00:00Z",
+        source: "init",
+        documentCount: documents.length,
+        totalEstimatedTokens: 40
+      }
+    },
+    preloadIssues: []
+  };
+}
diff --git a/llmdoc/cli-runtime/retrieval-and-mutation.mdx b/llmdoc/cli-runtime/retrieval-and-mutation.mdx
index 1bb831e..735224c 100644
--- a/llmdoc/cli-runtime/retrieval-and-mutation.mdx
+++ b/llmdoc/cli-runtime/retrieval-and-mutation.mdx
@@ -20,7 +20,17 @@ code:
     - cli/src/commands/prune.ts
     - cli/src/commands/upgrade.ts
     - cli/src/commands/serve.ts
+    - cli/src/lib/viewer-http.ts
+    - cli/src/lib/viewer-state.ts
     - cli/assets/viewer.html
+    - cli/assets/viewer.css
+    - cli/assets/viewer-model.js
+    - cli/assets/viewer-graph.js
+    - cli/assets/viewer-detail.js
+    - cli/assets/viewer-app.js
+    - cli/tests/viewer-state.test.ts
+    - cli/tests/viewer-http.test.ts
+    - cli/tests/viewer-assets.test.ts
     - cli/schemas/output.schema.json
 ---
 
@@ -30,6 +40,10 @@ code:
 
 读取面按“地图/索引或搜索/正文”逐层暴露,调用者在任一层都可以停止;这些命令返回受预算约束的公开投影,不泄漏内部解析对象。`context --files` 用 `code.paths` 反查 owner,并补齐 `relations.requires` 前置闭包。文本与 JSON 输出共用 schema 契约,避免不同宿主形成第二套语义。
 
+Viewer 是同一知识面的只读本地投影,不是新的改写入口。服务命令只负责 loopback 生命周期与装配;HTTP 层只接受 GET/HEAD 和显式资产/API 路由,文档读取必须命中 workspace 扫描得到的 canonical ID,不能把请求路径解释成文件系统路径;状态层把 workspace、delta、validate 与 growth 投影为可序列化 DTO,并以 `requires > related > link` 的优先级确定同向关系,反向关系仍独立保留。
+
+浏览器端保持无框架的原生模块边界:model 负责视图模型,graph 负责确定性布局与交互,detail 负责详情导航和安全渲染,app 只协调状态与 UI。服务端防御性响应头与客户端 Markdown 清洗共同构成渲染边界;任何扩展都不能绕过路由白名单、canonical 文档查找或在未清洗时注入 HTML。
+
 ## 结构改写不变量
 
 所有路径先经过仓库与 `llmdoc/` realpath 边界校验,符号链接逃逸和覆盖现有目标都会被拒绝。`adopt` 只登记已有合法正文;`mv` 负责移动及内部引用/ledger key 重写,失败时回滚,不触碰源码。
diff --git a/llmdoc/cli-runtime/state-and-validation.mdx b/llmdoc/cli-runtime/state-and-validation.mdx
index 585eaa1..fc51b2e 100644
--- a/llmdoc/cli-runtime/state-and-validation.mdx
+++ b/llmdoc/cli-runtime/state-and-validation.mdx
@@ -8,13 +8,17 @@ code:
     - cli/src/lib/state.ts
     - cli/src/lib/git.ts
     - cli/src/lib/output-schema.ts
+    - cli/src/lib/repository-health.ts
     - cli/src/lib/schema.ts
+    - cli/src/lib/viewer-state.ts
     - cli/src/commands/validate.ts
     - cli/src/commands/status.ts
     - cli/src/commands/delta.ts
     - cli/src/commands/fingerprint.ts
     - cli/src/commands/hook.ts
     - cli/tests/cli.test.ts
+    - cli/tests/viewer-state.test.ts
+    - cli/tests/viewer-http.test.ts
     - cli/schemas/doc-frontmatter.schema.json
     - cli/schemas/meta.schema.json
     - cli/schemas/output.schema.json
@@ -34,7 +38,9 @@ CLI 只接受最近 Git 根直属的 `llmdoc/`,避免跨仓库误认;没有
 
 `delta` 先用 `code.paths` 找直接受影响文档,再沿 `relations.requires` 反向扩展一跳为 needs-review。未映射路径、反向复核、影响过广、dirty 关联代码或失效 revision 会把建议模式抬到 deep。命中表示必须复核,不表示正文必须变化。
 
-revision 推进前必须确认 git 可推进且目标文档关联实现无 dirty;全量推进还要求所有实现表面 clean。`commit --verified` 让语义仍成立的文档只刷新有效性锚点。fingerprint 之后由 CLI 自己创建的纯 `llmdoc/meta.json` follow-up 不代表知识再次过期,重复收尾应成为 no-op。
+revision 推进前必须确认 git 可推进且目标文档关联实现无 dirty;全量推进还要求所有实现表面 clean。`commit --verified` 让语义仍成立的文档只刷新有效性锚点。
+
+`status` 与 Viewer 共用 repository health 判定:原始 commits-behind 保留为 Git 历史可观测值,知识陈旧度只计其中触碰 implementation surface 的 relevant commit。behind 全为 metadata-only 时仍是 knowledge-clean,不能直接把 `behindHead > 0` 渲染成 stale;无法可靠计算 relevant 数量时必须保持 unknown/degraded,而不是乐观判 fresh。fingerprint 之后由 CLI 自己创建的纯 `llmdoc/meta.json` follow-up 因此不代表知识再次过期,重复收尾应成为 no-op。
 
 ## Hook 信号边界
 
diff --git a/llmdoc/meta.json b/llmdoc/meta.json
index ea7f86a..6329fe2 100644
--- a/llmdoc/meta.json
+++ b/llmdoc/meta.json
@@ -9,10 +9,10 @@
       "validatedRevision": "0157d17e0cca2eb0ed0aaf20da41b80035be3a2e"
     },
     "cli-runtime/state-and-validation.mdx": {
-      "validatedRevision": "61ef5822ec305f61ebf41bb826e143a889c31e5e"
+      "validatedRevision": "88e578b04885de7a8e104887834c57c1b1cff5ba"
     },
     "cli-runtime/retrieval-and-mutation.mdx": {
-      "validatedRevision": "61ef5822ec305f61ebf41bb826e143a889c31e5e"
+      "validatedRevision": "88e578b04885de7a8e104887834c57c1b1cff5ba"
     },
     "plugin-packaging/claude-and-codex.mdx": {
       "validatedRevision": "0157d17e0cca2eb0ed0aaf20da41b80035be3a2e"
@@ -21,7 +21,7 @@
       "validatedRevision": "832bcf943c10528b60f01a02a7d8ce607ea44699"
     },
     "workflows/init-and-update.mdx": {
-      "validatedRevision": "61ef5822ec305f61ebf41bb826e143a889c31e5e"
+      "validatedRevision": "88e578b04885de7a8e104887834c57c1b1cff5ba"
     },
     "workflows/prune-and-upgrade.mdx": {
       "validatedRevision": "61ef5822ec305f61ebf41bb826e143a889c31e5e"