From ac7a716e5cc537830c36d4ddf19ca8558191c29c Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 21:38:59 +0000 Subject: [PATCH] feat: render safe mobile Markdown across My Work (#343) --- frontend/dashboard.css | 12 +++ frontend/dashboard.js | 20 ++--- frontend/index.html | 10 +-- frontend/markdown.js | 98 ++++++++++++++++++++--- frontend/service-worker.js | 2 +- tests/test_markdown_renderer.py | 88 ++++++++++++++++++++ tests/test_mobile_composer_integration.py | 2 +- tests/test_my_work.py | 2 +- tests/test_service_worker.py | 6 +- 9 files changed, 209 insertions(+), 31 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 3991860..514dcb9 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -238,6 +238,18 @@ textarea { resize: vertical; min-height: 120px; } .search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; } .search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } .search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; } +.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; } +.markdown-content > :first-child { margin-top:0; } +.markdown-content > :last-child { margin-bottom:0; } +.markdown-content pre { max-width:100%; overflow-x:auto; padding:10px; border-radius:8px; background:#07101d; white-space:pre; -webkit-overflow-scrolling:touch; } +.markdown-content code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; } +.markdown-content :not(pre) > code { padding:2px 4px; border-radius:4px; background:#07101d; } +.markdown-content blockquote { margin:10px 0; padding-left:12px; border-left:3px solid #60a5fa; color:#b9c9dd; } +.markdown-content ul { padding-left:24px; } +.markdown-content .task-list { padding-left:0; list-style:none; } +.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; } +.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; } +.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; } @media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } } .create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .create-issue-sheet.open { display:flex; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 3d73f55..f880390 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -435,7 +435,7 @@ qs('#update-sheet-title').textContent = detail.title || 'Unread update'; qs('#update-subject-type').textContent = detail.subject_type || 'Update'; qs('#update-subject-state').textContent = detail.state || ''; - qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.'; + qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.'); qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#'; updateOwnership.open(detail, selectedUpdate); qs('#retry-update-load').hidden = true; @@ -1046,8 +1046,8 @@ return '
' + escapeHtml(comment.author || 'Unknown author') + (comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') + - '
' + - escapeHtml(comment.body || 'No comment body provided.') + '
'; + '
' + + renderMarkdown(comment.body || 'No comment body provided.') + '
'; } function renderIssueConversation(state) { @@ -1187,7 +1187,7 @@ selectedIssueDetail = detail; issueConversation = issueController.conversation(item, detail.conversation); qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue'; - qs('#issue-sheet-body').textContent = detail.body || 'No description provided.'; + qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.'); qs('#issue-labels').innerHTML = (detail.labels || []).map(label => '' + escapeHtml(label) + '' ).join(' '); @@ -1332,7 +1332,7 @@ selectedPullDetail = detail; pullConversation = pullController.conversation(item, detail.conversation); qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request'; - qs('#pull-sheet-body').textContent = detail.body || 'No description provided.'; + qs('#pull-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.'); renderPullConversation(pullConversation.snapshot()); qs('#open-pull-gitea').href = detail.url || item.url || '#'; qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author'); @@ -1369,7 +1369,7 @@ const expanded = findWorkController.isPreviewed(item); const detailId = 'find-work-detail-' + index; const detail = '

' + escapeHtml(item.body || 'No description provided.') + '

' + + '>
' + renderMarkdown(item.body || 'No description provided.') + '
' + (item.url ? 'Open in Gitea' : '') + '
'; return '
' + escapeHtml(item.repository) + '#' + @@ -1678,7 +1678,7 @@ try { const detail = await reviewController.load(selectedReview); if (selectedReview !== item) return; - qs('#review-sheet-body').textContent = detail.body || 'No description provided.'; + qs('#review-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.'); qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown'); qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) => createReviewController.renderDiffFile(file, index, escapeHtml) @@ -1731,7 +1731,7 @@ openNextUnreviewed(progressSnapshot); qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review => '
' + escapeHtml(review.user?.login || 'Reviewer') + ' · ' + - escapeHtml(review.state || 'commented') + (review.body ? '
' + escapeHtml(review.body) + '
' : '') + '
' + escapeHtml(review.state || 'commented') + (review.body ? '
' + renderMarkdown(review.body) + '
' : '') + '
' ).join('') : '
No prior reviews.
'; qs('#review-sheet-status').textContent = 'Ready to review · by ' + (detail.author || 'unknown author'); qs('#submit-review').disabled = false; @@ -2009,7 +2009,7 @@ (detail.author ? ' · by ' + detail.author : '') + (detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') + (detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : ''); - qs('#search-preview-body').textContent = detail.body || 'No description provided.'; + qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.'); qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#'; claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')); claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'; @@ -2403,7 +2403,7 @@ selectedIssue = { ...editing, ...confirmed, key: editing.key }; selectedIssueDetail = { ...selectedIssueDetail, ...confirmed }; qs('#issue-sheet-title').textContent = confirmed.title; - qs('#issue-sheet-body').textContent = confirmed.body || 'No description provided.'; + qs('#issue-sheet-body').innerHTML = renderMarkdown(confirmed.body || 'No description provided.'); paintMyWork(lastContextSnapshot); qs('#issue-edit-form').hidden = true; qs('#issue-sheet-status').textContent = 'Issue saved.'; diff --git a/frontend/index.html b/frontend/index.html index fc0f420..a08ebea 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -184,7 +184,7 @@
Choose a search result.
-

+
Open in Gitea @@ -221,7 +221,7 @@
Choose an issue.
-

+

Full conversation

@@ -380,7 +380,7 @@

Subject context

-

+

Reply

@@ -412,7 +412,7 @@
Choose a pull request.
-

+

Full conversation

@@ -455,7 +455,7 @@
Choose a review request.
-

+
CI unknownOpen in Gitea

Changed files

diff --git a/frontend/markdown.js b/frontend/markdown.js index c2e99ce..24d6c07 100644 --- a/frontend/markdown.js +++ b/frontend/markdown.js @@ -13,17 +13,95 @@ })[character]); } - return function renderMarkdown(raw) { - return escapeHtml(raw) - .replace(/^#{1,6}\s.*$/gm, (line) => { - const level = line.match(/^(#{1,6})/)[1].length; - return '' + line.replace(/^#{1,6}\s/, '') + ''; + function renderInline(value) { + const code = []; + let rendered = escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, body) => { + const marker = '\u0000CODE' + code.length + '\u0000'; + code.push('' + body + ''); + return marker; + }); + rendered = rendered + .replace(/\[([^\]\n]+)\]\(([^\s)]+)(?:\s+"[^&]*?")?\)/g, (_match, label, escapedUrl) => { + const url = escapedUrl.replace(/&/g, '&'); + const safe = /^(https?:|mailto:)/i.test(url) || /^(\/|#|\.\/|\.\.\/)/.test(url); + if (!safe) return label; + return '' + label + ''; }) .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/`([^`]+)`/g, '$1') - .replace(/^-\s?(.+)$/gm, '
  • $1
  • ') - .replace(/(
  • .*<\/li>)/s, '
      $1
    ') - .replace(/\n/g, '
    '); + .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); + return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]); + } + + function renderList(lines) { + const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line)); + const items = lines.map(line => { + let body = line.replace(/^[-*+]\s+/, ''); + if (!taskList) return '
  • ' + renderInline(body) + '
  • '; + const checked = /^\[[xX]\]\s+/.test(body); + body = body.replace(/^\[[ xX]\]\s+/, ''); + return '
  • ' + renderInline(body) + '
  • '; + }).join(''); + return '' + items + ''; + } + + return function renderMarkdown(raw) { + const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n'); + const output = []; + let index = 0; + while (index < lines.length) { + const line = lines[index]; + if (!line.trim()) { + index += 1; + continue; + } + const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/); + if (fence) { + const body = []; + index += 1; + while (index < lines.length && !/^```\s*$/.test(lines[index])) { + body.push(lines[index]); + index += 1; + } + if (index < lines.length) index += 1; + const language = fence[1] ? ' class="language-' + fence[1] + '"' : ''; + output.push('
    ' + escapeHtml(body.join('\n')) + '
    '); + continue; + } + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + const level = heading[1].length; + output.push('' + renderInline(heading[2]) + ''); + index += 1; + continue; + } + if (/^>\s?/.test(line)) { + const quote = []; + while (index < lines.length && /^>\s?/.test(lines[index])) { + quote.push(lines[index].replace(/^>\s?/, '')); + index += 1; + } + output.push('

    ' + renderInline(quote.join('\n')).replace(/\n/g, '
    ') + '

    '); + continue; + } + if (/^[-*+]\s+/.test(line)) { + const list = []; + while (index < lines.length && /^[-*+]\s+/.test(lines[index])) { + list.push(lines[index]); + index += 1; + } + output.push(renderList(list)); + continue; + } + const paragraph = []; + while (index < lines.length && lines[index].trim() && + !/^```/.test(lines[index]) && !/^(#{1,6})\s+/.test(lines[index]) && + !/^>\s?/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index])) { + paragraph.push(lines[index]); + index += 1; + } + output.push('

    ' + renderInline(paragraph.join('\n')).replace(/\n/g, '
    ') + '

    '); + } + return output.join(''); }; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index fdf1ebc..01b724e 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v37'; +const CACHE = 'stackchain-dashboard-shell-v38'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 7ff3965..037eda1 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -1,4 +1,5 @@ import json +import re import subprocess from html.parser import HTMLParser from pathlib import Path @@ -9,6 +10,19 @@ FRONTEND = Path(__file__).parent.parent / "frontend" RENDERER = FRONTEND / "markdown.js" +def render_markdown(payload): + script = ( + f"const render = require({json.dumps(str(RENDERER))});" + f"process.stdout.write(render({json.dumps(payload)}));" + ) + return subprocess.run( + ["node", "-e", script], + check=True, + capture_output=True, + text=True, + ).stdout + + class ScriptSourceParser(HTMLParser): def __init__(self): super().__init__() @@ -50,3 +64,77 @@ def test_markdown_script_resolves_inside_dashboard_subpath(): assert urljoin( "https://forge.alexanderwhitestone.com/dashboard/", markdown_source ) == "https://forge.alexanderwhitestone.com/dashboard/static/markdown.js" + + +def test_markdown_renderer_preserves_mobile_reading_structure(): + rendered = render_markdown( + "## Plan\n\n> Ship this\n\n- [x] Tested\n- [ ] Released\n\n" + "```python\nprint('ready')\n```" + ) + + assert rendered == ( + "

    Plan

    Ship this

    " + '
    • ' + ' Tested
    • ' + '
    • Released
    ' + '
    print('ready')
    ' + ) + + +def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert(): + rendered = render_markdown( + "[Forge](https://forge.example/work?q=1&safe=yes) " + "[relative](/git/org/repo/issues/1) " + "[attack](javascript:alert(1)) " + ) + + assert ( + 'Forge' in rendered + ) + assert ( + 'relative' in rendered + ) + assert "javascript:" not in rendered + assert "]*\bid="{body_id}"[^>]*>', html) + assert body_tag + assert "markdown-content" in body_tag.group(0) + + assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css + assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css + assert ".markdown-content a { min-height:44px;" in css + assert "stackchain-dashboard-shell-v38" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index b83243c..55ca53e 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v37" in worker + assert "stackchain-dashboard-shell-v38" in worker diff --git a/tests/test_my_work.py b/tests/test_my_work.py index de8071d..9586701 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1667,7 +1667,7 @@ async def test_mobile_find_work_cards_preview_escaped_context_without_extra_requ assert 'aria-controls="' in html assert "const detailId = 'find-work-detail-' + index" in html assert 'class="find-work-detail"' in html - assert "escapeHtml(item.body || 'No description provided.')" in html + assert "renderMarkdown(item.body || 'No description provided.')" in html assert '>Open in Gitea' in html assert '.find-work-card a, .find-work-more { min-height:44px;' in html assert '.find-work-detail { min-width:0;' in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 8e65a53..8ffa866 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v37" in source + assert "stackchain-dashboard-shell-v38" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v37" in source + assert "stackchain-dashboard-shell-v38" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v37" in source + assert "stackchain-dashboard-shell-v38" in source assert "BASE + 'static/update-ownership.js'" in source -- 2.43.0