refactor(web): share one invisible-character masker between code and diff views
- commit
- fb91573ebf7758a1f96ff23a23ae33722464c2da
- author
- brice · · 2026-10-10 13:26 UTC
- parent
- 652ddb1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +9 -45
M internal/adapters/web/blob.go +3 -3
| @@ -123,7 +123,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 123 | 123 | } | |
| 124 | 124 | size := b.info.GetSize() | |
| 125 | 125 | p := BlobPage{ | |
| 126 | - | Name: markedText(name), | |
| 126 | + | Name: visibleText(name), | |
| 127 | 127 | RawHref: repoHref(d.name(), "raw", t.name, t.path), | |
| 128 | 128 | HistHref: repoHref(d.name(), "commits", t.name, t.path), | |
| 129 | 129 | DirHref: repoHref(d.name(), "tree", t.name, dir), | |
| @@ -187,7 +187,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 187 | 187 | l := s.repoLayout(ctx, d, "tree") | |
| 188 | 188 | pinRef(&l, t) | |
| 189 | 189 | l.Crumbs = fileCrumbs(d.name(), "blob", t) | |
| 190 | - | l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance | |
| 190 | + | l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance | |
| 191 | 191 | l.Status = Status{ | |
| 192 | 192 | Mode: "blob", Context: p.Name, | |
| 193 | 193 | Hints: "click a line number · y permalink · ? help", Blob: p.HasCode, | |
| @@ -207,7 +207,7 @@ func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitR | |||
| 207 | 207 | } | |
| 208 | 208 | c := res.Msg.GetCommits()[0] | |
| 209 | 209 | row := &CommitRow{ | |
| 210 | - | Short: short(c.GetId()), Subject: markedText(c.GetSummary()), Author: c.GetAuthor().GetName(), | |
| 210 | + | Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), Author: c.GetAuthor().GetName(), | |
| 211 | 211 | CHref: repoHref(repo, "commit", c.GetId()), | |
| 212 | 212 | } | |
| 213 | 213 | if ts := c.GetCommitter().GetTime(); ts.IsValid() { | |
| @@ -123,7 +123,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 123 | } | 123 | } | ||
| 124 | size := b.info.GetSize() | 124 | size := b.info.GetSize() | ||
| 125 | p := BlobPage{ | 125 | p := BlobPage{ | ||
| 126 | - | Name: markedText(name), | 126 | + | Name: visibleText(name), |
| 127 | RawHref: repoHref(d.name(), "raw", t.name, t.path), | 127 | RawHref: repoHref(d.name(), "raw", t.name, t.path), | ||
| 128 | HistHref: repoHref(d.name(), "commits", t.name, t.path), | 128 | HistHref: repoHref(d.name(), "commits", t.name, t.path), | ||
| 129 | DirHref: repoHref(d.name(), "tree", t.name, dir), | 129 | DirHref: repoHref(d.name(), "tree", t.name, dir), | ||
| @@ -187,7 +187,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 187 | l := s.repoLayout(ctx, d, "tree") | 187 | l := s.repoLayout(ctx, d, "tree") | ||
| 188 | pinRef(&l, t) | 188 | pinRef(&l, t) | ||
| 189 | l.Crumbs = fileCrumbs(d.name(), "blob", t) | 189 | l.Crumbs = fileCrumbs(d.name(), "blob", t) | ||
| 190 | - | l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance | 190 | + | l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance |
| 191 | l.Status = Status{ | 191 | l.Status = Status{ | ||
| 192 | Mode: "blob", Context: p.Name, | 192 | Mode: "blob", Context: p.Name, | ||
| 193 | Hints: "click a line number · y permalink · ? help", Blob: p.HasCode, | 193 | Hints: "click a line number · y permalink · ? help", Blob: p.HasCode, | ||
| @@ -207,7 +207,7 @@ func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitR | |||||
| 207 | } | 207 | } | ||
| 208 | c := res.Msg.GetCommits()[0] | 208 | c := res.Msg.GetCommits()[0] | ||
| 209 | row := &CommitRow{ | 209 | row := &CommitRow{ | ||
| 210 | - | Short: short(c.GetId()), Subject: markedText(c.GetSummary()), Author: c.GetAuthor().GetName(), | 210 | + | Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), Author: c.GetAuthor().GetName(), |
| 211 | CHref: repoHref(repo, "commit", c.GetId()), | 211 | CHref: repoHref(repo, "commit", c.GetId()), | ||
| 212 | } | 212 | } | ||
| 213 | if ts := c.GetCommitter().GetTime(); ts.IsValid() { | 213 | if ts := c.GetCommitter().GetTime(); ts.IsValid() { | ||
M internal/adapters/web/highlight.go +1 -32
| @@ -204,20 +204,6 @@ var invisibleNames = map[rune]string{ | |||
| 204 | 204 | 0xFEFF: "byte order mark", | |
| 205 | 205 | } | |
| 206 | 206 | ||
| 207 | - | // isInvisible reports whether r changes how code reads without being seen: | |
| 208 | - | // the bidirectional controls of "Trojan Source" attacks (CVE-2021-42574) and | |
| 209 | - | // the other format characters, the controls, the line and paragraph | |
| 210 | - | // separators and the blank Hangul fillers. A tab is ordinary. | |
| 211 | - | func isInvisible(r rune) bool { | |
| 212 | - | switch r { | |
| 213 | - | case '\t', '\n': | |
| 214 | - | return false | |
| 215 | - | case 0x115F, 0x1160, 0x3164, 0xFFA0: | |
| 216 | - | return true | |
| 217 | - | } | |
| 218 | - | return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) | |
| 219 | - | } | |
| 220 | - | ||
| 221 | 207 | // codePoint is "U+202E". | |
| 222 | 208 | func codePoint(r rune) string { return fmt.Sprintf("U+%04X", r) } | |
| 223 | 209 | ||
| @@ -237,7 +223,7 @@ func invisibleName(r rune) string { | |||
| 237 | 223 | // </span>. The character itself is never output. | |
| 238 | 224 | func writeMarked(b *strings.Builder, s string) { | |
| 239 | 225 | for { | |
| 240 | - | i := strings.IndexFunc(s, isInvisible) | |
| 226 | + | i := strings.IndexFunc(s, invisible) | |
| 241 | 227 | if i < 0 { | |
| 242 | 228 | b.WriteString(html.EscapeString(s)) | |
| 243 | 229 | return | |
| @@ -249,20 +235,3 @@ func writeMarked(b *strings.Builder, s string) { | |||
| 249 | 235 | s = s[i+size:] | |
| 250 | 236 | } | |
| 251 | 237 | } | |
| 252 | - | ||
| 253 | - | // markedText is s as plain text with each invisible character replaced by | |
| 254 | - | // its <U+XXXX> marker, for names and paths shown outside the code view. | |
| 255 | - | func markedText(s string) string { | |
| 256 | - | if !strings.ContainsFunc(s, isInvisible) { | |
| 257 | - | return s | |
| 258 | - | } | |
| 259 | - | var b strings.Builder | |
| 260 | - | for _, r := range s { | |
| 261 | - | if isInvisible(r) { | |
| 262 | - | b.WriteString("<" + codePoint(r) + ">") | |
| 263 | - | } else { | |
| 264 | - | b.WriteRune(r) | |
| 265 | - | } | |
| 266 | - | } | |
| 267 | - | return b.String() | |
| 268 | - | } | |
| @@ -204,20 +204,6 @@ var invisibleNames = map[rune]string{ | |||||
| 204 | 0xFEFF: "byte order mark", | 204 | 0xFEFF: "byte order mark", | ||
| 205 | } | 205 | } | ||
| 206 | 206 | ||||
| 207 | - | // isInvisible reports whether r changes how code reads without being seen: | |||
| 208 | - | // the bidirectional controls of "Trojan Source" attacks (CVE-2021-42574) and | |||
| 209 | - | // the other format characters, the controls, the line and paragraph | |||
| 210 | - | // separators and the blank Hangul fillers. A tab is ordinary. | |||
| 211 | - | func isInvisible(r rune) bool { | |||
| 212 | - | switch r { | |||
| 213 | - | case '\t', '\n': | |||
| 214 | - | return false | |||
| 215 | - | case 0x115F, 0x1160, 0x3164, 0xFFA0: | |||
| 216 | - | return true | |||
| 217 | - | } | |||
| 218 | - | return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | // codePoint is "U+202E". | 207 | // codePoint is "U+202E". | ||
| 222 | func codePoint(r rune) string { return fmt.Sprintf("U+%04X", r) } | 208 | func codePoint(r rune) string { return fmt.Sprintf("U+%04X", r) } | ||
| 223 | 209 | ||||
| @@ -237,7 +223,7 @@ func invisibleName(r rune) string { | |||||
| 237 | // </span>. The character itself is never output. | 223 | // </span>. The character itself is never output. | ||
| 238 | func writeMarked(b *strings.Builder, s string) { | 224 | func writeMarked(b *strings.Builder, s string) { | ||
| 239 | for { | 225 | for { | ||
| 240 | - | i := strings.IndexFunc(s, isInvisible) | 226 | + | i := strings.IndexFunc(s, invisible) |
| 241 | if i < 0 { | 227 | if i < 0 { | ||
| 242 | b.WriteString(html.EscapeString(s)) | 228 | b.WriteString(html.EscapeString(s)) | ||
| 243 | return | 229 | return | ||
| @@ -249,20 +235,3 @@ func writeMarked(b *strings.Builder, s string) { | |||||
| 249 | s = s[i+size:] | 235 | s = s[i+size:] | ||
| 250 | } | 236 | } | ||
| 251 | } | 237 | } | ||
| 252 | - | ||||
| 253 | - | // markedText is s as plain text with each invisible character replaced by | |||
| 254 | - | // its <U+XXXX> marker, for names and paths shown outside the code view. | |||
| 255 | - | func markedText(s string) string { | |||
| 256 | - | if !strings.ContainsFunc(s, isInvisible) { | |||
| 257 | - | return s | |||
| 258 | - | } | |||
| 259 | - | var b strings.Builder | |||
| 260 | - | for _, r := range s { | |||
| 261 | - | if isInvisible(r) { | |||
| 262 | - | b.WriteString("<" + codePoint(r) + ">") | |||
| 263 | - | } else { | |||
| 264 | - | b.WriteRune(r) | |||
| 265 | - | } | |||
| 266 | - | } | |||
| 267 | - | return b.String() | |||
| 268 | - | } | |||
M internal/adapters/web/highlight_test.go +0 -5
| @@ -59,11 +59,6 @@ func TestHighlightMarksInvisibleCharacters(t *testing.T) { | |||
| 59 | 59 | assert.Contains(t, h, "<U+202E>") | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | - | func TestMarkedText(t *testing.T) { | |
| 63 | - | assert.Equal(t, "plain.go", markedText("plain.go")) | |
| 64 | - | assert.Equal(t, "a<U+202E>b", markedText("a\u202eb")) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | 62 | // The lexers do not lose or add characters: the text of the lines is the | |
| 68 | 63 | // source, whatever the language. | |
| 69 | 64 | func TestHighlightKeepsTheSource(t *testing.T) { | |
| @@ -59,11 +59,6 @@ func TestHighlightMarksInvisibleCharacters(t *testing.T) { | |||||
| 59 | assert.Contains(t, h, "<U+202E>") | 59 | assert.Contains(t, h, "<U+202E>") | ||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | - | func TestMarkedText(t *testing.T) { | |||
| 63 | - | assert.Equal(t, "plain.go", markedText("plain.go")) | |||
| 64 | - | assert.Equal(t, "a<U+202E>b", markedText("a\u202eb")) | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | // The lexers do not lose or add characters: the text of the lines is the | 62 | // The lexers do not lose or add characters: the text of the lines is the | ||
| 68 | // source, whatever the language. | 63 | // source, whatever the language. | ||
| 69 | func TestHighlightKeepsTheSource(t *testing.T) { | 64 | func TestHighlightKeepsTheSource(t *testing.T) { | ||
M internal/adapters/web/tree.go +5 -5
| @@ -107,7 +107,7 @@ func fileCrumbs(repo, kind string, t target) []Crumb { | |||
| 107 | 107 | segs := strings.Split(t.path, "/") | |
| 108 | 108 | for i, seg := range segs { | |
| 109 | 109 | last := i == len(segs)-1 | |
| 110 | - | c := Crumb{Label: markedText(seg), Current: last} | |
| 110 | + | c := Crumb{Label: visibleText(seg), Current: last} | |
| 111 | 111 | if !last || kind == "tree" { | |
| 112 | 112 | c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/")) | |
| 113 | 113 | } else { | |
| @@ -166,7 +166,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 166 | 166 | p := TreePage{} | |
| 167 | 167 | l := s.repoLayout(ctx, d, "tree") | |
| 168 | 168 | for i, e := range entries { | |
| 169 | - | row := TreeRow{Glyph: "·", Name: markedText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0} | |
| 169 | + | row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0} | |
| 170 | 170 | switch e.GetKind() { | |
| 171 | 171 | case pb.EntryKind_ENTRY_KIND_DIRECTORY: | |
| 172 | 172 | row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-" | |
| @@ -179,11 +179,11 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 179 | 179 | default: | |
| 180 | 180 | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | |
| 181 | 181 | if len(l.Cmd) < maxFileTargets { | |
| 182 | - | l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: markedText(e.GetPath()), Href: row.Href}) | |
| 182 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: visibleText(e.GetPath()), Href: row.Href}) | |
| 183 | 183 | } | |
| 184 | 184 | } | |
| 185 | 185 | if c := e.GetLastCommit(); c != nil { | |
| 186 | - | row.Subject, row.Author = markedText(c.GetSummary()), c.GetAuthor().GetName() | |
| 186 | + | row.Subject, row.Author = visibleText(c.GetSummary()), c.GetAuthor().GetName() | |
| 187 | 187 | row.CHref = repoHref(d.name(), "commit", c.GetId()) | |
| 188 | 188 | if ts := c.GetCommitter().GetTime(); ts.IsValid() { | |
| 189 | 189 | row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime()) | |
| @@ -210,7 +210,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 210 | 210 | if t.path == "" { | |
| 211 | 211 | l.Title = "tree · " + d.name() + " · " + s.instance | |
| 212 | 212 | } else { | |
| 213 | - | l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance | |
| 213 | + | l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance | |
| 214 | 214 | } | |
| 215 | 215 | l.Status = Status{ | |
| 216 | 216 | Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"), | |
| @@ -107,7 +107,7 @@ func fileCrumbs(repo, kind string, t target) []Crumb { | |||||
| 107 | segs := strings.Split(t.path, "/") | 107 | segs := strings.Split(t.path, "/") | ||
| 108 | for i, seg := range segs { | 108 | for i, seg := range segs { | ||
| 109 | last := i == len(segs)-1 | 109 | last := i == len(segs)-1 | ||
| 110 | - | c := Crumb{Label: markedText(seg), Current: last} | 110 | + | c := Crumb{Label: visibleText(seg), Current: last} |
| 111 | if !last || kind == "tree" { | 111 | if !last || kind == "tree" { | ||
| 112 | c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/")) | 112 | c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/")) | ||
| 113 | } else { | 113 | } else { | ||
| @@ -166,7 +166,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 166 | p := TreePage{} | 166 | p := TreePage{} | ||
| 167 | l := s.repoLayout(ctx, d, "tree") | 167 | l := s.repoLayout(ctx, d, "tree") | ||
| 168 | for i, e := range entries { | 168 | for i, e := range entries { | ||
| 169 | - | row := TreeRow{Glyph: "·", Name: markedText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0} | 169 | + | row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0} |
| 170 | switch e.GetKind() { | 170 | switch e.GetKind() { | ||
| 171 | case pb.EntryKind_ENTRY_KIND_DIRECTORY: | 171 | case pb.EntryKind_ENTRY_KIND_DIRECTORY: | ||
| 172 | row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-" | 172 | row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-" | ||
| @@ -179,11 +179,11 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 179 | default: | 179 | default: | ||
| 180 | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | 180 | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | ||
| 181 | if len(l.Cmd) < maxFileTargets { | 181 | if len(l.Cmd) < maxFileTargets { | ||
| 182 | - | l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: markedText(e.GetPath()), Href: row.Href}) | 182 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: visibleText(e.GetPath()), Href: row.Href}) |
| 183 | } | 183 | } | ||
| 184 | } | 184 | } | ||
| 185 | if c := e.GetLastCommit(); c != nil { | 185 | if c := e.GetLastCommit(); c != nil { | ||
| 186 | - | row.Subject, row.Author = markedText(c.GetSummary()), c.GetAuthor().GetName() | 186 | + | row.Subject, row.Author = visibleText(c.GetSummary()), c.GetAuthor().GetName() |
| 187 | row.CHref = repoHref(d.name(), "commit", c.GetId()) | 187 | row.CHref = repoHref(d.name(), "commit", c.GetId()) | ||
| 188 | if ts := c.GetCommitter().GetTime(); ts.IsValid() { | 188 | if ts := c.GetCommitter().GetTime(); ts.IsValid() { | ||
| 189 | row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime()) | 189 | row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime()) | ||
| @@ -210,7 +210,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||||
| 210 | if t.path == "" { | 210 | if t.path == "" { | ||
| 211 | l.Title = "tree · " + d.name() + " · " + s.instance | 211 | l.Title = "tree · " + d.name() + " · " + s.instance | ||
| 212 | } else { | 212 | } else { | ||
| 213 | - | l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance | 213 | + | l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance |
| 214 | } | 214 | } | ||
| 215 | l.Status = Status{ | 215 | l.Status = Status{ | ||
| 216 | Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"), | 216 | Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"), | ||