fix: code-in-table CSS sizing + markdown image rendering (#486, #487)

- static/style.css: add td code / th code rules (font-size 0.85em,
  padding 1px 4px, vertical-align baseline) for both .msg-body and
  .preview-md to fix cramped inline code in table cells (#486)

- static/ui.js inlineMd(): add image pass (![alt](url) → <img
  class=msg-media-img>) running while _code_stash is active (protects
  image syntax inside backticks), add _img_stash (\x00G) to shield
  rendered <img> src= from autolink, add img to SAFE_INLINE (#487)

- static/ui.js renderMd() outer: add image pass before outer link pass
  for images in plain paragraphs, add img to SAFE_TAGS allowlist (#487)

- tests/test_issue486_487.py: 45 new tests covering CSS source checks,
  JS source structure, rendering behaviour, and combination edge cases
  (code + image + link in same table cell, image inside code span, etc.)

Closes #486, closes #487
This commit is contained in:
Hermes Agent
2026-04-14 21:52:34 +00:00
parent 75de03c99f
commit 887893ecd1
5 changed files with 640 additions and 4 deletions

View File

@@ -1,5 +1,52 @@
# Hermes Web UI -- Changelog # Hermes Web UI -- Changelog
## [v0.50.44] fix: code-in-table CSS sizing + markdown image rendering (#486, #487)
**CSS: inline code inside table cells** (fixes #486)
Inline `` `code` `` spans inside `<td>` and `<th>` cells were rendering too
large relative to the cell height — the `.msg-body code` rule sets `12.5px`
which sits awkward against the table's `12px` base font.
Fix: added two targeted rules in `static/style.css`:
.msg-body td code,.msg-body th code { font-size:0.85em; padding:1px 4px; vertical-align:baseline; }
.preview-md td code,.preview-md th code { font-size:0.85em; padding:1px 4px; vertical-align:baseline; }
Covers both the chat message surface (`.msg-body`) and the markdown preview
panel (`.preview-md`).
**JS renderer: `![alt](url)` image syntax** (fixes #487)
Standard markdown image syntax was not handled by `renderMd()`. The `!` was
left as a stray character and `[alt](url)` was consumed by the link pass,
producing `! <a href="url">alt</a>` instead of an `<img>`.
Fix: added an image pass to both `inlineMd()` (for images in table cells,
list items, blockquotes, headings) and the outer `renderMd()` pipeline (for
images in plain paragraphs):
- Regex: `![alt](https?://url)` — only `http://` and `https://` URIs accepted;
`javascript:` and `data:` URIs cannot match.
- Alt text passes through `esc()` — XSS-safe.
- URL double-quotes percent-encoded to `%22` — attribute breakout prevented.
- Reuses `.msg-media-img` class — same click-to-zoom and max-width styling as
agent-emitted `MEDIA:` images.
- `img` added to `SAFE_TAGS` allowlist so the generated `<img>` is not escaped.
- In `inlineMd()`: image pass runs while the `_code_stash` is still active,
so `![alt](url)` inside a backtick span stays protected and is never rendered
as an image. A new `_img_stash` (`\x00G`) protects rendered `<img>` tags
from the autolink pass touching `src=` values.
**Tests**
45 new tests in `tests/test_issue486_487.py`:
- 13 CSS source checks and rendering tests for #486
- 22 JS source checks and rendering tests for #487
- 10 combination edge cases (code + image + link all in same table)
- Total tests: 1195 (was 1150)
## [v0.50.43] fix: markdown link rendering + KaTeX CSP fonts ## [v0.50.43] fix: markdown link rendering + KaTeX CSP fonts
**Markdown link rendering — `renderMd()` in `static/ui.js`** (PR #475, fixes #470) **Markdown link rendering — `renderMd()` in `static/ui.js`** (PR #475, fixes #470)

View File

@@ -536,7 +536,7 @@
<div class="settings-section-title">System</div> <div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div> <div class="settings-section-meta">Instance version and access controls.</div>
</div> </div>
<span class="settings-version-badge">v0.50.43</span> <span class="settings-version-badge">v0.50.44</span>
</div> </div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px"> <div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label> <label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -430,6 +430,8 @@
.msg-body th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);} .msg-body th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
.msg-body td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);} .msg-body td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);}
.msg-body tr:nth-child(even){background:rgba(255,255,255,.03);} .msg-body tr:nth-child(even){background:rgba(255,255,255,.03);}
/* #486: inline code inside table cells needs scaled sizing to avoid overflow/clipping */
.msg-body td code,.msg-body th code{font-size:0.85em;padding:1px 4px;vertical-align:baseline;}
/* KaTeX math rendering */ /* KaTeX math rendering */
.katex-block{display:block;text-align:center;margin:12px 0;overflow-x:auto;} .katex-block{display:block;text-align:center;margin:12px 0;overflow-x:auto;}
.katex-inline{display:inline;} .katex-inline{display:inline;}
@@ -589,6 +591,8 @@
.preview-md th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);} .preview-md th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
.preview-md td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);} .preview-md td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);}
.preview-md tr:nth-child(even){background:rgba(255,255,255,.03);} .preview-md tr:nth-child(even){background:rgba(255,255,255,.03);}
/* #486: inline code inside table cells needs scaled sizing to avoid overflow/clipping */
.preview-md td code,.preview-md th code{font-size:0.85em;padding:1px 4px;vertical-align:baseline;}
/* File type badge in preview path bar */ /* File type badge in preview path bar */
.preview-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;margin-left:8px;text-transform:uppercase;letter-spacing:.06em;} .preview-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;margin-left:8px;text-transform:uppercase;letter-spacing:.06em;}
.preview-badge.img{background:rgba(124,185,255,.15);color:var(--blue);} .preview-badge.img{background:rgba(124,185,255,.15);color:var(--blue);}

View File

@@ -465,15 +465,24 @@ function renderMd(raw){
t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`<strong><em>${esc(x)}</em></strong>`); t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`<strong><em>${esc(x)}</em></strong>`);
t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`<strong>${esc(x)}</strong>`); t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`<strong>${esc(x)}</strong>`);
t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`<em>${esc(x)}</em>`); t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`<em>${esc(x)}</em>`);
// #487: Image pass — runs while code stash is active so ![x](url) inside
// backticks stays protected as a \x00C token and is never rendered as <img>.
// Must run before _code_stash restore and before _link_stash so the image
// is not consumed by the [label](url) link regex.
t=t.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`);
// Stash rendered <img> tags so autolink never matches URLs inside src=
const _img_stash=[];
t=t.replace(/(<img\b[^>]*>)/g,m=>{_img_stash.push(m);return `\x00G${_img_stash.length-1}\x00`;});
t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]); t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]);
// Stash [label](url) links before autolink so the URL in href= is not re-linked // Stash [label](url) links before autolink so the URL in href= is not re-linked
const _link_stash=[]; const _link_stash=[];
t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`<a href="${u.replace(/"/g,'%22')}" target="_blank" rel="noopener">${esc(lb)}</a>`);return `\x00L${_link_stash.length-1}\x00`;}); t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`<a href="${u.replace(/"/g,'%22')}" target="_blank" rel="noopener">${esc(lb)}</a>`);return `\x00L${_link_stash.length-1}\x00`;});
t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `<a href="${clean}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;}); t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `<a href="${clean}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;});
t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]); t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]);
t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]);
// Escape any plain text that isn't already wrapped in a tag we produced // Escape any plain text that isn't already wrapped in a tag we produced
// by escaping bare < > that aren't part of our own tags // by escaping bare < > that are not part of our own tags
const SAFE_INLINE=/^<\/?(strong|em|code|a)([\s>]|$)/i; const SAFE_INLINE=/^<\/?(strong|em|code|a|img)([\s>]|$)/i;
t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag)); t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag));
return t; return t;
} }
@@ -523,6 +532,10 @@ function renderMd(raw){
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join(''); const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`; return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
}); });
// #487: Outer image pass — handles ![alt](url) in plain paragraphs (outside tables/lists).
// Runs AFTER the table pass (images in table cells are handled by inlineMd() above).
// Runs BEFORE the outer [label](url) link pass so the image is not consumed as a plain link.
s=s.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>`<img src="${url.replace(/"/g,'%22')}" alt="${esc(alt)}" class="msg-media-img" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`);
// Outer link pass for labeled links in plain paragraphs (outside table cells). // Outer link pass for labeled links in plain paragraphs (outside table cells).
// Runs AFTER the table pass so table cells are processed by inlineMd() only. // Runs AFTER the table pass so table cells are processed by inlineMd() only.
// Stash existing <a> tags first to avoid re-linking already-linked URLs. // Stash existing <a> tags first to avoid re-linking already-linked URLs.
@@ -534,7 +547,7 @@ function renderMd(raw){
// Our pipeline only emits: <strong>,<em>,<code>,<pre>,<h1-6>,<ul>,<ol>,<li>, // Our pipeline only emits: <strong>,<em>,<code>,<pre>,<h1-6>,<ul>,<ol>,<li>,
// <table>,<thead>,<tbody>,<tr>,<th>,<td>,<hr>,<blockquote>,<p>,<br>,<a>, // <table>,<thead>,<tbody>,<tr>,<th>,<td>,<hr>,<blockquote>,<p>,<br>,<a>,
// <div class="..."> (mermaid/pre-header). Everything else is untrusted input. // <div class="..."> (mermaid/pre-header). Everything else is untrusted input.
const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div|span)([\s>]|$)/i; const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|img|div|span)([\s>]|$)/i;
s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag)); s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));
// Autolink: convert plain URLs to clickable links. // Autolink: convert plain URLs to clickable links.
// Stash existing <a> tags first so we never re-link a URL already inside href="...". // Stash existing <a> tags first so we never re-link a URL already inside href="...".

572
tests/test_issue486_487.py Normal file
View File

@@ -0,0 +1,572 @@
"""
Tests for issue #486 (CSS: inline code in table cells) and
issue #487 (JS renderer: markdown image syntax not implemented).
Issue #486 — CSS fix in static/style.css:
Inline `code` spans inside table cells render with awkward sizing.
Fix: td code, th code { font-size: 0.85em; padding: 1px 4px; vertical-align: baseline; }
Issue #487 — JS fix in static/ui.js:
![alt](url) image syntax not handled — renders as stray ! + link.
Fix: add image pass to renderMd() (before link pass) and inlineMd()
reusing the .msg-media-img class.
Strategy:
- Source-level checks verify the fixes are present in the JS/CSS.
- Python mirror tests verify the rendering logic with exhaustive edge cases,
especially code blocks inside tables (the specific case Nathan flagged).
"""
import pathlib
import re
import html as _html
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text()
# ── Helpers ───────────────────────────────────────────────────────────────────
def esc(s):
return _html.escape(str(s), quote=True)
def inline_md(t):
"""
Python mirror of the fixed inlineMd() function — includes:
- _code_stash (protects backtick spans from bold/italic AND from image pass)
- image pass (NEW for #487 — runs while code stash is active, before link pass)
- _img_stash (protects rendered img tags from autolink touching src=)
- _link_stash (protects links from autolink)
- autolink
- code stash restore (after autolink, so code content is never autolinked)
Correct operation order:
1. code stash — \x00C protects `...` from bold and image pass
2. bold/italic — runs on plain text only
3. image pass — runs while code content is still stashed (so ![x](url)
inside backticks stays protected as a \x00C token)
4. img stash — \x00I protects <img src="url"> from autolink
5. link stash — \x00L protects [label](url) links from autolink
6. autolink — only matches URLs not already in a stash token
7. link stash restore
8. img stash restore
9. code stash restore — restores <code> tags last
"""
# 1. Code stash — must be first to protect code content from all subsequent passes
code_stash = []
def stash_code(m):
code_stash.append(f'<code>{esc(m.group(1))}</code>')
return f'\x00C{len(code_stash)-1}\x00'
t = re.sub(r'`([^`\n]+)`', stash_code, t)
# 2. Bold/italic (code content is safely stashed)
t = re.sub(r'\*\*\*(.+?)\*\*\*', lambda m: f'<strong><em>{esc(m.group(1))}</em></strong>', t)
t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'<strong>{esc(m.group(1))}</strong>', t)
t = re.sub(r'\*([^*\n]+)\*', lambda m: f'<em>{esc(m.group(1))}</em>', t)
# 3. Image pass (NEW — runs while code is still stashed, so ![x](url) inside
# backticks is protected as a \x00C token and won't match here)
def render_image(m):
alt, url = m.group(1), m.group(2)
safe_url = url.replace('"', '%22')
return (f'<img src="{safe_url}" alt="{esc(alt)}" '
f'class="msg-media-img" loading="lazy" '
f'onclick="this.classList.toggle(\'msg-media-img--full\')">')
t = re.sub(r'!\[([^\]]*)\]\((https?://[^\)]+)\)', render_image, t)
# 4. Img stash — protect rendered <img> tags so autolink never touches src= values
img_stash = []
def stash_img(m):
img_stash.append(m.group(0))
return f'\x00I{len(img_stash)-1}\x00'
t = re.sub(r'<img\b[^>]*>', stash_img, t)
# 5. Link stash
link_stash = []
def stash_link(m):
lb, u = m.group(1), m.group(2)
link_stash.append(f'<a href="{u.replace(chr(34), "%22")}" target="_blank" rel="noopener">{esc(lb)}</a>')
return f'\x00L{len(link_stash)-1}\x00'
t = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_link, t)
# 6. Autolink (img and link URLs are both stashed — safe)
def autolink(m):
url = m.group(1)
trail = url[-1] if url[-1] in '.,;:!?)' else ''
clean = url[:-1] if trail else url
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
t = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, t)
# 7. Restore link stash
t = re.sub(r'\x00L(\d+)\x00', lambda m: link_stash[int(m.group(1))], t)
# 8. Restore img stash
t = re.sub(r'\x00I(\d+)\x00', lambda m: img_stash[int(m.group(1))], t)
# 9. Restore code stash (last — code content was never touched by any pass)
t = re.sub(r'\x00C(\d+)\x00', lambda m: code_stash[int(m.group(1))], t)
return t
def render_table(md):
"""Python mirror of the table pass, using inline_md() per cell."""
lines = md.strip().split('\n')
if len(lines) < 2:
return md
def is_sep(r):
return bool(re.match(r'^\|[\s|:-]+\|$', r.strip()))
if not is_sep(lines[1]):
return md
def parse_header(r):
cells = r.strip().lstrip('|').rstrip('|').split('|')
return ''.join(f'<th>{inline_md(c.strip())}</th>' for c in cells)
def parse_row(r):
cells = r.strip().lstrip('|').rstrip('|').split('|')
return ''.join(f'<td>{inline_md(c.strip())}</td>' for c in cells)
header = f'<tr>{parse_header(lines[0])}</tr>'
body = ''.join(f'<tr>{parse_row(r)}</tr>' for r in lines[2:])
return f'<table><thead>{header}</thead><tbody>{body}</tbody></table>'
# ═════════════════════════════════════════════════════════════════════════════
# ISSUE #486 — CSS: code inside table cells
# ═════════════════════════════════════════════════════════════════════════════
class TestIssue486CssCodeInTable:
"""CSS fix: td code and th code must have targeted sizing rules."""
def test_td_code_font_size_present(self):
"""msg-body td code rule must set font-size (e.g. 0.85em) to prevent oversized code."""
assert 'td code' in STYLE_CSS, (
"Missing 'td code' CSS rule — inline code in table cells needs sizing fix"
)
def test_th_code_rule_present(self):
"""th code rule must also exist for header cells."""
assert 'th code' in STYLE_CSS, (
"Missing 'th code' CSS rule — inline code in header cells needs sizing fix"
)
def test_td_code_has_font_size(self):
"""The td code / th code block must include a font-size declaration."""
# Find the msg-body scoped td code rule
idx = STYLE_CSS.find('td code')
assert idx != -1, "td code rule not found in style.css"
# Check nearby text (within 200 chars) has font-size
window = STYLE_CSS[idx:idx+200]
assert 'font-size' in window, (
f"td code rule must include font-size. Found near td code: {window!r}"
)
def test_td_code_has_padding(self):
"""The td code / th code block must include a padding declaration."""
idx = STYLE_CSS.find('td code')
assert idx != -1
window = STYLE_CSS[idx:idx+200]
assert 'padding' in window, (
f"td code rule must include padding. Found near td code: {window!r}"
)
def test_td_code_has_vertical_align(self):
"""The td code / th code block must include vertical-align: baseline."""
idx = STYLE_CSS.find('td code')
assert idx != -1
window = STYLE_CSS[idx:idx+200]
assert 'vertical-align' in window, (
f"td code rule must include vertical-align. Found near td code: {window!r}"
)
def test_code_renders_inside_table_cell(self):
"""Inline `code` inside a table cell must render as <code> element."""
md = "| Syntax | Rendered |\n|---|---|\n| `code` | `code` |"
result = render_table(md)
assert '<code>code</code>' in result, (
f"Inline code in table cell should render as <code>. Got: {result}"
)
def test_bold_code_renders_inside_table_cell(self):
"""**`bold code`** inside a table cell must render as <strong><code>."""
md = "| Style | Example |\n|---|---|\n| bold code | **`bold code`** |"
result = render_table(md)
# Should have code tag (even inside bold)
assert '<code>bold code</code>' in result, (
f"Bold code in table should render as <code>. Got: {result}"
)
def test_multiple_code_spans_in_same_cell(self):
"""Multiple backtick spans in one cell all render as <code>."""
md = "| Combined |\n|---|\n| `a` and `b` |"
result = render_table(md)
assert result.count('<code>') == 2, (
f"Expected 2 code tags in cell, got: {result}"
)
def test_code_in_header_cell(self):
"""`code` in a <th> header cell must also render as <code>."""
md = "| `header code` | Normal |\n|---|---|\n| data | data |"
result = render_table(md)
assert '<code>header code</code>' in result, (
f"Code in header cell should render. Got: {result}"
)
def test_code_not_mangled_by_bold_in_table(self):
"""**`code`** in a table cell must NOT produce &lt;code&gt; (the pre-fix bug)."""
md = "| Pattern | Example |\n|---|---|\n| bold-code | **`npm install`** |"
result = render_table(md)
assert '&lt;code&gt;' not in result, (
f"Code tags inside bold in table must not be HTML-escaped. Got: {result}"
)
assert '<strong>' in result, "Bold wrapper should be present"
assert '<code>npm install</code>' in result
def test_code_with_special_chars_in_table(self):
"""`<script>` inside a table cell must have the angle brackets escaped."""
md = "| Input | Output |\n|---|---|\n| `<script>` | sanitized |"
result = render_table(md)
assert '&lt;script&gt;' in result, (
f"Code content must be HTML-escaped. Got: {result}"
)
# The <code> wrapper itself must be there
assert '<code>' in result
def test_code_adjacent_to_link_in_table(self):
"""`code` and [link](url) in same cell both render correctly."""
url = 'https://example.com'
md = f"| Mixed |\n|---|\n| `foo` and [bar]({url}) |"
result = render_table(md)
assert '<code>foo</code>' in result
assert f'href="{url}"' in result
assert 'bar' in result
def test_empty_code_span_in_table(self):
"""Edge case: empty backtick span in table cell (`` ` ` ``) — no crash."""
# This won't match the code regex (requires at least 1 char), should pass through
md = "| Col |\n|---|\n| normal text |"
result = render_table(md)
assert '<td>normal text</td>' in result
# ═════════════════════════════════════════════════════════════════════════════
# ISSUE #487 — JS renderer: markdown image syntax
# ═════════════════════════════════════════════════════════════════════════════
class TestIssue487ImageRendering:
"""Image syntax ![alt](url) must render as <img>, not as ! + link."""
# ── Source-level checks ──────────────────────────────────────────────────
def test_image_pass_present_in_ui_js(self):
"""renderMd() must contain an image regex pass for ![alt](url)."""
assert '![' in UI_JS or r'!\[' in UI_JS, (
"ui.js should contain image syntax handling (![...](url) regex)"
)
# More specifically, look for the img tag being generated
assert 'msg-media-img' in UI_JS, (
"Image pass should reuse .msg-media-img class"
)
def test_image_pass_runs_before_link_pass_in_outer(self):
"""Image regex must appear in ui.js BEFORE the [label](url) link pass."""
# Find the image pass position
img_idx = UI_JS.find('!\\[')
if img_idx == -1:
img_idx = UI_JS.find("![")
# Find the outer labeled link pass position (after table pass)
link_idx = UI_JS.find("Outer link pass for labeled links")
assert img_idx != -1, "Image pass not found in ui.js"
assert link_idx != -1, "Outer link pass comment not found in ui.js"
assert img_idx < link_idx, (
"Image pass must run before the outer [label](url) link pass "
"to prevent the image from being consumed as a plain link"
)
def test_image_url_sanitized_for_quotes(self):
"""Image src URL must have double-quotes percent-encoded."""
# The image pass must use .replace(/"/g,'%22') or equivalent
# Look for the pattern near image handling
img_idx = UI_JS.find('msg-media-img')
assert img_idx != -1
# Find all occurrences — there's the MEDIA restore and the new image pass
# The new one should have %22 for URL sanitization
assert '%22' in UI_JS, (
"Image src URL must sanitize double-quotes to %22"
)
def test_image_alt_uses_esc(self):
"""Alt text must be passed through esc() to prevent XSS."""
# Look for esc( call near the image rendering code
# The pattern should be: alt="${esc(alt)}"
assert 'esc(' in UI_JS, "esc() function must be used for alt text"
def test_safe_tags_includes_img(self):
"""SAFE_TAGS allowlist must include 'img' to prevent the tag from being escaped."""
# Find the SAFE_TAGS regex in ui.js
safe_idx = UI_JS.find('SAFE_TAGS=')
assert safe_idx != -1, "SAFE_TAGS not found in ui.js"
safe_window = UI_JS[safe_idx:safe_idx+300]
assert 'img' in safe_window, (
f"SAFE_TAGS must include 'img' tag. Found: {safe_window!r}"
)
def test_inlinemd_has_image_pass(self):
"""inlineMd() must also handle ![alt](url) for images inside table cells."""
# inlineMd is called for table cells, list items, blockquotes
# Find inlineMd function body
start = UI_JS.find('function inlineMd(')
assert start != -1, "inlineMd function not found"
# Get a generous window covering the function
fn_window = UI_JS[start:start+1500]
assert '![' in fn_window or r'!\[' in fn_window, (
"inlineMd() must handle image syntax for images in table cells"
)
# ── Behaviour tests (Python mirror) ─────────────────────────────────────
def test_basic_image_renders_as_img_tag(self):
"""![alt](https://example.com/img.png) must produce an <img> tag."""
t = '![A cat](https://example.com/cat.png)'
result = inline_md(t)
assert '<img ' in result, f"Expected <img> tag, got: {result}"
assert 'src="https://example.com/cat.png"' in result
assert 'alt="A cat"' in result
# Must NOT have the raw ![...] syntax left over
assert '![' not in result
# Must NOT have a stray ! character
assert result.startswith('<img '), f"Result should start with img tag: {result}"
def test_image_does_not_render_as_link(self):
"""![alt](url) must NOT produce an <a> tag (the pre-fix bug)."""
t = '![Logo](https://example.com/logo.png)'
result = inline_md(t)
assert '<a ' not in result, (
f"Image must not render as an <a> tag. Got: {result}"
)
def test_image_stray_exclamation_not_present(self):
"""No stray ! character before the img tag (the pre-fix symptom)."""
t = '![alt](https://example.com/img.png)'
result = inline_md(t)
# Strip the img tag and check no ! is left
cleaned = re.sub(r'<img[^>]+>', '', result)
assert '!' not in cleaned, (
f"Stray ! character present after image render. Got: {result}"
)
def test_image_uses_msg_media_img_class(self):
"""Rendered <img> must use class=\"msg-media-img\" for consistent styling."""
t = '![screenshot](https://example.com/shot.png)'
result = inline_md(t)
assert 'class="msg-media-img"' in result, (
f"Image must use .msg-media-img class. Got: {result}"
)
def test_image_has_lazy_loading(self):
"""Rendered <img> must have loading=\"lazy\"."""
t = '![x](https://example.com/x.png)'
result = inline_md(t)
assert 'loading="lazy"' in result, f"Expected loading=lazy. Got: {result}"
def test_image_has_click_to_zoom(self):
"""Rendered <img> must have onclick toggle for zoom."""
t = '![x](https://example.com/x.png)'
result = inline_md(t)
assert 'msg-media-img--full' in result, (
f"Image must have click-to-zoom onclick. Got: {result}"
)
def test_image_alt_is_escaped(self):
"""Alt text with HTML special chars must be escaped."""
t = '![<evil>](https://example.com/img.png)'
result = inline_md(t)
assert '&lt;evil&gt;' in result, (
f"Alt text must be HTML-escaped. Got: {result}"
)
assert '<evil>' not in result
def test_image_url_quote_sanitized(self):
"""Double-quote in image URL must be percent-encoded to prevent attribute breakout."""
t = '![x](https://example.com/path"with"quotes.png)'
result = inline_md(t)
# Find the src attribute value
src_match = re.search(r'src="([^"]*)"', result)
assert src_match, f"src attribute not found. Got: {result}"
src_val = src_match.group(1)
assert '"' not in src_val, (
f"Raw double-quote in src would break attribute. Got src: {src_val!r}"
)
def test_image_no_javascript_uri(self):
"""javascript: URIs must not be rendered as image src (regex only matches http/https)."""
t = '![x](javascript:alert(1))'
result = inline_md(t)
# The regex requires https?://, so this should pass through unmodified
assert '<img ' not in result, (
f"javascript: URI must not render as <img>. Got: {result}"
)
def test_image_no_data_uri(self):
"""data: URIs must not be rendered as image src."""
t = '![x](data:image/png;base64,abc123)'
result = inline_md(t)
assert '<img ' not in result, (
f"data: URI must not render as <img>. Got: {result}"
)
def test_image_followed_by_text(self):
"""Image followed by plain text — only the image becomes an <img>."""
t = '![cat](https://example.com/cat.png) and some text'
result = inline_md(t)
assert '<img ' in result
assert 'and some text' in result
def test_image_preceded_by_text(self):
"""Text before an image — both render correctly."""
t = 'Here is a screenshot: ![shot](https://example.com/shot.png)'
result = inline_md(t)
assert 'Here is a screenshot:' in result
assert '<img ' in result
def test_image_and_link_in_same_cell(self):
"""Image and link in same inline context both render correctly."""
t = '![img](https://example.com/img.png) see [here](https://example.com)'
result = inline_md(t)
assert '<img ' in result
assert '<a href="https://example.com"' in result
assert '![' not in result
def test_image_inside_table_cell(self):
"""![alt](url) inside a markdown table cell must render as <img>."""
md = ("| Image | Caption |\n"
"|---|---|\n"
"| ![logo](https://example.com/logo.png) | Company logo |")
result = render_table(md)
assert '<img ' in result, f"Image in table should render as <img>. Got: {result}"
assert 'src="https://example.com/logo.png"' in result
assert '<a ' not in result, "Image in table must not render as <a>"
def test_image_in_table_no_stray_exclamation(self):
"""No stray ! before the <img> when image is inside a table cell."""
md = ("| X |\n|---|\n| ![x](https://x.com/x.png) |")
result = render_table(md)
# Strip known tags and check no ! appears
cleaned = re.sub(r'<[^>]+>', '', result)
assert '!' not in cleaned, (
f"Stray ! in table cell after image render. Cleaned: {cleaned!r}"
)
def test_empty_alt_text_image(self):
"""![](url) with empty alt renders as <img> with empty alt attribute."""
t = '![](https://example.com/img.png)'
result = inline_md(t)
assert '<img ' in result
assert 'alt=""' in result
def test_multiple_images_in_one_cell(self):
"""Two images in one table cell both render as <img> tags."""
t = ('![a](https://example.com/a.png) '
'![b](https://example.com/b.png)')
result = inline_md(t)
assert result.count('<img ') == 2, (
f"Expected 2 img tags. Got: {result}"
)
def test_image_with_https_url(self):
"""https:// image URL renders correctly."""
t = '![secure](https://secure.example.com/img.jpg)'
result = inline_md(t)
assert 'src="https://secure.example.com/img.jpg"' in result
def test_image_with_http_url(self):
"""http:// image URL also renders (non-https still valid)."""
t = '![old](http://example.com/img.jpg)'
result = inline_md(t)
assert '<img ' in result
assert 'src="http://example.com/img.jpg"' in result
# ═════════════════════════════════════════════════════════════════════════════
# Cross-cutting: code + image together inside tables (the edge case Nathan flagged)
# ═════════════════════════════════════════════════════════════════════════════
class TestEdgeCasesCodeAndImageInTables:
"""Combination edge cases: code blocks and images mixed inside table cells."""
def test_code_and_image_in_same_table_row(self):
"""Table row with code in one cell and image in another renders both correctly."""
md = ("| Code | Preview |\n"
"|---|---|\n"
"| `print('hello')` | ![screenshot](https://example.com/shot.png) |")
result = render_table(md)
assert "<code>print(&#x27;hello&#x27;)</code>" in result or "<code>print('hello')</code>" in result, (
f"Code cell should render as <code>. Got: {result}"
)
assert '<img ' in result, "Image cell should render as <img>"
def test_code_in_cell_with_image_in_next_cell(self):
"""Multiple columns: code stays code, image stays image, no cross-contamination."""
md = ("| Step | Example |\n"
"|---|---|\n"
"| Run `npm install` | ![demo](https://example.com/demo.gif) |")
result = render_table(md)
assert '<code>npm install</code>' in result
assert '<img ' in result
assert '<a ' not in result # image must not become a link
def test_bold_code_in_cell_and_image_in_cell(self):
"""**`code`** in one cell and image in another — no esc() mangling."""
md = ("| Command | Result |\n"
"|---|---|\n"
"| **`git status`** | ![result](https://example.com/r.png) |")
result = render_table(md)
assert '&lt;code&gt;' not in result, (
"Bold+code in table cell must not produce escaped code tags"
)
assert '<code>git status</code>' in result
assert '<img ' in result
def test_link_code_image_all_in_table(self):
"""Table with code, link, and image cells all render correctly."""
url = 'https://github.com/issues/486'
img_url = 'https://example.com/img.png'
md = (f"| Code | Link | Image |\n"
f"|---|---|---|\n"
f"| `var x = 1` | [#486]({url}) | ![img]({img_url}) |")
result = render_table(md)
assert '<code>var x = 1</code>' in result
assert f'href="{url}"' in result
assert '<img ' in result
# No double-linking
assert result.count('<a ') == 1
def test_image_url_with_query_string_in_table(self):
"""Image URL with & in query string inside table cell — & not mangled."""
url = 'https://example.com/img?w=100&h=200'
md = f"| Image |\n|---|\n| ![sized]({url}) |"
result = render_table(md)
assert f'src="{url}"' in result, (
f"& in image URL must not be escaped. Got: {result}"
)
def test_image_adjacent_to_code_no_interference(self):
"""Image immediately followed by code span in same cell — no token cross-talk."""
t = '![x](https://x.com/x.png) `code`'
result = inline_md(t)
assert '<img ' in result
assert '<code>code</code>' in result
def test_image_inside_code_span_not_rendered(self):
"""An image syntax inside a backtick span must NOT render as an img tag."""
t = '`![not an image](https://example.com/img.png)`'
result = inline_md(t)
# The whole thing is inside backticks — should be literal code, not an img
assert '<img ' not in result, (
f"Image syntax inside code span must not render as <img>. Got: {result}"
)
# Should render as a code element with the raw text inside
assert '<code>' in result