Resolve relative image paths in export/import markdown viewer

The MarkdownBody component now accepts an optional resolveImageSrc callback
that maps relative image paths (like images/org-chart.png) to base64 data URLs
from the portable file entries. This fixes the export README showing a broken
image instead of the org chart PNG.

Applied to both CompanyExport and CompanyImport preview panes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-19 16:33:30 -05:00
parent 531945cfe2
commit a9802c1962
3 changed files with 45 additions and 6 deletions

View file

@ -8,6 +8,8 @@ import { useTheme } from "../context/ThemeContext";
interface MarkdownBodyProps { interface MarkdownBodyProps {
children: string; children: string;
className?: string; className?: string;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
} }
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null; let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
@ -112,7 +114,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
); );
} }
export function MarkdownBody({ children, className }: MarkdownBodyProps) { export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownBodyProps) {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<div <div
@ -152,6 +154,12 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
</a> </a>
); );
}, },
img: resolveImageSrc
? ({ src, alt, ...imgProps }) => {
const resolved = src ? resolveImageSrc(src) : null;
return <img {...imgProps} src={resolved ?? src} alt={alt ?? ""} />;
}
: undefined,
}} }}
> >
{children} {children}

View file

@ -470,10 +470,12 @@ function generateReadmeFromSelection(
function ExportPreviewPane({ function ExportPreviewPane({
selectedFile, selectedFile,
content, content,
allFiles,
onSkillClick, onSkillClick,
}: { }: {
selectedFile: string | null; selectedFile: string | null;
content: CompanyPortabilityFileEntry | null; content: CompanyPortabilityFileEntry | null;
allFiles: Record<string, CompanyPortabilityFileEntry>;
onSkillClick?: (skill: string) => void; onSkillClick?: (skill: string) => void;
}) { }) {
if (!selectedFile || content === null) { if (!selectedFile || content === null) {
@ -487,6 +489,20 @@ function ExportPreviewPane({
const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null; const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null;
const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null; const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null;
// Resolve relative image paths within the export package (e.g. images/org-chart.png)
const resolveImageSrc = isMarkdown
? (src: string) => {
// Skip absolute URLs and data URIs
if (/^(?:https?:|data:)/i.test(src)) return null;
// Resolve relative to the directory of the current markdown file
const dir = selectedFile.includes("/") ? selectedFile.slice(0, selectedFile.lastIndexOf("/") + 1) : "";
const resolved = dir + src;
const entry = allFiles[resolved] ?? allFiles[src];
if (!entry) return null;
return getPortableFileDataUrl(resolved in allFiles ? resolved : src, entry);
}
: undefined;
return ( return (
<div className="min-w-0"> <div className="min-w-0">
<div className="border-b border-border px-5 py-3"> <div className="border-b border-border px-5 py-3">
@ -496,10 +512,10 @@ function ExportPreviewPane({
{parsed ? ( {parsed ? (
<> <>
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} /> <FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>} {parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
</> </>
) : isMarkdown ? ( ) : isMarkdown ? (
<MarkdownBody>{textContent ?? ""}</MarkdownBody> <MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
) : imageSrc ? ( ) : imageSrc ? (
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6"> <div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" /> <img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
@ -924,7 +940,7 @@ export function CompanyExport() {
</div> </div>
</aside> </aside>
<div className="min-w-0 overflow-y-auto pl-6"> <div className="min-w-0 overflow-y-auto pl-6">
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} onSkillClick={handleSkillClick} /> <ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -177,11 +177,13 @@ function importFileRowClassName(_node: FileTreeNode, checked: boolean) {
function ImportPreviewPane({ function ImportPreviewPane({
selectedFile, selectedFile,
content, content,
allFiles,
action, action,
renamedTo, renamedTo,
}: { }: {
selectedFile: string | null; selectedFile: string | null;
content: CompanyPortabilityFileEntry | null; content: CompanyPortabilityFileEntry | null;
allFiles: Record<string, CompanyPortabilityFileEntry>;
action: string | null; action: string | null;
renamedTo: string | null; renamedTo: string | null;
}) { }) {
@ -197,6 +199,18 @@ function ImportPreviewPane({
const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null; const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null;
const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : ""; const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : "";
// Resolve relative image paths within the import package
const resolveImageSrc = isMarkdown
? (src: string) => {
if (/^(?:https?:|data:)/i.test(src)) return null;
const dir = selectedFile.includes("/") ? selectedFile.slice(0, selectedFile.lastIndexOf("/") + 1) : "";
const resolved = dir + src;
const entry = allFiles[resolved] ?? allFiles[src];
if (!entry) return null;
return getPortableFileDataUrl(resolved in allFiles ? resolved : src, entry);
}
: undefined;
return ( return (
<div className="min-w-0"> <div className="min-w-0">
<div className="border-b border-border px-5 py-3"> <div className="border-b border-border px-5 py-3">
@ -223,10 +237,10 @@ function ImportPreviewPane({
{parsed ? ( {parsed ? (
<> <>
<FrontmatterCard data={parsed.data} /> <FrontmatterCard data={parsed.data} />
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>} {parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
</> </>
) : isMarkdown ? ( ) : isMarkdown ? (
<MarkdownBody>{textContent ?? ""}</MarkdownBody> <MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
) : imageSrc ? ( ) : imageSrc ? (
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6"> <div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" /> <img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
@ -1265,6 +1279,7 @@ export function CompanyImport() {
<ImportPreviewPane <ImportPreviewPane
selectedFile={selectedFile} selectedFile={selectedFile}
content={previewContent} content={previewContent}
allFiles={importPreview?.files ?? {}}
action={selectedAction} action={selectedAction}
renamedTo={selectedFile ? (renameMap.get(selectedFile) ?? null) : null} renamedTo={selectedFile ? (renameMap.get(selectedFile) ?? null) : null}
/> />