| 1 | diff --git a/internal/webui/source_render.go b/internal/webui/source_render.go |
| 2 | index 6c50982..ec85aa3 100644 |
| 3 | --- a/internal/webui/source_render.go |
| 4 | +++ b/internal/webui/source_render.go |
| 5 | @@ -28,7 +28,8 @@ func previewLines(content string) ([]string, bool) { |
| 6 | |
| 7 | // sourceHTML renders a bounded source preview. It returns template.HTML only |
| 8 | // after escaping every source byte and constructing the surrounding markup. |
| 9 | -// The template should place the result inside a source-view region. |
| 10 | +// The markup is wrapped in a <source-view lang="..."> element and uses only |
| 11 | +// semantic elements so styling needs no class hooks. |
| 12 | func sourceHTML(value any) template.HTML { |
| 13 | m := valueMap(value) |
| 14 | content, _ := m["content"].(string) |
| 15 | @@ -45,29 +46,32 @@ func sourceHTML(value any) template.HTML { |
| 16 | lines, linesTruncated := previewLines(content) |
| 17 | var out strings.Builder |
| 18 | out.Grow(len(content) + len(lines)*120) |
| 19 | - out.WriteString(`<table class="source-view source-lang-`) |
| 20 | + out.WriteString(`<source-view lang="`) |
| 21 | out.WriteString(language) |
| 22 | - out.WriteString(`"><tbody>`) |
| 23 | + out.WriteString(`"><table><tbody>`) |
| 24 | state := highlightState{} |
| 25 | for number, line := range lines { |
| 26 | out.WriteString(`<tr id="L`) |
| 27 | writeUint(&out, number+1) |
| 28 | - out.WriteString(`"><td class="source-line"><a href="#L`) |
| 29 | + out.WriteString(`"><td><a href="#L`) |
| 30 | writeUint(&out, number+1) |
| 31 | out.WriteString(`">`) |
| 32 | writeUint(&out, number+1) |
| 33 | - out.WriteString(`</a></td><td class="source-code"><code>`) |
| 34 | + out.WriteString(`</a></td><td><code>`) |
| 35 | highlightLine(&out, line, language, &state) |
| 36 | out.WriteString(`</code></td></tr>`) |
| 37 | } |
| 38 | + out.WriteString(`</tbody>`) |
| 39 | if truncated || linesTruncated { |
| 40 | - out.WriteString(`<tr class="source-truncated"><td colspan="2">Preview truncated (256 KiB or 10,000 lines). Use the raw/download control to view the complete file.</td></tr>`) |
| 41 | + out.WriteString(`<tfoot><tr><td colspan="2">Preview truncated (256 KiB or 10,000 lines). Use the raw/download control to view the complete file.</td></tr></tfoot>`) |
| 42 | } |
| 43 | - out.WriteString(`</tbody></table>`) |
| 44 | + out.WriteString(`</table></source-view>`) |
| 45 | return template.HTML(out.String()) |
| 46 | } |
| 47 | |
| 48 | // diffHTML renders a line-oriented diff without interpreting its content. |
| 49 | +// Added lines are wrapped in <ins>, removed lines in <del>, and hunk headers |
| 50 | +// in <b>; context lines carry no wrapper. |
| 51 | func diffHTML(value any) template.HTML { |
| 52 | m := valueMap(value) |
| 53 | diff, _ := m["diff"].(string) |
| 54 | @@ -84,35 +88,32 @@ func diffHTML(value any) template.HTML { |
| 55 | lines, linesTruncated := previewLines(diff) |
| 56 | var out strings.Builder |
| 57 | out.Grow(len(diff) + len(lines)*140) |
| 58 | - out.WriteString(`<table class="diff-view"><tbody>`) |
| 59 | + out.WriteString(`<diff-view><table><tbody>`) |
| 60 | for number, line := range lines { |
| 61 | - class := "diff-context" |
| 62 | + wrapper := "" |
| 63 | switch { |
| 64 | case strings.HasPrefix(line, "@@"): |
| 65 | - class = "diff-hunk" |
| 66 | + wrapper = "b" |
| 67 | case strings.HasPrefix(line, "+"): |
| 68 | - class = "diff-add" |
| 69 | + wrapper = "ins" |
| 70 | case strings.HasPrefix(line, "-"): |
| 71 | - class = "diff-remove" |
| 72 | - case strings.HasPrefix(line, "\\"): |
| 73 | - class = "diff-note" |
| 74 | + wrapper = "del" |
| 75 | } |
| 76 | - out.WriteString(`<tr class="`) |
| 77 | - out.WriteString(class) |
| 78 | - out.WriteString(`" id="D`) |
| 79 | + out.WriteString(`<tr id="D`) |
| 80 | writeUint(&out, number+1) |
| 81 | - out.WriteString(`"><td class="diff-line"><a href="#D`) |
| 82 | + out.WriteString(`"><td><a href="#D`) |
| 83 | writeUint(&out, number+1) |
| 84 | out.WriteString(`">`) |
| 85 | writeUint(&out, number+1) |
| 86 | - out.WriteString(`</a></td><td class="diff-code"><code>`) |
| 87 | - out.WriteString(html.EscapeString(line)) |
| 88 | + out.WriteString(`</a></td><td><code>`) |
| 89 | + writeWrapped(&out, wrapper, line) |
| 90 | out.WriteString(`</code></td></tr>`) |
| 91 | } |
| 92 | + out.WriteString(`</tbody>`) |
| 93 | if truncated || linesTruncated { |
| 94 | - out.WriteString(`<tr class="diff-truncated"><td colspan="2">Diff preview truncated (256 KiB or 10,000 lines).</td></tr>`) |
| 95 | + out.WriteString(`<tfoot><tr><td colspan="2">Diff preview truncated (256 KiB or 10,000 lines).</td></tr></tfoot>`) |
| 96 | } |
| 97 | - out.WriteString(`</tbody></table>`) |
| 98 | + out.WriteString(`</table></diff-view>`) |
| 99 | return template.HTML(out.String()) |
| 100 | } |
| 101 | |
| 102 | @@ -229,15 +230,26 @@ func highlightLine(out *strings.Builder, line, language string, state *highlight |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | -func writeToken(out *strings.Builder, class, value string) { |
| 107 | - if class != "" { |
| 108 | - out.WriteString(`<span class="tok-`) |
| 109 | - out.WriteString(class) |
| 110 | - out.WriteString(`">`) |
| 111 | +// tokenElements maps token kinds to the semantic element that carries them. |
| 112 | +var tokenElements = map[string]string{"keyword": "b", "comment": "i", "string": "q", "number": "var"} |
| 113 | + |
| 114 | +func writeToken(out *strings.Builder, kind, value string) { |
| 115 | + writeWrapped(out, tokenElements[kind], value) |
| 116 | +} |
| 117 | + |
| 118 | +// writeWrapped escapes value and, when element is not empty, wraps it in that |
| 119 | +// attribute-free element. |
| 120 | +func writeWrapped(out *strings.Builder, element, value string) { |
| 121 | + if element != "" { |
| 122 | + out.WriteByte('<') |
| 123 | + out.WriteString(element) |
| 124 | + out.WriteByte('>') |
| 125 | } |
| 126 | out.WriteString(html.EscapeString(value)) |
| 127 | - if class != "" { |
| 128 | - out.WriteString(`</span>`) |
| 129 | + if element != "" { |
| 130 | + out.WriteString("</") |
| 131 | + out.WriteString(element) |
| 132 | + out.WriteByte('>') |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | diff --git a/internal/webui/source_render_test.go b/internal/webui/source_render_test.go |
| 137 | index 7bd21fc..a01779c 100644 |
| 138 | --- a/internal/webui/source_render_test.go |
| 139 | +++ b/internal/webui/source_render_test.go |
| 140 | @@ -15,18 +15,19 @@ func TestSourceHTMLIsEscapedAndAnchored(t *testing.T) { |
| 141 | }) |
| 142 | html := string(got) |
| 143 | for _, want := range []string{ |
| 144 | - `class="source-view source-lang-go"`, |
| 145 | + `<source-view lang="go"><table>`, |
| 146 | `id="L1"`, |
| 147 | `href="#L2"`, |
| 148 | - `class="tok-keyword">var</span>`, |
| 149 | - `<script>`, |
| 150 | + `<b>var</b>`, |
| 151 | + `<q>"<script>alert(1)</script>"</q>`, |
| 152 | + `</table></source-view>`, |
| 153 | } { |
| 154 | if !strings.Contains(html, want) { |
| 155 | t.Fatalf("source output missing %q: %s", want, html) |
| 156 | } |
| 157 | } |
| 158 | - if strings.Contains(html, "<script>alert") { |
| 159 | - t.Fatalf("source content was not escaped: %s", html) |
| 160 | + if strings.Contains(html, "<script>alert") || strings.Contains(html, "class=") { |
| 161 | + t.Fatalf("source content was not escaped or used class hooks: %s", html) |
| 162 | } |
| 163 | if _, ok := any(got).(template.HTML); !ok { |
| 164 | t.Fatalf("sourceHTML type = %T, want template.HTML", got) |
| 165 | @@ -36,10 +37,10 @@ func TestSourceHTMLIsEscapedAndAnchored(t *testing.T) { |
| 166 | func TestSourceHTMLUsesPlaintextAndBoundsPreview(t *testing.T) { |
| 167 | content := strings.Repeat("<unsafe>\n", sourcePreviewLimit/len("<unsafe>\n")+10) |
| 168 | got := string(sourceHTML(map[string]any{"name": "README.unknown", "content": content})) |
| 169 | - if !strings.Contains(got, `class="source-view source-lang-text"`) { |
| 170 | + if !strings.Contains(got, `<source-view lang="text">`) { |
| 171 | t.Fatalf("unknown extension should use plaintext: %s", got[:min(len(got), 200)]) |
| 172 | } |
| 173 | - if !strings.Contains(got, `class="source-truncated"`) { |
| 174 | + if !strings.Contains(got, `<tfoot><tr><td colspan="2">Preview truncated`) { |
| 175 | t.Fatal("bounded preview should identify truncation") |
| 176 | } |
| 177 | if strings.Contains(got, "<unsafe>") { |
| 178 | @@ -49,13 +50,13 @@ func TestSourceHTMLUsesPlaintextAndBoundsPreview(t *testing.T) { |
| 179 | |
| 180 | func TestDiffHTMLClassifiesLinesWithoutHTMLInjection(t *testing.T) { |
| 181 | got := string(diffHTML(map[string]any{"diff": "@@ -1 +1 @@\n-old\n+<script>bad</script>\n context"})) |
| 182 | - for _, want := range []string{`class="diff-hunk"`, `class="diff-remove"`, `class="diff-add"`, `class="diff-context"`, `<script>`} { |
| 183 | + for _, want := range []string{`<diff-view><table>`, `<code><b>@@ -1 +1 @@</b></code>`, `<code><del>-old</del></code>`, `<code><ins>+<script>bad</script></ins></code>`, `<code> context</code>`, `</table></diff-view>`} { |
| 184 | if !strings.Contains(got, want) { |
| 185 | t.Fatalf("diff output missing %q: %s", want, got) |
| 186 | } |
| 187 | } |
| 188 | - if strings.Contains(got, "<script>bad") { |
| 189 | - t.Fatal("diff content was not escaped") |
| 190 | + if strings.Contains(got, "<script>bad") || strings.Contains(got, "class=") { |
| 191 | + t.Fatal("diff content was not escaped or used class hooks") |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | @@ -67,7 +68,7 @@ func TestPreviewBoundsLineMarkupAndDisclosesOmission(t *testing.T) { |
| 196 | if len(got) > 2*1024*1024 { |
| 197 | t.Fatalf("a short-line preview expanded to %d bytes", len(got)) |
| 198 | } |
| 199 | - if !strings.Contains(got, "10,000 lines") { |
| 200 | + if !strings.Contains(got, "<tfoot>") || !strings.Contains(got, "10,000 lines") { |
| 201 | t.Fatal("line preview omission was not disclosed") |
| 202 | } |
| 203 | }) |
| 204 | @@ -76,7 +77,7 @@ func TestPreviewBoundsLineMarkupAndDisclosesOmission(t *testing.T) { |
| 205 | |
| 206 | func TestDiffDisclosesBackendTruncation(t *testing.T) { |
| 207 | got := string(diffHTML(map[string]any{"diff": "already bounded", "truncated": true})) |
| 208 | - if !strings.Contains(got, "truncated") { |
| 209 | + if !strings.Contains(got, "<tfoot>") || !strings.Contains(got, "truncated") { |
| 210 | t.Fatal("backend truncation was hidden") |
| 211 | } |
| 212 | } |
| 213 | @@ -100,7 +101,7 @@ func TestSourceHTMLPreservesRenderedSourceText(t *testing.T) { |
| 214 | var got strings.Builder |
| 215 | var visit func(*html.Node) |
| 216 | visit = func(node *html.Node) { |
| 217 | - if node.Type == html.ElementNode && node.Data == "td" && hasClass(node, "source-code") { |
| 218 | + if node.Type == html.ElementNode && node.Data == "td" && isCodeCell(node) { |
| 219 | appendText(&got, node) |
| 220 | got.WriteByte('\n') |
| 221 | return |
| 222 | @@ -115,13 +116,11 @@ func TestSourceHTMLPreservesRenderedSourceText(t *testing.T) { |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | -func hasClass(node *html.Node, class string) bool { |
| 227 | - for _, attr := range node.Attr { |
| 228 | - if attr.Key == "class" && attr.Val == class { |
| 229 | - return true |
| 230 | - } |
| 231 | - } |
| 232 | - return false |
| 233 | +// isCodeCell reports whether a table cell holds rendered source rather than |
| 234 | +// a line number anchor. |
| 235 | +func isCodeCell(node *html.Node) bool { |
| 236 | + first := node.FirstChild |
| 237 | + return first != nil && first.Type == html.ElementNode && first.Data == "code" |
| 238 | } |
| 239 | |
| 240 | func appendText(out *strings.Builder, node *html.Node) { |