refactor: replace SVG org chart with Mermaid diagram in exports
- 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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
ceo: "CEO",
|
||||
cto: "CTO",
|
||||
@@ -103,107 +16,45 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string | null, OrgNode[]>();
|
||||
|
||||
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(`<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">`);
|
||||
lines.push(`<style>`);
|
||||
lines.push(` text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }`);
|
||||
lines.push(`</style>`);
|
||||
lines.push(`<rect width="${svgW}" height="${svgH}" fill="#ffffff" rx="8"/>`);
|
||||
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(`<path d="M${x1},${y1} C${x1},${midY} ${x2},${midY} ${x2},${y2}" fill="none" stroke="#d1d5db" stroke-width="2"/>`);
|
||||
// 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)}<br/><small>${mermaidEscape(roleLabel)}</small>"]`);
|
||||
}
|
||||
|
||||
// Draw cards
|
||||
for (const node of allNodes) {
|
||||
const roleLabel = ROLE_LABELS[node.role] ?? node.role;
|
||||
lines.push(`<g transform="translate(${node.x},${node.y})">`);
|
||||
lines.push(` <rect width="${CARD_W}" height="${CARD_H}" rx="8" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1.5"/>`);
|
||||
lines.push(` <text x="${CARD_W / 2}" y="28" text-anchor="middle" font-size="14" font-weight="600" fill="#111827">${escapeXml(truncate(node.name, 22))}</text>`);
|
||||
lines.push(` <text x="${CARD_W / 2}" y="48" text-anchor="middle" font-size="12" fill="#6b7280">${escapeXml(roleLabel)}</text>`);
|
||||
lines.push(`</g>`);
|
||||
// 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(`</svg>`);
|
||||
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, "<").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 as Mermaid diagram
|
||||
const mermaid = generateOrgChartMermaid(manifest.agents);
|
||||
if (mermaid) {
|
||||
lines.push(mermaid);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -331,6 +331,36 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
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, "<").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)}<br/><small>${mermaidEscape(roleLabel)}</small>"]`);
|
||||
}
|
||||
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 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, string>): 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 ``;
|
||||
}
|
||||
return _match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preview pane ──────────────────────────────────────────────────────
|
||||
|
||||
function ExportPreviewPane({
|
||||
selectedFile,
|
||||
content,
|
||||
files,
|
||||
onSkillClick,
|
||||
}: {
|
||||
selectedFile: string | null;
|
||||
content: string | null;
|
||||
files: Record<string, string>;
|
||||
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 (
|
||||
<div className="min-w-0">
|
||||
@@ -475,15 +482,10 @@ function ExportPreviewPane({
|
||||
{parsed ? (
|
||||
<>
|
||||
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
|
||||
{resolvedBody?.trim() && <MarkdownBody>{resolvedBody}</MarkdownBody>}
|
||||
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>}
|
||||
</>
|
||||
) : isMarkdown ? (
|
||||
<MarkdownBody>{resolvedContent}</MarkdownBody>
|
||||
) : isSvg ? (
|
||||
<div
|
||||
className="flex justify-center overflow-auto rounded-lg border border-border bg-white p-4"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
<MarkdownBody>{content}</MarkdownBody>
|
||||
) : (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
|
||||
<code>{content}</code>
|
||||
@@ -875,7 +877,7 @@ export function CompanyExport() {
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 overflow-y-auto pl-6">
|
||||
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} files={effectiveFiles} onSkillClick={handleSkillClick} />
|
||||
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} onSkillClick={handleSkillClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user