Skip to content
Open
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
12 changes: 12 additions & 0 deletions assets/css/milkdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
@import "@milkdown/crepe/theme/common/latex.css";
@import "@milkdown/crepe/theme/frame.css";

/* Image alt text input */
.milkdown .milkdown-image-block > .image-alt-input {
display: block;
width: 100%;
text-align: center;
color: var(--crepe-color-on-background);
margin: 4px auto;
font-family: var(--crepe-font-default);
font-size: 14px;
padding: 4px 8px;
}

/* Unwrap '@milkdown/crepe/theme/common/reset.css' and remove some styling */
.milkdown {
position: relative;
Expand Down
122 changes: 111 additions & 11 deletions assets/js/milkdown.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Crepe } from "@milkdown/crepe";
import { $nodeSchema } from "@milkdown/utils";
import { $nodeSchema, $prose } from "@milkdown/utils";
import { Plugin, PluginKey } from "@milkdown/prose/state";

const csrfToken = document
.querySelector("meta[name='csrf-token']")
Expand All @@ -23,7 +24,7 @@ const blockEditOpts = {
const uploadUrl = new URL("upload-image", window.location.href).href;

// Create custom image block schema plugin
// Reuses caption input as alt text to get `[alt](url)` format for markdown
// Outputs `![alt](url "caption")` format for markdown
// https://github.com/Milkdown/milkdown/blob/main/packages/components/src/image-block/schema.ts
const customImageBlockPlugin = $nodeSchema("image-block", () => ({
inline: false,
Expand All @@ -36,6 +37,7 @@ const customImageBlockPlugin = $nodeSchema("image-block", () => ({
priority: 100,
attrs: {
src: { default: "", validate: "string" },
alt: { default: "", validate: "string" },
caption: { default: "", validate: "string" },
ratio: { default: 1, validate: "number" },
},
Expand All @@ -48,6 +50,7 @@ const customImageBlockPlugin = $nodeSchema("image-block", () => ({

return {
src: dom.getAttribute("src") || "",
alt: dom.getAttribute("alt") || "",
caption: dom.getAttribute("caption") || "",
ratio: 1,
};
Expand All @@ -62,32 +65,129 @@ const customImageBlockPlugin = $nodeSchema("image-block", () => ({
match: ({ type }) => type === "image-block",
runner: (state, node, type) => {
const src = node.url;
const caption = node.alt;
const ratio = 1; // Always keep ratio as 1
const alt = node.alt;
const caption = node.title;

state.addNode(type, {
src,
alt,
caption,
ratio,
ratio: 1,
});
},
},

// Custom markdown output - standard format
// Custom markdown output - ![alt](url "caption")
toMarkdown: {
match: (node) => node.type.name === "image-block",
runner: (state, node) => {
state.openNode("paragraph");
state.addNode("image", undefined, undefined, {
title: "",
title: node.attrs.caption,
url: node.attrs.src,
alt: node.attrs.caption,
alt: node.attrs.alt,
});
state.closeNode();
},
},
}));

// Plugin to add alt text input to image blocks in the editor
const imageAltPlugin = $prose(() => {
return new Plugin({
key: new PluginKey("image-alt-input"),
view(editorView) {
const managedBlocks = new WeakMap();

const sync = () => {
const blocks = editorView.dom.querySelectorAll(".milkdown-image-block");

for (const block of blocks) {
// Already managed — just sync value when not focused
const altInput = managedBlocks.get(block);
if (altInput && block.contains(altInput)) {
if (document.activeElement !== altInput) {
try {
const pos = editorView.posAtDOM(block, 0);
const node = editorView.state.doc.nodeAt(pos);
if (node) {
altInput.value = node.attrs.alt || "";
}
} catch (e) {
console.warn("image-alt-input: position lookup failed", e);
}
}
continue;
}

// Create alt input
const input = document.createElement("input");
input.className = "image-alt-input";
input.placeholder = "Enter image alt";
input.type = "text";

// Set initial value
try {
const pos = editorView.posAtDOM(block, 0);
const node = editorView.state.doc.nodeAt(pos);
if (node) {
input.value = node.attrs.alt || "";
}
} catch (e) {
console.warn("image-alt-input: initial value lookup failed", e);
}

const saveValue = () => {
try {
const pos = editorView.posAtDOM(block, 0);
const node = editorView.state.doc.nodeAt(pos);
if (node && node.type.name === "image-block") {
const tr = editorView.state.tr.setNodeMarkup(pos, undefined, {
...node.attrs,
alt: input.value,
});
editorView.dispatch(tr);
}
} catch (e) {
console.warn("image-alt-input: save failed", e);
}
};

// Debounced save on input, immediate save on blur
let timer = 0;
input.addEventListener("input", (e) => {
e.stopPropagation();
clearTimeout(timer);
timer = setTimeout(saveValue, 1000);
});
input.addEventListener("blur", () => {
clearTimeout(timer);
saveValue();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
input.blur();
}
e.stopPropagation();
});
input.addEventListener("keypress", (e) => e.stopPropagation());

block.appendChild(input);
managedBlocks.set(block, input);
}
};

return {
update() {
sync();
},
destroy() {},
};
},
});
});

const imageBlockOpts = {
onUpload: async (file) => {
// Create FormData for file upload
Expand All @@ -110,7 +210,7 @@ const imageBlockOpts = {

throw new Error(`Upload failed: ${response.statusText}`);
},
blockCaptionPlaceholderText: "Enter image alt",
blockCaptionPlaceholderText: "Enter image caption",
};

const MilkdownEditor = (element) => {
Expand Down Expand Up @@ -142,8 +242,8 @@ const MilkdownEditor = (element) => {
},
});

// Add custom image block plugin before creating
crepe.editor.use(customImageBlockPlugin);
// Add custom image block plugins before creating
crepe.editor.use(customImageBlockPlugin).use(imageAltPlugin);

crepe.create().then(() => {
crepe.on((listener) => {
Expand Down
15 changes: 11 additions & 4 deletions lib/literature/components/image_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,16 @@ defmodule Literature.ImageComponent do
def parse_image_tag({"img", _, _} = tag, img_str \\ nil) do
case get_img_size(tag) do
{width, height} ->
caption = find_img_attribute(tag, "title") || find_img_attribute(tag, "caption")
src = find_img_attribute(tag, "src")
alt = find_img_attribute(tag, "alt")

~s"""
<picture>
<source srcset="#{load_srcset(:jpg, find_img_attribute(tag, "src"))}"/>
<source srcset="#{load_srcset(:webp, find_img_attribute(tag, "src"))}"/>
<img src="#{find_img_attribute(tag, "src")}" alt="#{find_img_attribute(tag, "alt")}" width="#{width}" height="#{height}" loading="lazy" />
<figcaption style="font-style: italic;";>#{find_img_attribute(tag, "caption")}</figcaption>
<source srcset="#{load_srcset(:jpg, src)}"/>
<source srcset="#{load_srcset(:webp, src)}"/>
<img src="#{escape(src)}" alt="#{escape(alt)}" width="#{width}" height="#{height}" loading="lazy" />
<figcaption style="font-style: italic;">#{escape(caption)}</figcaption>
</picture>
"""

Expand Down Expand Up @@ -94,6 +98,9 @@ defmodule Literature.ImageComponent do
Helpers.get_dimension(file_name)
end

defp escape(nil), do: ""
defp escape(value), do: value |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()

defp find_img_attribute(tag, attr) do
[tag]
|> Floki.attribute(attr)
Expand Down
16 changes: 14 additions & 2 deletions test/literature/components/image_component_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ defmodule Literature.ImageComponentTest do
<picture>
<source srcset=\"/path/to/image-w100.jpg 100w, /path/to/image-w200.jpg 200w, /path/to/image-w300.jpg 300w\"/>
<source srcset=\"/path/to/image-w100.webp 100w, /path/to/image-w200.webp 200w, /path/to/image-w300.webp 300w\"/>
<img src=\"/path/to/image-w300x453.jpg\" alt=\"An image's test\" width=\"300\" height=\"453\" loading=\"lazy\" />
<figcaption style="font-style: italic;";>An image's test</figcaption>
<img src=\"/path/to/image-w300x453.jpg\" alt=\"An image&#39;s test\" width=\"300\" height=\"453\" loading=\"lazy\" />
<figcaption style="font-style: italic;">An image&#39;s test</figcaption>
</picture>
"""

Expand All @@ -31,6 +31,18 @@ defmodule Literature.ImageComponentTest do
assert ImageComponent.parse_image_tag(tag) == tag
end

test "it escapes alt and caption attributes to prevent XSS" do
tag =
~s[<img src="/path/to/image-w300x453.jpg" alt="<script>alert('xss')</script>" caption="<img onerror=alert(1)>">]

result = ImageComponent.parse_image_tag(tag)

refute result =~ "<script>alert"
refute result =~ "<img onerror="
assert result =~ ~s[alt="&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"]
assert result =~ ~s[&lt;img onerror=alert(1)&gt;]
end

test "it does not convert image tags if only part of the string matches" do
tag =
"<img src=https://images.martide.com/en-employers/2022/07/aframax-tanker-x1-200.jpg alt='Image' />"
Expand Down
Loading