Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/extensions-silo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"@silo-code/sdk": "workspace:*",
"react": "^19.1.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"yaml": "^2.9.0"
},
Expand Down
103 changes: 101 additions & 2 deletions packages/extensions-silo/src/markdown-preview/MarkdownPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,83 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import type { EditorProps, ExtensionContext } from "@silo-code/sdk";
import { buildPreviewMenuItems } from "./menu";
import { classifyMarkdownLink } from "./links";
import { parseFrontmatter, formatFrontmatterValue } from "./frontmatter";
import { GITHUB_SANITIZE_SCHEMA } from "./sanitize-schema";
import { isExternalImageUrl, resolveLocalImagePath } from "./resolveImageSrc";
import "./MarkdownPreview.css";

const MIME_BY_EXT: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
bmp: "image/bmp",
avif: "image/avif",
ico: "image/x-icon",
};

function mimeFromPath(p: string): string {
return (
MIME_BY_EXT[p.split(".").pop()?.toLowerCase() ?? ""] ??
"application/octet-stream"
);
}

interface MarkdownImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
ctx: ExtensionContext;
filePath: string | null;
}

function MarkdownImage({
src,
alt,
ctx,
filePath,
...rest
}: MarkdownImageProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);

useEffect(() => {
setBlobUrl(null);
if (!src || isExternalImageUrl(src)) return;
const absolutePath = resolveLocalImagePath(src, filePath);
if (!absolutePath) return;

let cancelled = false;
let createdUrl: string | null = null;

ctx.files
.readBytes(absolutePath)
.then((bytes) => {
if (cancelled) return;
const blob = new Blob([bytes], { type: mimeFromPath(absolutePath) });
createdUrl = URL.createObjectURL(blob);
setBlobUrl(createdUrl);
})
.catch(() => {
// Broken image indicator renders naturally when src is undefined.
});

return () => {
cancelled = true;
if (createdUrl) URL.revokeObjectURL(createdUrl);
};
}, [src, filePath, ctx]);

const effectiveSrc = isExternalImageUrl(src ?? "")
? src
: (blobUrl ?? undefined);
return <img src={effectiveSrc} alt={alt} {...rest} />;
}

function FrontmatterBlock({ fields }: { fields: Record<string, unknown> }) {
const entries = Object.entries(fields);
if (entries.length === 0) return null;
Expand Down Expand Up @@ -43,6 +113,28 @@ export function MarkdownPreview({
const [error, setError] = useState<string | null>(null);
const bodyRef = useRef<HTMLElement>(null);

const components = useMemo(
() => ({
img({
node: _node,
src,
alt,
...rest
}: React.ImgHTMLAttributes<HTMLImageElement> & { node?: unknown }) {
return (
<MarkdownImage
src={src}
alt={alt}
ctx={ctx}
filePath={filePath}
{...rest}
/>
);
},
}),
[ctx, filePath],
);

useEffect(() => {
if (!filePath) {
setError("Markdown preview requires a file path.");
Expand Down Expand Up @@ -146,7 +238,14 @@ export function MarkdownPreview({
return (
<>
{parsed && <FrontmatterBlock fields={parsed.fields} />}
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, GITHUB_SANITIZE_SCHEMA],
]}
components={components}
>
{body}
</ReactMarkdown>
</>
Expand Down
109 changes: 109 additions & 0 deletions packages/extensions-silo/src/markdown-preview/resolveImageSrc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { isExternalImageUrl, resolveLocalImagePath } from "./resolveImageSrc";

describe("isExternalImageUrl", () => {
it("recognises http:// URLs", () => {
expect(isExternalImageUrl("http://example.com/img.png")).toBe(true);
});

it("recognises https:// URLs", () => {
expect(isExternalImageUrl("https://img.shields.io/badge.svg")).toBe(true);
});

it("is case-insensitive", () => {
expect(isExternalImageUrl("HTTP://example.com/img.png")).toBe(true);
expect(isExternalImageUrl("HTTPS://example.com/img.png")).toBe(true);
});

it("returns false for relative paths", () => {
expect(isExternalImageUrl("./assets/logo.png")).toBe(false);
expect(isExternalImageUrl("../img.jpg")).toBe(false);
expect(isExternalImageUrl("assets/logo.png")).toBe(false);
});

it("returns false for absolute local paths", () => {
expect(isExternalImageUrl("/Users/me/images/logo.png")).toBe(false);
});

it("returns false for empty string", () => {
expect(isExternalImageUrl("")).toBe(false);
});

it("returns false for data: URIs", () => {
expect(isExternalImageUrl("data:image/png;base64,abc")).toBe(false);
});
});

describe("resolveLocalImagePath", () => {
const file = "/Users/me/project/docs/README.md";

it("resolves a same-directory relative path", () => {
expect(resolveLocalImagePath("assets/logo.png", file)).toBe(
"/Users/me/project/docs/assets/logo.png",
);
});

it("resolves a ./ prefixed relative path", () => {
expect(resolveLocalImagePath("./assets/logo.png", file)).toBe(
"/Users/me/project/docs/assets/logo.png",
);
});

it("resolves a ../ parent-directory path", () => {
expect(resolveLocalImagePath("../images/logo.png", file)).toBe(
"/Users/me/project/images/logo.png",
);
});

it("returns an absolute local path unchanged", () => {
expect(resolveLocalImagePath("/Users/me/images/logo.png", file)).toBe(
"/Users/me/images/logo.png",
);
});

it("returns null for http URLs", () => {
expect(
resolveLocalImagePath("http://example.com/img.png", file),
).toBeNull();
});

it("returns null for https URLs", () => {
expect(
resolveLocalImagePath("https://example.com/img.png", file),
).toBeNull();
});

it("returns null for protocol-relative URLs", () => {
expect(resolveLocalImagePath("//example.com/img.png", file)).toBeNull();
});

it("returns null for data: URIs", () => {
expect(resolveLocalImagePath("data:image/png;base64,abc", file)).toBeNull();
});

it("returns null when filePath is null", () => {
expect(resolveLocalImagePath("assets/logo.png", null)).toBeNull();
});

it("returns null for empty src", () => {
expect(resolveLocalImagePath("", file)).toBeNull();
});

it("strips ?query before resolving", () => {
expect(resolveLocalImagePath("assets/logo.png?v=2", file)).toBe(
"/Users/me/project/docs/assets/logo.png",
);
});

it("strips #fragment before resolving", () => {
expect(resolveLocalImagePath("assets/diagram.svg#section", file)).toBe(
"/Users/me/project/docs/assets/diagram.svg",
);
});

it("decodes %20-encoded spaces in path segments", () => {
expect(resolveLocalImagePath("my%20images/logo.png", file)).toBe(
"/Users/me/project/docs/my images/logo.png",
);
});
});
26 changes: 26 additions & 0 deletions packages/extensions-silo/src/markdown-preview/resolveImageSrc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Pure logic for resolving markdown image srcs — kept out of React so it's unit-testable.
import { resolveFilePath } from "./links";

const EXTERNAL = /^https?:/i;

export function isExternalImageUrl(src: string): boolean {
return EXTERNAL.test(src);
}

/**
* Given an image `src` from markdown and the path of the markdown file, return
* the absolute local filesystem path to load — or `null` if the src is external,
* unresolvable, or an unsupported scheme (data:, //, etc.).
*/
export function resolveLocalImagePath(
src: string,
filePath: string | null,
): string | null {
if (!src) return null;
if (isExternalImageUrl(src)) return null;
// Protocol-relative and data URIs can't map to a local file.
if (src.startsWith("//") || src.startsWith("data:")) return null;
// Drop ?query and #fragment before handing off to the filesystem resolver.
const stripped = src.split(/[?#]/, 1)[0];
return resolveFilePath(stripped, filePath);
}
17 changes: 17 additions & 0 deletions packages/extensions-silo/src/markdown-preview/sanitize-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defaultSchema } from "rehype-sanitize";

// Remove the protocol filter on `src` so relative/absolute local image paths
// pass through to the custom <img> renderer, which loads them via readBytes.
// All other protocol filters (href → http/https/mailto) are preserved.
const { src: _srcProtocol, ...protocolsWithoutSrc } =
(defaultSchema.protocols ?? {}) as Record<string, string[]>;

export const GITHUB_SANITIZE_SCHEMA = {
...defaultSchema,
protocols: protocolsWithoutSrc,
// remark-gfm emits <input type="checkbox" disabled> for task list items.
attributes: {
...defaultSchema.attributes,
input: ["type", "checked", "disabled"],
},
};
Loading
Loading