feat: dynamic README in export preview with SVG image resolution

- README.md now regenerates in real-time when files are checked/unchecked
  in the export file tree, so counts and tables reflect the actual selection
- SVG image references in markdown (e.g. images/org-chart.svg) resolve to
  inline data URIs so the org chart renders in the README preview
- Fixes issue where unchecked tasks/projects were still counted in README

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-17 08:27:42 -05:00
parent 5d1e39b651
commit 78342e384d

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import type { CompanyPortabilityExportResult } from "@paperclipai/shared";
import type { CompanyPortabilityExportResult, CompanyPortabilityManifest } from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
@@ -323,15 +323,133 @@ function FrontmatterCard({
);
}
// ── Client-side README generation ────────────────────────────────────
const ROLE_LABELS: Record<string, string> = {
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", coo: "COO",
vp: "VP", manager: "Manager", engineer: "Engineer", agent: "Agent",
};
/**
* Regenerate README.md content based on the currently checked files.
* Only counts/lists entities whose files are in the checked set.
*/
function generateReadmeFromSelection(
manifest: CompanyPortabilityManifest,
checkedFiles: Set<string>,
companyName: string,
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));
const tasks = manifest.issues.filter((t) => slugs.tasks.has(t.slug));
const skills = manifest.skills.filter((s) => {
// Skill files live under skills/{key}/...
return [...checkedFiles].some((f) => f.startsWith(`skills/${s.key}/`) || f.startsWith(`skills/`) && f.includes(`/${s.slug}/`));
});
const lines: string[] = [];
lines.push(`# ${companyName}`);
lines.push("");
if (companyDescription) {
lines.push(`> ${companyDescription}`);
lines.push("");
}
if (hasOrgChart) {
lines.push(`![Org Chart](images/org-chart.svg)`);
lines.push("");
}
lines.push("## What's Inside");
lines.push("");
lines.push("This is an [Agent Company](https://paperclip.ing) package.");
lines.push("");
const counts: Array<[string, number]> = [];
if (agents.length > 0) counts.push(["Agents", agents.length]);
if (projects.length > 0) counts.push(["Projects", projects.length]);
if (skills.length > 0) counts.push(["Skills", skills.length]);
if (tasks.length > 0) counts.push(["Tasks", tasks.length]);
if (counts.length > 0) {
lines.push("| Content | Count |");
lines.push("|---------|-------|");
for (const [label, count] of counts) {
lines.push(`| ${label} | ${count} |`);
}
lines.push("");
}
if (agents.length > 0) {
lines.push("### Agents");
lines.push("");
lines.push("| Agent | Role | Reports To |");
lines.push("|-------|------|------------|");
for (const agent of agents) {
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
const reportsTo = agent.reportsToSlug ?? "\u2014";
lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`);
}
lines.push("");
}
if (projects.length > 0) {
lines.push("### Projects");
lines.push("");
for (const project of projects) {
const desc = project.description ? ` \u2014 ${project.description}` : "";
lines.push(`- **${project.name}**${desc}`);
}
lines.push("");
}
lines.push("## Getting Started");
lines.push("");
lines.push("```bash");
lines.push("pnpm paperclipai company import this-github-url-or-folder");
lines.push("```");
lines.push("");
lines.push("See [Paperclip](https://paperclip.ing) for more information.");
lines.push("");
lines.push("---");
lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`);
lines.push("");
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 `![${alt}](${dataUri})`;
}
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) {
@@ -343,6 +461,9 @@ 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">
@@ -353,10 +474,10 @@ function ExportPreviewPane({
{parsed ? (
<>
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>}
{resolvedBody?.trim() && <MarkdownBody>{resolvedBody}</MarkdownBody>}
</>
) : isMarkdown ? (
<MarkdownBody>{content}</MarkdownBody>
<MarkdownBody>{resolvedContent}</MarkdownBody>
) : isSvg ? (
<div
className="flex justify-center overflow-auto rounded-lg border border-border bg-white p-4"
@@ -453,16 +574,32 @@ export function CompanyExport() {
};
}, [tree, treeSearch, checkedFiles, taskLimit]);
// Recompute .paperclip.yaml content whenever checked files change so
// the preview & download always reflect the current selection.
// Recompute .paperclip.yaml and README.md content whenever checked files
// change so the preview & download always reflect the current selection.
const effectiveFiles = useMemo(() => {
if (!exportData) return {} as Record<string, string>;
const yamlPath = exportData.paperclipExtensionPath;
if (!yamlPath || !exportData.files[yamlPath]) return exportData.files;
const filtered = { ...exportData.files };
filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles);
// Filter .paperclip.yaml
const yamlPath = exportData.paperclipExtensionPath;
if (yamlPath && exportData.files[yamlPath]) {
filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles);
}
// Regenerate README.md based on checked selection
if (exportData.files["README.md"]) {
const companyName = exportData.manifest.company?.name ?? selectedCompany?.name ?? "Company";
const companyDescription = exportData.manifest.company?.description ?? null;
filtered["README.md"] = generateReadmeFromSelection(
exportData.manifest,
checkedFiles,
companyName,
companyDescription,
);
}
return filtered;
}, [exportData, checkedFiles]);
}, [exportData, checkedFiles, selectedCompany?.name]);
const totalFiles = useMemo(() => countFiles(tree), [tree]);
const selectedCount = checkedFiles.size;
@@ -671,7 +808,7 @@ export function CompanyExport() {
</div>
</aside>
<div className="min-w-0 overflow-y-auto pl-6">
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} onSkillClick={handleSkillClick} />
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} files={effectiveFiles} onSkillClick={handleSkillClick} />
</div>
</div>
</div>