From 517e90c13a58f9f05ba0e16bfcecb9c7223e70a0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 17 Mar 2026 09:09:37 -0500 Subject: [PATCH] refactor: replace SVG org chart with Mermaid diagram in exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Org chart now uses a Mermaid flowchart (graph TD) instead of a standalone SVG file — GitHub and the preview both render it natively - Removed SVG generation code, layout algorithm, and image resolution - Removed images/org-chart.svg from export output - Simplified ExportPreviewPane (no more SVG/data-URI handling) - Both server and client README generators produce Mermaid diagrams Co-Authored-By: Paperclip --- server/src/services/company-export-readme.ts | 212 +++---------------- server/src/services/company-portability.ts | 9 +- ui/src/pages/CompanyExport.tsx | 72 ++++--- 3 files changed, 71 insertions(+), 222 deletions(-) diff --git a/server/src/services/company-export-readme.ts b/server/src/services/company-export-readme.ts index 56e59d23..72ebe8b1 100644 --- a/server/src/services/company-export-readme.ts +++ b/server/src/services/company-export-readme.ts @@ -1,95 +1,8 @@ /** - * Generates README.md and org chart SVG for company exports. + * Generates README.md with Mermaid org chart for company exports. */ import type { CompanyPortabilityManifest } from "@paperclipai/shared"; -// ── Org chart layout (mirrors ui/src/pages/OrgChart.tsx) ──────────────── - -const CARD_W = 200; -const CARD_H = 72; -const GAP_X = 32; -const GAP_Y = 64; -const PADDING = 40; - -interface OrgNode { - id: string; - name: string; - role: string; - reports: OrgNode[]; -} - -interface LayoutNode { - id: string; - name: string; - role: string; - x: number; - y: number; - children: LayoutNode[]; -} - -function subtreeWidth(node: OrgNode): number { - if (node.reports.length === 0) return CARD_W; - const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0); - const gaps = (node.reports.length - 1) * GAP_X; - return Math.max(CARD_W, childrenW + gaps); -} - -function layoutTree(node: OrgNode, x: number, y: number): LayoutNode { - const totalW = subtreeWidth(node); - const layoutChildren: LayoutNode[] = []; - - if (node.reports.length > 0) { - const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0); - const gaps = (node.reports.length - 1) * GAP_X; - let cx = x + (totalW - childrenW - gaps) / 2; - - for (const child of node.reports) { - const cw = subtreeWidth(child); - layoutChildren.push(layoutTree(child, cx, y + CARD_H + GAP_Y)); - cx += cw + GAP_X; - } - } - - return { - id: node.id, - name: node.name, - role: node.role, - x: x + (totalW - CARD_W) / 2, - y, - children: layoutChildren, - }; -} - -function flattenLayout(nodes: LayoutNode[]): LayoutNode[] { - const result: LayoutNode[] = []; - function walk(n: LayoutNode) { - result.push(n); - n.children.forEach(walk); - } - nodes.forEach(walk); - return result; -} - -function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: LayoutNode }> { - const edges: Array<{ parent: LayoutNode; child: LayoutNode }> = []; - function walk(n: LayoutNode) { - for (const c of n.children) { - edges.push({ parent: n, child: c }); - walk(c); - } - } - nodes.forEach(walk); - return edges; -} - -function escapeXml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -} - -function truncate(s: string, max: number): string { - return s.length > max ? s.slice(0, max - 1) + "\u2026" : s; -} - const ROLE_LABELS: Record = { ceo: "CEO", cto: "CTO", @@ -103,107 +16,45 @@ const ROLE_LABELS: Record = { }; /** - * Build an org-tree from the manifest agent list using reportsToSlug. - */ -function buildOrgTree(agents: CompanyPortabilityManifest["agents"]): OrgNode[] { - const bySlug = new Map(agents.map((a) => [a.slug, a])); - const childrenOf = new Map(); - - for (const agent of agents) { - const node: OrgNode = { - id: agent.slug, - name: agent.name, - role: agent.role, - reports: [], - }; - const parentKey = agent.reportsToSlug ?? null; - const siblings = childrenOf.get(parentKey) ?? []; - siblings.push(node); - childrenOf.set(parentKey, siblings); - } - - // Build tree recursively - function attach(nodes: OrgNode[]): OrgNode[] { - for (const node of nodes) { - node.reports = childrenOf.get(node.id) ?? []; - attach(node.reports); - } - return nodes; - } - - // Roots are agents whose reportsToSlug is null or points to a non-existent agent - const roots: OrgNode[] = []; - for (const [parentKey, children] of childrenOf.entries()) { - if (parentKey === null || !bySlug.has(parentKey)) { - roots.push(...children); - } - } - return attach(roots); -} - -/** - * Generate an SVG org chart from the manifest agents. + * Generate a Mermaid flowchart (TD = top-down) representing the org chart. * Returns null if there are no agents. */ -export function generateOrgChartSvg(manifest: CompanyPortabilityManifest): string | null { - if (manifest.agents.length === 0) return null; - - const roots = buildOrgTree(manifest.agents); - if (roots.length === 0) return null; - - // Layout all roots side by side - const layoutRoots: LayoutNode[] = []; - let x = PADDING; - for (const root of roots) { - const w = subtreeWidth(root); - layoutRoots.push(layoutTree(root, x, PADDING)); - x += w + GAP_X; - } - - const allNodes = flattenLayout(layoutRoots); - const edges = collectEdges(layoutRoots); - - // Compute canvas bounds - let maxX = 0; - let maxY = 0; - for (const n of allNodes) { - maxX = Math.max(maxX, n.x + CARD_W); - maxY = Math.max(maxY, n.y + CARD_H); - } - const svgW = maxX + PADDING; - const svgH = maxY + PADDING; +export function generateOrgChartMermaid(agents: CompanyPortabilityManifest["agents"]): string | null { + if (agents.length === 0) return null; const lines: string[] = []; - lines.push(``); - lines.push(``); - lines.push(``); + lines.push("```mermaid"); + lines.push("graph TD"); - // Draw edges (bezier connectors) - for (const { parent, child } of edges) { - const x1 = parent.x + CARD_W / 2; - const y1 = parent.y + CARD_H; - const x2 = child.x + CARD_W / 2; - const y2 = child.y; - const midY = (y1 + y2) / 2; - lines.push(``); + // Node definitions with role labels + for (const agent of agents) { + const roleLabel = ROLE_LABELS[agent.role] ?? agent.role; + const id = mermaidId(agent.slug); + lines.push(` ${id}["${mermaidEscape(agent.name)}
${mermaidEscape(roleLabel)}"]`); } - // Draw cards - for (const node of allNodes) { - const roleLabel = ROLE_LABELS[node.role] ?? node.role; - lines.push(``); - lines.push(` `); - lines.push(` ${escapeXml(truncate(node.name, 22))}`); - lines.push(` ${escapeXml(roleLabel)}`); - lines.push(``); + // Edges from parent to child + const slugSet = new Set(agents.map((a) => a.slug)); + for (const agent of agents) { + if (agent.reportsToSlug && slugSet.has(agent.reportsToSlug)) { + lines.push(` ${mermaidId(agent.reportsToSlug)} --> ${mermaidId(agent.slug)}`); + } } - lines.push(``); + lines.push("```"); return lines.join("\n"); } +/** Sanitize slug for use as a Mermaid node ID (alphanumeric + underscore). */ +function mermaidId(slug: string): string { + return slug.replace(/[^a-zA-Z0-9_]/g, "_"); +} + +/** Escape text for Mermaid node labels. */ +function mermaidEscape(s: string): string { + return s.replace(/"/g, """).replace(//g, ">"); +} + /** * Generate the README.md content for a company export. */ @@ -212,7 +63,6 @@ export function generateReadme( options: { companyName: string; companyDescription: string | null; - hasOrgChart: boolean; }, ): string { const lines: string[] = []; @@ -224,8 +74,10 @@ export function generateReadme( lines.push(""); } - if (options.hasOrgChart) { - lines.push(`![Org Chart](images/org-chart.svg)`); + // Org chart as Mermaid diagram + const mermaid = generateOrgChartMermaid(manifest.agents); + if (mermaid) { + lines.push(mermaid); lines.push(""); } diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 93dc4817..bf6bffc6 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -35,7 +35,7 @@ import { import { notFound, unprocessable } from "../errors.js"; import { accessService } from "./access.js"; import { agentService } from "./agents.js"; -import { generateOrgChartSvg, generateReadme } from "./company-export-readme.js"; +import { generateReadme } from "./company-export-readme.js"; import { companySkillService } from "./company-skills.js"; import { companyService } from "./companies.js"; import { issueService } from "./issues.js"; @@ -1939,15 +1939,10 @@ export function companyPortabilityService(db: Db) { resolved.manifest.envInputs = dedupeEnvInputs(envInputs); resolved.warnings.unshift(...warnings); - // Generate org chart SVG and README.md - const orgChartSvg = generateOrgChartSvg(resolved.manifest); - if (orgChartSvg) { - files["images/org-chart.svg"] = orgChartSvg; - } + // Generate README.md with Mermaid org chart files["README.md"] = generateReadme(resolved.manifest, { companyName: company.name, companyDescription: company.description ?? null, - hasOrgChart: orgChartSvg !== null, }); return { diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 922e9c5f..1abfb580 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -331,6 +331,36 @@ const ROLE_LABELS: Record = { vp: "VP", manager: "Manager", engineer: "Engineer", agent: "Agent", }; +/** Sanitize slug for use as a Mermaid node ID. */ +function mermaidId(slug: string): string { + return slug.replace(/[^a-zA-Z0-9_]/g, "_"); +} + +/** Escape text for Mermaid node labels. */ +function mermaidEscape(s: string): string { + return s.replace(/"/g, """).replace(//g, ">"); +} + +/** Generate a Mermaid org chart from the selected agents. */ +function generateOrgChartMermaid(agents: CompanyPortabilityManifest["agents"]): string | null { + if (agents.length === 0) return null; + const lines: string[] = []; + lines.push("```mermaid"); + lines.push("graph TD"); + for (const agent of agents) { + const roleLabel = ROLE_LABELS[agent.role] ?? agent.role; + lines.push(` ${mermaidId(agent.slug)}["${mermaidEscape(agent.name)}
${mermaidEscape(roleLabel)}"]`); + } + const slugSet = new Set(agents.map((a) => a.slug)); + for (const agent of agents) { + if (agent.reportsToSlug && slugSet.has(agent.reportsToSlug)) { + lines.push(` ${mermaidId(agent.reportsToSlug)} --> ${mermaidId(agent.slug)}`); + } + } + lines.push("```"); + return lines.join("\n"); +} + /** * Regenerate README.md content based on the currently checked files. * Only counts/lists entities whose files are in the checked set. @@ -342,7 +372,6 @@ function generateReadmeFromSelection( companyDescription: string | null, ): string { const slugs = checkedSlugs(checkedFiles); - const hasOrgChart = checkedFiles.has("images/org-chart.svg"); const agents = manifest.agents.filter((a) => slugs.agents.has(a.slug)); const projects = manifest.projects.filter((p) => slugs.projects.has(p.slug)); @@ -359,8 +388,10 @@ function generateReadmeFromSelection( lines.push(`> ${companyDescription}`); lines.push(""); } - if (hasOrgChart) { - lines.push(`![Org Chart](images/org-chart.svg)`); + // Org chart as Mermaid diagram + const mermaid = generateOrgChartMermaid(agents); + if (mermaid) { + lines.push(mermaid); lines.push(""); } @@ -422,35 +453,15 @@ function generateReadmeFromSelection( return lines.join("\n"); } -/** - * Resolve relative image paths in markdown content using the export files map. - * Converts SVG references to inline data URIs so they render in the preview. - */ -function resolveMarkdownImages(markdown: string, files: Record): string { - return markdown.replace( - /!\[([^\]]*)\]\(([^)]+)\)/g, - (_match, alt: string, src: string) => { - const svgContent = files[src]; - if (svgContent && src.endsWith(".svg")) { - const dataUri = `data:image/svg+xml;base64,${btoa(svgContent)}`; - return `![${alt}](${dataUri})`; - } - return _match; - }, - ); -} - // ── Preview pane ────────────────────────────────────────────────────── function ExportPreviewPane({ selectedFile, content, - files, onSkillClick, }: { selectedFile: string | null; content: string | null; - files: Record; onSkillClick?: (skill: string) => void; }) { if (!selectedFile || content === null) { @@ -460,11 +471,7 @@ function ExportPreviewPane({ } const isMarkdown = selectedFile.endsWith(".md"); - const isSvg = selectedFile.endsWith(".svg"); const parsed = isMarkdown ? parseFrontmatter(content) : null; - // Resolve relative image paths (e.g. images/org-chart.svg) for markdown preview - const resolvedBody = parsed?.body ? resolveMarkdownImages(parsed.body, files) : null; - const resolvedContent = isMarkdown && !parsed ? resolveMarkdownImages(content, files) : content; return (
@@ -475,15 +482,10 @@ function ExportPreviewPane({ {parsed ? ( <> - {resolvedBody?.trim() && {resolvedBody}} + {parsed.body.trim() && {parsed.body}} ) : isMarkdown ? ( - {resolvedContent} - ) : isSvg ? ( -
+ {content} ) : (
             {content}
@@ -875,7 +877,7 @@ export function CompanyExport() {
           
- +