feat render image files as inline images in file views

Image files (PNG, JPG, JPEG, GIF, SVG, WebP, ICO, BMP, AVIF) are now displayed as <img> elements pointing to the raw file URL instead of being shown as syntax-highlighted source. The raw file handler now resolves the trail to determine the filename and serves images with correct MIME types (critical for SVG rendering). On mobile, images scale to full viewport width; on desktop they are capped at 80% of the container.

Commit
f66e24127365b6c1b1fe26f06ec0d9e870f50663
Author
Claude Sonnet 4 <claude@anthropic.invalid>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/handlers.ml
index 3f76152e..64eaa6c2 100644..100644
@@ -251,13 +251,31 @@
251 251 Views.Repo.files context trail nodes
252 252 | `Blob blob -> Views.Repo.file context trail blob
253 253
254 Added: let mime_of_filename filename =
255 Added: match Filename.extension filename |> String.lowercase_ascii with
256 Added: | ".png" -> "image/png"
257 Added: | ".jpg" | ".jpeg" -> "image/jpeg"
258 Added: | ".gif" -> "image/gif"
259 Added: | ".svg" -> "image/svg+xml"
260 Added: | ".webp" -> "image/webp"
261 Added: | ".ico" -> "image/x-icon"
262 Added: | ".bmp" -> "image/bmp"
263 Added: | ".avif" -> "image/avif"
264 Added: | _ -> "text/plain; charset=utf-8"
265 Added:
254 266 let raw_file repository _context id =
267 Added: let* trail = Resolvers.Tree.find_path repository id in
255 268 let* object_ = Resolvers.blob_or_tree repository id in
256 269 match object_ with
257 270 | `Blob blob ->
271 Added: let content_type =
272 Added: match List.rev trail with
273 Added: | (name, _) :: _ -> mime_of_filename name
274 Added: | [] -> "text/plain; charset=utf-8"
275 Added: in
258 276 Lwt.return
259 277 (Dream.response
260 Removed: ~headers:[ ("Content-Type", "text/plain; charset=utf-8") ]
278 Added: ~headers:[ ("Content-Type", content_type) ]
261 279 blob.content)
262 280 | `Tree _ ->
263 281 error_response (Resolvers.Bad_request "object is a tree, not a blob")
lib/static/styles.css
index f75e66bc..4fc66399 100644..100644
@@ -1223,3 +1223,22 @@
1223 1223 .error-nav {
1224 1224 margin-top: 2em;
1225 1225 }
1226 Added:
1227 Added: /* Image file preview */
1228 Added:
1229 Added: .image-preview {
1230 Added: display: flex;
1231 Added: justify-content: center;
1232 Added: padding: 1em;
1233 Added: }
1234 Added:
1235 Added: .file-image {
1236 Added: max-width: 100%;
1237 Added: height: auto;
1238 Added: }
1239 Added:
1240 Added: @media (min-width: 768px) {
1241 Added: .file-image {
1242 Added: max-width: 80%;
1243 Added: }
1244 Added: }
lib/views/repo.ml
index 473c2462..37a19ca3 100644..100644
@@ -277,6 +277,13 @@
277 277 ~toolbar:[ path_trail context.repo trail ]
278 278 [ Ui.items_of ~id:"file-tree" (tree_row context.repo) entries ]
279 279
280 Added: let is_image_filename filename =
281 Added: match Filename.extension filename |> String.lowercase_ascii with
282 Added: | ".png" | ".jpg" | ".jpeg" | ".gif" | ".svg" | ".webp" | ".ico" | ".bmp"
283 Added: | ".avif" ->
284 Added: true
285 Added: | _ -> false
286 Added:
280 287 let file context trail (blob : Resolvers.Blob.t) =
281 288 let filename =
282 289 match List.rev trail with (name, _) :: _ -> Some name | [] -> None
@@ -290,6 +297,14 @@
290 297 in
291 298 let body =
292 299 match filename with
300 Added: | Some filename when is_image_filename filename ->
301 Added: let src =
302 Added: match List.rev trail with
303 Added: | (_, hash) :: _ -> Components.url (Raw_file (context.repo, hash))
304 Added: | [] -> ""
305 Added: in
306 Added: Ui.block ~class_:"image-preview"
307 Added: [ Ui.image ~class_:"file-image" ~alt:filename ~src () ]
293 308 | Some filename when Readme.is_readme_filename filename ->
294 309 Readme.render ~filename blob.content
295 310 | _ ->