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:
@@ -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(``);
|
||||
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 ``;
|
||||
}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user