feat(markdown): wrap titled images in figures with caption
This commit is contained in:
+40
-1
@@ -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 (``) 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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 " || "") + ")";
|
var title = node.getAttribute("title") || "";
|
||||||
|
var imgMd = " || "") + ")";
|
||||||
|
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("");
|
insertIntoTextarea("");
|
||||||
} 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user