feat(markdown): wrap titled images in figures with caption

This commit is contained in:
2026-07-12 10:24:45 +02:00
parent 1dbeb66be0
commit 6ee8e066aa
5 changed files with 80 additions and 26 deletions
+40 -1
View File
@@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "kramdown" require "kramdown"
require "rexml/document"
module Volumen module Volumen
# Renders Markdown to HTML using kramdown's GFM parser (tables, fenced code, # Renders Markdown to HTML using kramdown's GFM parser (tables, fenced code,
@@ -8,6 +9,10 @@ module Volumen
# #
# Posts are authored by the single trusted admin, so kramdown's default # Posts are authored by the single trusted admin, so kramdown's default
# behaviour of passing raw HTML through is intentional. # behaviour of passing raw HTML through is intentional.
#
# Images with a title (`![alt](url "caption")`) are wrapped in a `<figure>`
# element with a `<figcaption>` so the frontend can style captions and
# overlay an info icon.
module Markdown module Markdown
OPTIONS = { OPTIONS = {
input: "GFM", input: "GFM",
@@ -17,7 +22,41 @@ module Volumen
}.freeze }.freeze
def self.render(text) def self.render(text)
::Kramdown::Document.new(text.to_s, OPTIONS).to_html html = ::Kramdown::Document.new(text.to_s, OPTIONS).to_html
wrap_figures(html)
end
# Wrap every `<img title="...">` inside a `<figure>` with `<figcaption>`.
# Images without a title are left untouched.
def self.wrap_figures(html)
doc = REXML::Document.new("<root>#{html}</root>")
REXML::XPath.each(doc, "//img[@title]") do |img|
caption = img.attributes["title"]
img.attributes.delete("title")
figure = REXML::Element.new("figure")
figure.add_element(img.deep_clone)
info = REXML::Element.new("div")
info.add_attribute("class", "fig-info")
info.add_attribute("aria-hidden", "true")
info.text = "i"
figure.add_element(info)
figcaption = REXML::Element.new("figcaption")
figcaption.text = caption
figure.add_element(figcaption)
img.parent.replace_child(img, figure)
end
# Strip the wrapping <root> we added.
result = +""
doc.root.children.each { |child| result << child.to_s }
result
rescue REXML::ParseException
html
end end
end end
end end
+5
View File
@@ -58,6 +58,10 @@ module Volumen
metadata["cover"] metadata["cover"]
end end
def cover_alt
metadata["cover_alt"]
end
def fediverse_creator def fediverse_creator
value = metadata["fediverse_creator"] value = metadata["fediverse_creator"]
return nil if value.nil? return nil if value.nil?
@@ -128,6 +132,7 @@ module Volumen
"author" => author, "author" => author,
"fediverse_creator" => fediverse_creator, "fediverse_creator" => fediverse_creator,
"cover" => cover, "cover" => cover,
"cover_alt" => cover_alt,
"reading_time" => reading_time, "reading_time" => reading_time,
"translations" => translations, "translations" => translations,
"url" => "/api/volumen/posts/#{slug}" "url" => "/api/volumen/posts/#{slug}"
+9 -4
View File
@@ -376,7 +376,14 @@ module Volumen
end end
def post_from_params(http_params, existing: nil) def post_from_params(http_params, existing: nil)
metadata = { metadata = base_metadata(http_params, existing: existing)
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
post.path = existing.path if existing
post
end
def base_metadata(http_params, existing:)
{
"title" => presence(http_params["title"]), "title" => presence(http_params["title"]),
"slug" => presence(http_params["slug"]) || existing&.slug, "slug" => presence(http_params["slug"]) || existing&.slug,
"lang" => presence(http_params["lang"]), "lang" => presence(http_params["lang"]),
@@ -387,12 +394,10 @@ module Volumen
"tags" => parse_tags(http_params["tags"]), "tags" => parse_tags(http_params["tags"]),
"excerpt" => presence(http_params["excerpt"]), "excerpt" => presence(http_params["excerpt"]),
"cover" => presence(http_params["cover"]), "cover" => presence(http_params["cover"]),
"cover_alt" => presence(http_params["cover_alt"]),
"draft" => http_params["draft"] == "on", "draft" => http_params["draft"] == "on",
"all_langs" => http_params["all_langs"] == "on" "all_langs" => http_params["all_langs"] == "on"
} }
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
post.path = existing.path if existing
post
end end
def fediverse_creator_error(post) def fediverse_creator_error(post)
+20 -15
View File
@@ -46,6 +46,8 @@
<button type="button" id="cover-upload">Upload</button> <button type="button" id="cover-upload">Upload</button>
<button type="button" id="cover-clear">Clear</button> <button type="button" id="cover-clear">Clear</button>
</div> </div>
<input type="text" id="cover-alt-input" name="cover_alt" value="<%= h(@post.cover_alt) %>"
placeholder="ALT text (describe the image for accessibility)">
<img id="cover-preview" class="cover-preview" alt="" hidden> <img id="cover-preview" class="cover-preview" alt="" hidden>
<input type="file" id="cover-file" accept="image/*" hidden> <input type="file" id="cover-file" accept="image/*" hidden>
</div> </div>
@@ -98,7 +100,7 @@
</div> </div>
<input type="file" id="image-input" accept="image/*" hidden> <input type="file" id="image-input" accept="image/*" hidden>
<button type="submit" class="primary">Save</button> <button type="submit" class="primary">Save</button>
</form> </form>
<script> <script>
@@ -149,7 +151,10 @@
if (tag === "code") { return "`" + node.textContent + "`"; } if (tag === "code") { return "`" + node.textContent + "`"; }
if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; } if (tag === "a") { return "[" + inner + "](" + (node.getAttribute("href") || "") + ")"; }
if (tag === "img") { if (tag === "img") {
return "![" + (node.getAttribute("alt") || "") + "](" + (node.getAttribute("src") || "") + ")"; var title = node.getAttribute("title") || "";
var imgMd = "![" + (node.getAttribute("alt") || "") + "](" + (node.getAttribute("src") || "") + ")";
if (title) { imgMd = imgMd.replace(")", ' "' + title + '")'); }
return imgMd;
} }
if (tag === "br") { return "\n"; } if (tag === "br") { return "\n"; }
return inner; return inner;
@@ -220,17 +225,17 @@
} }
imageInput.addEventListener("change", function () { imageInput.addEventListener("change", function () {
var file = imageInput.files[0]; var file = imageInput.files[0];
if (!file) { return; } if (!file) { return; }
uploadImage(file, function (url) { uploadImage(file, function (url) {
if (visualView.hidden) { if (visualView.hidden) {
insertIntoTextarea("![](" + url + ")"); insertIntoTextarea("![](" + url + ")");
} else { } else {
exec("insertImage", url); exec("insertImage", url);
} }
}); });
imageInput.value = ""; imageInput.value = "";
}); });
// --- Cover image ------------------------------------------------------- // --- Cover image -------------------------------------------------------
@@ -469,6 +474,6 @@
}); });
// Start in Markdown mode (primary). // Start in Markdown mode (primary).
applyPreviewPreference(); applyPreviewPreference();
})(); })();
</script> </script>
+6 -6
View File
@@ -218,13 +218,13 @@
box-shadow: none; } box-shadow: none; }
.markdown th, .markdown td { border: 1px solid var(--border); padding: 0.45rem 0.7rem; text-align: left; } .markdown th, .markdown td { border: 1px solid var(--border); padding: 0.45rem 0.7rem; text-align: left; }
.markdown thead th { background: var(--accent-weak); color: var(--fg); } .markdown thead th { background: var(--accent-weak); color: var(--fg); }
.markdown tbody tr:hover td { background: transparent; } .markdown tbody tr:hover td { background: transparent; }
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; } .markdown hr { border: none; border-top: 1px solid var(--border); margin: 1.4em 0; }
.markdown img { max-width: 100%; border-radius: 8px; } .markdown img { max-width: 100%; border-radius: 8px; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { transition: none !important; } * { transition: none !important; }
} }
</style> </style>
</head> </head>
<body> <body>