mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Show repository images in READMEs
Relative images in markdown and AsciiDoc files were broken, nothing resolved them against the repository. They're read through a new `repo_blob_bytes` command, which skips the text encoding and last commit lookup of `repo_blob`. Images now also keep their alt text. Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
committed by
Rūdolfs Ošiņš
co-authored by
Rūdolfs Ošiņš
parent
70046d0c74
commit
ab8300e09a
@@ -81,6 +81,18 @@ pub async fn repo_blob(
|
||||
blocking(ctx, move |ctx| ctx.repo_blob(rid, path, sha)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn repo_blob_bytes(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
rid: RepoId,
|
||||
path: std::path::PathBuf,
|
||||
sha: git::Oid,
|
||||
) -> Result<tauri::ipc::Response, Error> {
|
||||
let bytes = blocking(ctx, move |ctx| ctx.repo_blob_bytes(rid, path, sha)).await?;
|
||||
|
||||
Ok(tauri::ipc::Response::new(bytes))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn diff_stats(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
|
||||
@@ -117,6 +117,7 @@ pub fn run() {
|
||||
repo::repo_readme,
|
||||
repo::repo_tree,
|
||||
repo::repo_blob,
|
||||
repo::repo_blob_bytes,
|
||||
repo::seed,
|
||||
repo::seeded_not_replicated,
|
||||
repo::unseed,
|
||||
|
||||
@@ -603,6 +603,31 @@ pub trait Repo: Profile {
|
||||
))
|
||||
}
|
||||
|
||||
/// The bytes of the file at `path`, for files the webview renders itself,
|
||||
/// like the images in a README. Unlike `repo_blob` it skips the last
|
||||
/// commit lookup and the text encoding.
|
||||
fn repo_blob_bytes(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
path: std::path::PathBuf,
|
||||
sha: git::Oid,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let profile = self.profile();
|
||||
let repo = profile.storage.repository(rid)?;
|
||||
let commit = repo.backend.find_commit(sha.into())?;
|
||||
let entry = commit.tree()?.get_path(&path)?;
|
||||
let blob = entry
|
||||
.to_object(&repo.backend)?
|
||||
.into_blob()
|
||||
.map_err(|_| git2::Error::from_str("path does not point to a blob"))?;
|
||||
|
||||
if blob.size() > MAX_BLOB_SIZE {
|
||||
return Err(Error::FileTooLarge(blob.size()));
|
||||
}
|
||||
|
||||
Ok(blob.content().to_vec())
|
||||
}
|
||||
|
||||
fn list_repo_refs(&self, rid: identity::RepoId) -> Result<repo::RepoRefs, Error> {
|
||||
let profile = self.profile();
|
||||
let repo = profile.storage.repository(rid)?;
|
||||
@@ -1318,6 +1343,43 @@ mod test {
|
||||
assert_eq!(split("master"), ("master".into(), "".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_blob_bytes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||
let (rid, _, _, head) =
|
||||
fixtures::project(tmp.path().join("working"), &profile.storage, &signer).unwrap();
|
||||
let backend = &profile.storage.repository(rid).unwrap().backend;
|
||||
|
||||
let large = backend.blob(&vec![0; super::MAX_BLOB_SIZE + 1]).unwrap();
|
||||
let mut tree = backend.treebuilder(None).unwrap();
|
||||
tree.insert("large.png", large, 0o100644).unwrap();
|
||||
let tree = backend.find_tree(tree.write().unwrap()).unwrap();
|
||||
let author = git2::Signature::now("alice", "alice@example.com").unwrap();
|
||||
let large_commit = backend
|
||||
.commit(None, &author, &author, "Add a large file", &tree, &[])
|
||||
.unwrap();
|
||||
|
||||
let state = AppState { profile };
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.repo_blob_bytes(rid, "README".into(), head.into())
|
||||
.unwrap(),
|
||||
b"Hello World!\n"
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.repo_blob_bytes(rid, "missing.png".into(), head.into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(matches!(
|
||||
state.repo_blob_bytes(rid, "large.png".into(), large_commit.into()),
|
||||
Err(crate::error::Error::FileTooLarge(size)) if size == super::MAX_BLOB_SIZE + 1
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_commit_accepts_an_oid_revision() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -175,6 +175,7 @@ pub fn router(shared: Shared) -> Router {
|
||||
.route("/repo_readme", post(readme_handler))
|
||||
.route("/repo_tree", post(tree_handler))
|
||||
.route("/repo_blob", post(blob_handler))
|
||||
.route("/repo_blob_bytes", post(blob_bytes_handler))
|
||||
.route("/get_diff", post(diff_handler))
|
||||
.route("/get_diff_text", post(diff_text_handler))
|
||||
.route("/save_diff_to_disk", post(save_diff_handler))
|
||||
@@ -529,6 +530,22 @@ async fn blob_handler(
|
||||
Ok::<_, Error>(Json(info))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BlobBytesBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub path: PathBuf,
|
||||
pub sha: git::Oid,
|
||||
}
|
||||
|
||||
async fn blob_bytes_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(BlobBytesBody { rid, path, sha }): Json<BlobBytesBody>,
|
||||
) -> impl IntoResponse {
|
||||
let bytes = ctx.repo_blob_bytes(rid, path, sha)?;
|
||||
|
||||
Ok::<_, Error>(([(CONTENT_TYPE, "application/octet-stream")], bytes))
|
||||
}
|
||||
|
||||
async fn diff_handler(
|
||||
Ctx(ctx): Ctx,
|
||||
Json(DiffBody { rid, options }): Json<DiffBody>,
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
import { tick } from "svelte";
|
||||
|
||||
import type { AsciidocSource } from "@app/lib/asciidoc";
|
||||
import { renderAsciidoc, resolveRepoPath } from "@app/lib/asciidoc";
|
||||
import { renderAsciidoc } from "@app/lib/asciidoc";
|
||||
import { enhanceCodeBlocks } from "@app/lib/codeBlocks";
|
||||
import {
|
||||
enhanceRepoImages,
|
||||
RepoImages,
|
||||
resolveRepoPath,
|
||||
} from "@app/lib/repoImages";
|
||||
import { scrollIntoView, twemoji } from "@app/lib/utils";
|
||||
|
||||
interface Props extends AsciidocSource {
|
||||
@@ -18,13 +23,24 @@
|
||||
let error: string | undefined = $state();
|
||||
let scrolledToHash = false;
|
||||
|
||||
const images = new RepoImages();
|
||||
|
||||
$effect(() => () => images.dispose());
|
||||
|
||||
$effect(() => {
|
||||
let stale = false;
|
||||
|
||||
renderAsciidoc(content, { rid, sha, path })
|
||||
.then(async result => {
|
||||
// Unchanged markup isn't re-rendered, so its DOM is already enhanced.
|
||||
if (stale || result === html) return;
|
||||
if (stale) return;
|
||||
// Unchanged markup isn't re-rendered, so its DOM is already enhanced,
|
||||
// but its images may come from another revision.
|
||||
if (result === html) {
|
||||
if (container) {
|
||||
enhanceRepoImages(container, { rid, sha, path }, images);
|
||||
}
|
||||
return;
|
||||
}
|
||||
html = result;
|
||||
error = undefined;
|
||||
await tick();
|
||||
@@ -66,6 +82,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
enhanceRepoImages(container, { rid, sha, path }, images);
|
||||
enhanceCodeBlocks(container);
|
||||
|
||||
if (!scrolledToHash && window.location.hash) {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
} from "@app/lib/markdown";
|
||||
import { parseEntityHref } from "@app/lib/mentions";
|
||||
import { isOid } from "@app/lib/radUri";
|
||||
import { enhanceRepoImages, RepoImages } from "@app/lib/repoImages";
|
||||
import { scrollIntoView, twemoji } from "@app/lib/utils";
|
||||
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
@@ -33,6 +34,12 @@
|
||||
interface Props {
|
||||
rid?: string;
|
||||
content: string;
|
||||
// Path of the document being rendered, used to resolve relative links to
|
||||
// other files in the repository.
|
||||
path?: string;
|
||||
// Commit the document is being read at, without which repository-relative
|
||||
// images cannot be resolved.
|
||||
sha?: string;
|
||||
// If true, add <br> on a single line break
|
||||
breaks?: boolean;
|
||||
toggleTaskItem?: (content: string) => Promise<void> | void;
|
||||
@@ -41,6 +48,8 @@
|
||||
const {
|
||||
rid = "",
|
||||
content,
|
||||
path = "",
|
||||
sha = undefined,
|
||||
breaks = false,
|
||||
toggleTaskItem = undefined,
|
||||
}: Props = $props();
|
||||
@@ -68,6 +77,8 @@
|
||||
{ element: HTMLElement; url: string }
|
||||
>();
|
||||
|
||||
const images = new RepoImages();
|
||||
|
||||
let destroyed = false;
|
||||
|
||||
$effect(() => () => {
|
||||
@@ -76,6 +87,21 @@
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
embedPreviews.clear();
|
||||
images.dispose();
|
||||
});
|
||||
|
||||
// The revision and path of a document can change while its text stays the
|
||||
// same, so images are resolved apart from the rest of the markup.
|
||||
$effect(() => {
|
||||
const source = { rid, sha, path };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
content;
|
||||
|
||||
void tick().then(() => {
|
||||
if (container) {
|
||||
enhanceRepoImages(container, source, images);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createEmbedPreview(
|
||||
|
||||
@@ -103,33 +103,6 @@ const includeScopes = new Map<
|
||||
// depth. Every read past this budget is refused.
|
||||
export const maxIncludeReads = 256;
|
||||
|
||||
/**
|
||||
* Resolve a link written relative to the document at `from` into a repository
|
||||
* path. Returns undefined when the link points to another origin or to the
|
||||
* repository root. Targets that climb above the root stop at it.
|
||||
*/
|
||||
export function resolveRepoPath(
|
||||
href: string,
|
||||
from: string,
|
||||
): string | undefined {
|
||||
const dir = from.split("/").slice(0, -1).join("/");
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
// The trailing slash makes the base a directory, so `a.adoc` resolves as
|
||||
// a sibling of `from` rather than replacing its last segment.
|
||||
url = new URL(href, `${includeOrigin}/${dir ? `${dir}/` : ""}`);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.origin !== includeOrigin) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return decodeURIComponent(url.pathname).replace(/^\//, "") || undefined;
|
||||
}
|
||||
|
||||
async function readInclude(uri: string): Promise<Response> {
|
||||
let url: URL;
|
||||
try {
|
||||
|
||||
@@ -31,6 +31,7 @@ dompurify.setConfig({
|
||||
ALLOWED_URI_REGEXP: allowedUriSchemes,
|
||||
ALLOWED_ATTR: [
|
||||
"align",
|
||||
"alt",
|
||||
"checked",
|
||||
"class",
|
||||
"href",
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { retinaWidth } from "@app/lib/embeds";
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
|
||||
// Links in a document are resolved against this origin, so that anything
|
||||
// resolving to another origin is known to point outside the repository. It
|
||||
// is never requested.
|
||||
const repoOrigin = "https://repo.radicle.invalid";
|
||||
|
||||
/**
|
||||
* Resolve a link written relative to the document at `from` into a repository
|
||||
* path. Returns undefined when the link points to another origin or to the
|
||||
* repository root. Targets that climb above the root stop at it.
|
||||
*/
|
||||
export function resolveRepoPath(
|
||||
href: string,
|
||||
from: string,
|
||||
): string | undefined {
|
||||
const dir = from.split("/").slice(0, -1).join("/");
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
// The trailing slash makes the base a directory, so `a.adoc` resolves as
|
||||
// a sibling of `from` rather than replacing its last segment.
|
||||
url = new URL(href, `${repoOrigin}/${dir ? `${dir}/` : ""}`);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.origin !== repoOrigin) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let path = url.pathname;
|
||||
try {
|
||||
path = decodeURIComponent(path);
|
||||
} catch {
|
||||
// A `%` that doesn't start an escape is part of the file name.
|
||||
}
|
||||
|
||||
return path.replace(/^\//, "") || undefined;
|
||||
}
|
||||
|
||||
// Rewrite GitHub "blob" image URLs so they resolve to the raw image content. A
|
||||
// URL like https://github.com/<owner>/<repo>/blob/<ref>/<path> serves an HTML
|
||||
// page rather than the image bytes, so appending `?raw=true` makes GitHub
|
||||
// redirect to the raw content. URLs already pointing at the raw content are
|
||||
// returned unchanged.
|
||||
export function canonicalizeGithubImageUrl(input: string): string {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (
|
||||
(url.hostname === "github.com" || url.hostname === "www.github.com") &&
|
||||
/^\/[^/]+\/[^/]+\/blob\//.test(url.pathname) &&
|
||||
url.searchParams.get("raw") !== "true"
|
||||
) {
|
||||
url.searchParams.set("raw", "true");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
const imageTypes: Record<string, string> = {
|
||||
avif: "image/avif",
|
||||
bmp: "image/bmp",
|
||||
gif: "image/gif",
|
||||
ico: "image/x-icon",
|
||||
jpeg: "image/jpeg",
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
svg: "image/svg+xml",
|
||||
webp: "image/webp",
|
||||
};
|
||||
|
||||
// The webview sniffs raster formats, but only shows an SVG when the blob it
|
||||
// comes from says it is one, so the type comes from the file name.
|
||||
export function imageMimeType(path: string): string | undefined {
|
||||
const extension = /\.([^./]+)$/.exec(path)?.[1].toLowerCase();
|
||||
return extension ? imageTypes[extension] : undefined;
|
||||
}
|
||||
|
||||
export interface RepoImage {
|
||||
url: string;
|
||||
// The width a Retina screenshot is meant to be shown at.
|
||||
width: number | undefined;
|
||||
}
|
||||
|
||||
export type FetchRepoBytes = (
|
||||
rid: string,
|
||||
sha: string,
|
||||
path: string,
|
||||
) => Promise<ArrayBuffer>;
|
||||
|
||||
function fetchRepoBytes(rid: string, sha: string, path: string) {
|
||||
return invoke<ArrayBuffer>("repo_blob_bytes", { rid, sha, path });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads files out of a repository as images, once per file, and owns the
|
||||
* object URLs they are shown through.
|
||||
*/
|
||||
export class RepoImages {
|
||||
#fetch: FetchRepoBytes;
|
||||
#images = new Map<string, Promise<RepoImage | undefined>>();
|
||||
#urls = new Map<string, string>();
|
||||
#used = new Set<string>();
|
||||
#disposed = false;
|
||||
|
||||
constructor(fetch: FetchRepoBytes = fetchRepoBytes) {
|
||||
this.#fetch = fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves to undefined when the image was released before it loaded.
|
||||
*/
|
||||
load(rid: string, sha: string, path: string): Promise<RepoImage | undefined> {
|
||||
const key = JSON.stringify([rid, sha, path]);
|
||||
this.#used.add(key);
|
||||
|
||||
const cached = this.#images.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const image = this.#fetch(rid, sha, path).then(buffer => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([bytes], { type: imageMimeType(path) ?? "" }),
|
||||
);
|
||||
if (this.#images.get(key) !== image) {
|
||||
URL.revokeObjectURL(url);
|
||||
return undefined;
|
||||
}
|
||||
this.#urls.set(key, url);
|
||||
return { url, width: retinaWidth(bytes) };
|
||||
});
|
||||
image.catch(() => {
|
||||
// A failed load is retried on the next pass rather than cached.
|
||||
if (this.#images.get(key) === image) {
|
||||
this.#images.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (!this.#disposed) {
|
||||
this.#images.set(key, image);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the images not loaded since the previous sweep.
|
||||
*/
|
||||
sweep() {
|
||||
for (const key of this.#images.keys()) {
|
||||
if (!this.#used.has(key)) {
|
||||
this.#release(key);
|
||||
}
|
||||
}
|
||||
this.#used.clear();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.#disposed = true;
|
||||
for (const key of this.#images.keys()) {
|
||||
this.#release(key);
|
||||
}
|
||||
this.#used.clear();
|
||||
}
|
||||
|
||||
#release(key: string) {
|
||||
this.#images.delete(key);
|
||||
const url = this.#urls.get(key);
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
this.#urls.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A pass replaces the source of a repository image with an object URL, so the
|
||||
// source the document was written with is kept here for the next pass.
|
||||
const sourceAttribute = "data-repo-src";
|
||||
|
||||
/**
|
||||
* Points the images in `container` at their content: images in the repository
|
||||
* are read out of storage, GitHub page URLs are rewritten to the raw file.
|
||||
* Repository images need the commit the document is read at, and resolve
|
||||
* against the document's `path`.
|
||||
*/
|
||||
export function enhanceRepoImages(
|
||||
container: HTMLElement,
|
||||
source: { rid: string; sha: string | undefined; path: string },
|
||||
images: RepoImages,
|
||||
) {
|
||||
for (const image of container.querySelectorAll("img")) {
|
||||
if (image.classList.contains("txt-emoji")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const src =
|
||||
image.getAttribute(sourceAttribute) ?? image.getAttribute("src");
|
||||
if (!src) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const repoPath = resolveRepoPath(src, source.path);
|
||||
if (!repoPath) {
|
||||
const canonical = canonicalizeGithubImageUrl(src);
|
||||
if (canonical !== src) {
|
||||
image.setAttribute("src", canonical);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!source.sha) {
|
||||
continue;
|
||||
}
|
||||
|
||||
image.setAttribute(sourceAttribute, src);
|
||||
images
|
||||
.load(source.rid, source.sha, repoPath)
|
||||
.then(loaded => {
|
||||
if (!loaded || image.getAttribute(sourceAttribute) !== src) {
|
||||
return;
|
||||
}
|
||||
image.setAttribute("src", loaded.url);
|
||||
if (loaded.width && !image.hasAttribute("width")) {
|
||||
image.style.width = `${loaded.width}px`;
|
||||
} else {
|
||||
image.style.removeProperty("width");
|
||||
}
|
||||
})
|
||||
.catch(error => console.warn("Not able to load image", src, error));
|
||||
}
|
||||
|
||||
images.sweep();
|
||||
}
|
||||
@@ -322,7 +322,11 @@
|
||||
path={currentPath}
|
||||
onNavigate={showPath} />
|
||||
{:else}
|
||||
<Markdown content={blob.content} />
|
||||
<Markdown
|
||||
rid={repo.rid}
|
||||
path={currentPath}
|
||||
sha={oid}
|
||||
content={blob.content} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if blob.content.trim() === ""}
|
||||
|
||||
@@ -2,15 +2,74 @@ import type { RadiclePeer } from "@tests/support/peerManager.js";
|
||||
|
||||
import * as Fs from "node:fs/promises";
|
||||
import * as Path from "node:path";
|
||||
import * as Zlib from "node:zlib";
|
||||
|
||||
import { expect, test } from "@tests/support/fixtures.js";
|
||||
import { createRepo } from "@tests/support/repo.js";
|
||||
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length);
|
||||
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(Zlib.crc32(body));
|
||||
return Buffer.concat([length, body, crc]);
|
||||
}
|
||||
|
||||
// A blank PNG saved at 144 DPI, the way macOS saves Retina screenshots.
|
||||
function retinaPng(width: number, height: number): Buffer {
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(width, 0);
|
||||
header.writeUInt32BE(height, 4);
|
||||
header.set([8, 2, 0, 0, 0], 8);
|
||||
const density = Buffer.alloc(9);
|
||||
density.writeUInt32BE(5669, 0);
|
||||
density.writeUInt32BE(5669, 4);
|
||||
density[8] = 1;
|
||||
const rows = Buffer.alloc((width * 3 + 1) * height);
|
||||
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", header),
|
||||
chunk("pHYs", density),
|
||||
chunk("IDAT", Zlib.deflateSync(rows)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
const logo =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#08f"/></svg>\n';
|
||||
|
||||
async function createRichRepo(peer: RadiclePeer) {
|
||||
const { rid, repoFolder } = await createRepo(peer, { name: "rich" });
|
||||
await Fs.mkdir(Path.join(repoFolder, "images"));
|
||||
await Fs.mkdir(Path.join(repoFolder, "docs"));
|
||||
await Fs.writeFile(Path.join(repoFolder, "images", "logo.svg"), logo);
|
||||
await Fs.writeFile(
|
||||
Path.join(repoFolder, "images", "shot.png"),
|
||||
retinaPng(64, 8),
|
||||
);
|
||||
await Fs.writeFile(
|
||||
Path.join(repoFolder, "README.md"),
|
||||
["# Rich", "", "> [!NOTE]", "> Read the guide first.", ""].join("\n"),
|
||||
[
|
||||
"# Rich",
|
||||
"",
|
||||
"> [!NOTE]",
|
||||
"> Read the guide first.",
|
||||
"",
|
||||
// A `%` that doesn't start an escape must not stop the rest of the
|
||||
// document from being enhanced.
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
await Fs.writeFile(
|
||||
Path.join(repoFolder, "docs", "guide.adoc"),
|
||||
["= Guide", "", "image::../images/logo.svg[Guide logo]", ""].join("\n"),
|
||||
);
|
||||
await peer.git(["add", "."], { cwd: repoFolder });
|
||||
await peer.git(["commit", "-m", "Write a rich readme"], { cwd: repoFolder });
|
||||
@@ -18,7 +77,10 @@ async function createRichRepo(peer: RadiclePeer) {
|
||||
return { rid };
|
||||
}
|
||||
|
||||
test("render alerts in a readme", async ({ page, peer }) => {
|
||||
test("render alerts and repository images in a readme", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const { rid } = await createRichRepo(peer);
|
||||
await page.goto(`/repos/${rid}/home`);
|
||||
|
||||
@@ -26,4 +88,38 @@ test("render alerts in a readme", async ({ page, peer }) => {
|
||||
await expect(
|
||||
readme.locator(".alert-note").getByText("Read the guide first."),
|
||||
).toBeVisible();
|
||||
|
||||
const logoImage = readme.getByRole("img", { name: "Logo" });
|
||||
await expect(logoImage).toHaveAttribute("src", /^blob:/);
|
||||
await expect
|
||||
.poll(() =>
|
||||
logoImage.evaluate(image => (image as HTMLImageElement).naturalWidth),
|
||||
)
|
||||
.toBe(40);
|
||||
|
||||
const screenshot = readme.getByRole("img", { name: "Screenshot" });
|
||||
await expect(screenshot).toHaveAttribute("src", /^blob:/);
|
||||
await expect
|
||||
.poll(() =>
|
||||
screenshot.evaluate(image => image.getBoundingClientRect().width),
|
||||
)
|
||||
.toBe(32);
|
||||
});
|
||||
|
||||
test("show repository images in an AsciiDoc file", async ({ page, peer }) => {
|
||||
const { rid } = await createRichRepo(peer);
|
||||
await page.goto(`/repos/${rid}/home`);
|
||||
|
||||
await page.getByText("docs", { exact: true }).click();
|
||||
await page.getByText("guide.adoc", { exact: true }).click();
|
||||
|
||||
const image = page.locator(".asciidoc").getByRole("img", {
|
||||
name: "Guide logo",
|
||||
});
|
||||
await expect(image).toHaveAttribute("src", /^blob:/);
|
||||
await expect
|
||||
.poll(() =>
|
||||
image.evaluate(element => (element as HTMLImageElement).naturalWidth),
|
||||
)
|
||||
.toBe(40);
|
||||
});
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveRepoPath } from "@app/lib/asciidoc";
|
||||
|
||||
describe("resolveRepoPath", () => {
|
||||
test.each([
|
||||
["notes.txt", "README.adoc", "notes.txt"],
|
||||
["./notes.txt", "README.adoc", "notes.txt"],
|
||||
["guide.adoc", "docs/index.adoc", "docs/guide.adoc"],
|
||||
["../README.adoc", "docs/index.adoc", "README.adoc"],
|
||||
["../../README.adoc", "docs/index.adoc", "README.adoc"],
|
||||
["../notes.txt", "README.adoc", "notes.txt"],
|
||||
["/src/main.rs", "docs/index.adoc", "src/main.rs"],
|
||||
["my%20notes.txt", "README.adoc", "my notes.txt"],
|
||||
["notes.txt?plain=1", "README.adoc", "notes.txt"],
|
||||
["notes.txt#usage", "README.adoc", "notes.txt"],
|
||||
["docs/", "README.adoc", "docs/"],
|
||||
])("resolves %j from %j to %j", (href, from, expected) => {
|
||||
expect(resolveRepoPath(href, from)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["https://radicle.xyz", "README.adoc"],
|
||||
["//radicle.xyz/notes.txt", "README.adoc"],
|
||||
["mailto:alice@example.com", "README.adoc"],
|
||||
["javascript:alert(1)", "README.adoc"],
|
||||
["/", "README.adoc"],
|
||||
["..", "README.adoc"],
|
||||
])("leaves %j from %j outside the repository", (href, from) => {
|
||||
expect(resolveRepoPath(href, from)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
canonicalizeGithubImageUrl,
|
||||
enhanceRepoImages,
|
||||
imageMimeType,
|
||||
RepoImages,
|
||||
resolveRepoPath,
|
||||
} from "@app/lib/repoImages";
|
||||
|
||||
describe("resolveRepoPath", () => {
|
||||
test.each([
|
||||
["notes.txt", "README.adoc", "notes.txt"],
|
||||
["./notes.txt", "README.adoc", "notes.txt"],
|
||||
["guide.adoc", "docs/index.adoc", "docs/guide.adoc"],
|
||||
["../README.adoc", "docs/index.adoc", "README.adoc"],
|
||||
["../../README.adoc", "docs/index.adoc", "README.adoc"],
|
||||
["../notes.txt", "README.adoc", "notes.txt"],
|
||||
["/src/main.rs", "docs/index.adoc", "src/main.rs"],
|
||||
["my%20notes.txt", "README.adoc", "my notes.txt"],
|
||||
["notes.txt?plain=1", "README.adoc", "notes.txt"],
|
||||
["notes.txt#usage", "README.adoc", "notes.txt"],
|
||||
["docs/", "README.adoc", "docs/"],
|
||||
["50%.png", "README.md", "50%.png"],
|
||||
["docs/100%25.png", "README.md", "docs/100%.png"],
|
||||
])("resolves %j from %j to %j", (href, from, expected) => {
|
||||
expect(resolveRepoPath(href, from)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["https://radicle.xyz", "README.adoc"],
|
||||
["//radicle.xyz/notes.txt", "README.adoc"],
|
||||
["mailto:alice@example.com", "README.adoc"],
|
||||
["javascript:alert(1)", "README.adoc"],
|
||||
["data:image/png;base64,AAAA", "README.md"],
|
||||
["blob:tauri://localhost/1234", "README.md"],
|
||||
["/", "README.adoc"],
|
||||
["..", "README.adoc"],
|
||||
])("leaves %j from %j outside the repository", (href, from) => {
|
||||
expect(resolveRepoPath(href, from)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalizeGithubImageUrl", () => {
|
||||
test.each([
|
||||
[
|
||||
"https://github.com/radicle/app/blob/main/shot.png",
|
||||
"https://github.com/radicle/app/blob/main/shot.png?raw=true",
|
||||
],
|
||||
[
|
||||
"https://github.com/radicle/app/blob/main/shot.png?raw=true",
|
||||
"https://github.com/radicle/app/blob/main/shot.png?raw=true",
|
||||
],
|
||||
[
|
||||
"https://raw.githubusercontent.com/radicle/app/main/shot.png",
|
||||
"https://raw.githubusercontent.com/radicle/app/main/shot.png",
|
||||
],
|
||||
[
|
||||
"https://example.com/radicle/app/blob/main/shot.png",
|
||||
"https://example.com/radicle/app/blob/main/shot.png",
|
||||
],
|
||||
["shot.png", "shot.png"],
|
||||
])("rewrites %j to %j", (input, expected) => {
|
||||
expect(canonicalizeGithubImageUrl(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageMimeType", () => {
|
||||
test.each([
|
||||
["logo.svg", "image/svg+xml"],
|
||||
["docs/LOGO.SVG", "image/svg+xml"],
|
||||
["shot.png", "image/png"],
|
||||
])("of %j is %j", (path, expected) => {
|
||||
expect(imageMimeType(path)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each(["Makefile", "docs.v2/logo", "notes.txt", ""])(
|
||||
"of %j is unknown",
|
||||
path => {
|
||||
expect(imageMimeType(path)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function u32(n: number): number[] {
|
||||
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
|
||||
function chunk(type: string, data: number[]): number[] {
|
||||
const name = Array.from(type, c => c.charCodeAt(0));
|
||||
return [...u32(data.length), ...name, ...data, 0, 0, 0, 0];
|
||||
}
|
||||
|
||||
// A PNG `width` pixels wide, saved at 144 DPI like a Retina screenshot.
|
||||
function retinaPng(width: number): ArrayBuffer {
|
||||
return new Uint8Array([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
...chunk("IHDR", [...u32(width), ...u32(100), 8, 6, 0, 0, 0]),
|
||||
...chunk("pHYs", [...u32(5669), ...u32(5669), 1]),
|
||||
...chunk("IEND", []),
|
||||
]).buffer;
|
||||
}
|
||||
|
||||
let created: Blob[];
|
||||
let revoked: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
created = [];
|
||||
revoked = [];
|
||||
URL.createObjectURL = vi.fn((blob: Blob) => {
|
||||
created.push(blob);
|
||||
return `blob:test/${created.length}`;
|
||||
});
|
||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
});
|
||||
});
|
||||
|
||||
function fakeFetch() {
|
||||
return vi.fn(async (_rid: string, _sha: string, _path: string) => {
|
||||
return new Uint8Array([1, 2, 3]).buffer;
|
||||
});
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("RepoImages", () => {
|
||||
test("reads each file once", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const images = new RepoImages(fetch);
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
images.load("rad:a", "sha1", "logo.png"),
|
||||
images.load("rad:a", "sha1", "logo.png"),
|
||||
]);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(first).toEqual(second);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("reads the same path again at another revision", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const images = new RepoImages(fetch);
|
||||
|
||||
await images.load("rad:a", "sha1", "logo.png");
|
||||
await images.load("rad:a", "sha2", "logo.png");
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("types an SVG so the webview shows it", async () => {
|
||||
const images = new RepoImages(fakeFetch());
|
||||
|
||||
await images.load("rad:a", "sha1", "docs/logo.svg");
|
||||
|
||||
expect(created[0].type).toBe("image/svg+xml");
|
||||
});
|
||||
|
||||
test("sizes a Retina screenshot to its intended width", async () => {
|
||||
const images = new RepoImages(async () => retinaPng(3680));
|
||||
|
||||
const image = await images.load("rad:a", "sha1", "shot.png");
|
||||
|
||||
expect(image?.width).toBe(1840);
|
||||
});
|
||||
|
||||
test("sweeps the images not loaded since the last sweep", async () => {
|
||||
const images = new RepoImages(fakeFetch());
|
||||
|
||||
const old = await images.load("rad:a", "sha1", "old.png");
|
||||
const kept = await images.load("rad:a", "sha1", "kept.png");
|
||||
images.sweep();
|
||||
await images.load("rad:a", "sha1", "kept.png");
|
||||
images.sweep();
|
||||
|
||||
expect(revoked).toEqual([old?.url]);
|
||||
expect(revoked).not.toContain(kept?.url);
|
||||
});
|
||||
|
||||
test("frees an image released before it loaded", async () => {
|
||||
const pending = deferred<ArrayBuffer>();
|
||||
const images = new RepoImages(() => pending.promise);
|
||||
|
||||
const image = images.load("rad:a", "sha1", "logo.png");
|
||||
images.sweep();
|
||||
images.sweep();
|
||||
pending.resolve(new Uint8Array([1]).buffer);
|
||||
|
||||
expect(await image).toBeUndefined();
|
||||
expect(revoked).toEqual(["blob:test/1"]);
|
||||
});
|
||||
|
||||
test("tries a failed file again", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("not found"))
|
||||
.mockResolvedValue(new Uint8Array([1]).buffer);
|
||||
const images = new RepoImages(fetch);
|
||||
|
||||
await expect(images.load("rad:a", "sha1", "logo.png")).rejects.toThrow();
|
||||
await expect(
|
||||
images.load("rad:a", "sha1", "logo.png"),
|
||||
).resolves.toBeDefined();
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("frees every image when disposed, and those still loading", async () => {
|
||||
const pending = deferred<ArrayBuffer>();
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Uint8Array([1]).buffer)
|
||||
.mockReturnValueOnce(pending.promise);
|
||||
const images = new RepoImages(fetch);
|
||||
|
||||
const loaded = await images.load("rad:a", "sha1", "a.png");
|
||||
const loading = images.load("rad:a", "sha1", "b.png");
|
||||
images.dispose();
|
||||
pending.resolve(new Uint8Array([1]).buffer);
|
||||
|
||||
expect(await loading).toBeUndefined();
|
||||
expect(revoked).toEqual([loaded?.url, "blob:test/2"]);
|
||||
});
|
||||
});
|
||||
|
||||
function documentWith(html: string): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = html;
|
||||
return container;
|
||||
}
|
||||
|
||||
async function settled() {
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
}
|
||||
|
||||
describe("enhanceRepoImages", () => {
|
||||
test("shows repository images and rewrites GitHub page URLs", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const container = documentWith(
|
||||
[
|
||||
'<img src="../images/logo.svg">',
|
||||
'<img src="https://github.com/a/b/blob/main/c.png">',
|
||||
'<img src="https://example.com/d.png">',
|
||||
'<img class="txt-emoji" src="twemoji/1f600.svg">',
|
||||
].join(""),
|
||||
);
|
||||
|
||||
enhanceRepoImages(
|
||||
container,
|
||||
{ rid: "rad:a", sha: "sha1", path: "docs/README.md" },
|
||||
new RepoImages(fetch),
|
||||
);
|
||||
await settled();
|
||||
|
||||
const [logo, github, external, emoji] = container.querySelectorAll("img");
|
||||
expect(fetch).toHaveBeenCalledExactlyOnceWith(
|
||||
"rad:a",
|
||||
"sha1",
|
||||
"images/logo.svg",
|
||||
);
|
||||
expect(logo.getAttribute("src")).toBe("blob:test/1");
|
||||
expect(github.getAttribute("src")).toBe(
|
||||
"https://github.com/a/b/blob/main/c.png?raw=true",
|
||||
);
|
||||
expect(external.getAttribute("src")).toBe("https://example.com/d.png");
|
||||
expect(emoji.getAttribute("src")).toBe("twemoji/1f600.svg");
|
||||
});
|
||||
|
||||
test("leaves repository images alone without a revision", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const container = documentWith('<img src="logo.png">');
|
||||
|
||||
enhanceRepoImages(
|
||||
container,
|
||||
{ rid: "", sha: undefined, path: "" },
|
||||
new RepoImages(fetch),
|
||||
);
|
||||
await settled();
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe(
|
||||
"logo.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("a stray percent sign doesn't stop the other images", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const container = documentWith('<img src="50%.png"><img src="logo.png">');
|
||||
|
||||
enhanceRepoImages(
|
||||
container,
|
||||
{ rid: "rad:a", sha: "sha1", path: "README.md" },
|
||||
new RepoImages(fetch),
|
||||
);
|
||||
await settled();
|
||||
|
||||
expect(fetch.mock.calls.map(([, , path]) => path)).toEqual([
|
||||
"50%.png",
|
||||
"logo.png",
|
||||
]);
|
||||
});
|
||||
|
||||
test("reads the images again when the revision changes", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const images = new RepoImages(fetch);
|
||||
const container = documentWith('<img src="logo.png">');
|
||||
|
||||
enhanceRepoImages(
|
||||
container,
|
||||
{ rid: "rad:a", sha: "sha1", path: "README.md" },
|
||||
images,
|
||||
);
|
||||
await settled();
|
||||
enhanceRepoImages(
|
||||
container,
|
||||
{ rid: "rad:a", sha: "sha2", path: "README.md" },
|
||||
images,
|
||||
);
|
||||
await settled();
|
||||
|
||||
expect(fetch.mock.calls).toEqual([
|
||||
["rad:a", "sha1", "logo.png"],
|
||||
["rad:a", "sha2", "logo.png"],
|
||||
]);
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe(
|
||||
"blob:test/2",
|
||||
);
|
||||
expect(revoked).toEqual(["blob:test/1"]);
|
||||
});
|
||||
|
||||
test("an older revision loading late doesn't replace the newer one", async () => {
|
||||
const first = deferred<ArrayBuffer>();
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValue(new Uint8Array([2]).buffer);
|
||||
const images = new RepoImages(fetch);
|
||||
const container = documentWith('<img src="logo.png">');
|
||||
|
||||
enhanceRepoImages(
|
||||
container,
|
||||
{ rid: "rad:a", sha: "sha1", path: "README.md" },
|
||||
images,
|
||||
);
|
||||
enhanceRepoImages(
|
||||
container,
|
||||
{ rid: "rad:a", sha: "sha2", path: "README.md" },
|
||||
images,
|
||||
);
|
||||
await settled();
|
||||
first.resolve(new Uint8Array([1]).buffer);
|
||||
await settled();
|
||||
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe(
|
||||
"blob:test/1",
|
||||
);
|
||||
expect(revoked).toEqual(["blob:test/2"]);
|
||||
});
|
||||
|
||||
test("shows a Retina screenshot at its intended width", async () => {
|
||||
const container = documentWith('<img src="shot.png">');
|
||||
|
||||
enhanceRepoImages(
|
||||
container,
|
||||
{ rid: "rad:a", sha: "sha1", path: "README.md" },
|
||||
new RepoImages(async () => retinaPng(3680)),
|
||||
);
|
||||
await settled();
|
||||
|
||||
expect(container.querySelector("img")?.style.width).toBe("1840px");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user