diff --git a/CHANGELOG.md b/CHANGELOG.md index 997e250..3bb7c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # 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 `` and `` 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 `! alt` instead of an ``. + +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 `` 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 `` 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 **Markdown link rendering — `renderMd()` in `static/ui.js`** (PR #475, fixes #470) diff --git a/static/index.html b/static/index.html index 79948ce..67604b8 100644 --- a/static/index.html +++ b/static/index.html @@ -536,7 +536,7 @@
System
Instance version and access controls.
- v0.50.43 + v0.50.44
diff --git a/static/style.css b/static/style.css index 9fb7d3c..5063dc3 100644 --- a/static/style.css +++ b/static/style.css @@ -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 td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);} .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-block{display:block;text-align:center;margin:12px 0;overflow-x:auto;} .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 td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);} .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 */ .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);} diff --git a/static/ui.js b/static/ui.js index 00926b2..278cda1 100644 --- a/static/ui.js +++ b/static/ui.js @@ -465,15 +465,24 @@ function renderMd(raw){ t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`${esc(x)}`); + // #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 . + // 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)=>`${esc(alt)}`); + // Stash rendered tags so autolink never matches URLs inside src= + const _img_stash=[]; + t=t.replace(/(]*>)/g,m=>{_img_stash.push(m);return `\x00G${_img_stash.length-1}\x00`;}); 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 const _link_stash=[]; t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);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 `${esc(clean)}${trail}`;}); 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 - // by escaping bare < > that aren't part of our own tags - const SAFE_INLINE=/^<\/?(strong|em|code|a)([\s>]|$)/i; + // by escaping bare < > that are not part of our own tags + const SAFE_INLINE=/^<\/?(strong|em|code|a|img)([\s>]|$)/i; t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag)); return t; } @@ -523,6 +532,10 @@ function renderMd(raw){ const body=rows.slice(2).map(r=>`${parseRow(r)}`).join(''); return `${header}${body}
`; }); + // #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)=>`${esc(alt)}`); // 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. // Stash existing tags first to avoid re-linking already-linked URLs. @@ -534,7 +547,7 @@ function renderMd(raw){ // Our pipeline only emits: ,,,
,,
    ,
      ,
    1. , // ,,,,' for c in cells) + + def parse_row(r): + cells = r.strip().lstrip('|').rstrip('|').split('|') + return ''.join(f'' for c in cells) + + header = f'{parse_header(lines[0])}' + body = ''.join(f'{parse_row(r)}' for r in lines[2:]) + return f'
      ,,
      ,
      ,

      ,
      ,, //

      (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)); // Autolink: convert plain URLs to clickable links. // Stash existing tags first so we never re-link a URL already inside href="...". diff --git a/tests/test_issue486_487.py b/tests/test_issue486_487.py new file mode 100644 index 0000000..822c726 --- /dev/null +++ b/tests/test_issue486_487.py @@ -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 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 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'{esc(m.group(1))}') + 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'{esc(m.group(1))}', t) + t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'{esc(m.group(1))}', t) + t = re.sub(r'\*([^*\n]+)\*', lambda m: f'{esc(m.group(1))}', 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'{esc(alt)}') + t = re.sub(r'!\[([^\]]*)\]\((https?://[^\)]+)\)', render_image, t) + + # 4. Img stash — protect rendered 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']*>', stash_img, t) + + # 5. Link stash + link_stash = [] + def stash_link(m): + lb, u = m.group(1), m.group(2) + link_stash.append(f'{esc(lb)}') + 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'{esc(clean)}{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'
      {inline_md(c.strip())}{inline_md(c.strip())}
      {header}{body}
      ' + + +# ═════════════════════════════════════════════════════════════════════════════ +# 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 element.""" + md = "| Syntax | Rendered |\n|---|---|\n| `code` | `code` |" + result = render_table(md) + assert 'code' in result, ( + f"Inline code in table cell should render as . Got: {result}" + ) + + def test_bold_code_renders_inside_table_cell(self): + """**`bold code`** inside a table cell must render as .""" + md = "| Style | Example |\n|---|---|\n| bold code | **`bold code`** |" + result = render_table(md) + # Should have code tag (even inside bold) + assert 'bold code' in result, ( + f"Bold code in table should render as . Got: {result}" + ) + + def test_multiple_code_spans_in_same_cell(self): + """Multiple backtick spans in one cell all render as .""" + md = "| Combined |\n|---|\n| `a` and `b` |" + result = render_table(md) + assert result.count('') == 2, ( + f"Expected 2 code tags in cell, got: {result}" + ) + + def test_code_in_header_cell(self): + """`code` in a header cell must also render as .""" + md = "| `header code` | Normal |\n|---|---|\n| data | data |" + result = render_table(md) + assert 'header 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 <code> (the pre-fix bug).""" + md = "| Pattern | Example |\n|---|---|\n| bold-code | **`npm install`** |" + result = render_table(md) + assert '<code>' not in result, ( + f"Code tags inside bold in table must not be HTML-escaped. Got: {result}" + ) + assert '' in result, "Bold wrapper should be present" + assert 'npm install' in result + + def test_code_with_special_chars_in_table(self): + """`