Monorepo for Tangled tangled.org

[feature] - Add copy-to-clipboard button for markdown code blocks #1114

Summary#

  • Injects a copy button into .prose pre.chroma code blocks via client-side DOM injection in base.html
  • Scoped to .prose so blob view source code (which has its own UX) is unaffected
  • Handles HTMX-swapped content (dynamic comment loading) via htmx:afterSettle listener

Test plan#

  • go test ./appview/pages/markup/... passes (no regressions)
  • Verify button appears on hover over code blocks in rendered markdown
  • Verify clicking copies code and shows check icon briefly
  • Verify dark mode styling
  • Verify blob view code blocks do NOT get copy buttons
Labels

None yet.

assignee

None yet.

Participants 3
AT URI
at://did:plc:c7frv4rcitff3p2nh7of5bcv/sh.tangled.repo.pull/3mgbmydvrsw22
+86
Interdiff #3 โ†’ #4
appview/pages/markup/extension/codecopy.go

This file has not been changed.

appview/pages/markup/markdown.go

This file has not been changed.

appview/pages/markup/sanitizer.go

This file has not been changed.

appview/pages/templates/layouts/base.html

This file has not been changed.

input.css

This file has not been changed.

+86
appview/pages/markup/markdown_test.go
··· 4 "bytes" 5 "strings" 6 "testing" 7 ) 8 9 func TestMermaidExtension(t *testing.T) { ··· 165 }) 166 } 167 }
··· 4 "bytes" 5 "strings" 6 "testing" 7 + 8 + textension "tangled.org/core/appview/pages/markup/extension" 9 ) 10 11 func TestMermaidExtension(t *testing.T) { ··· 167 }) 168 } 169 } 170 + 171 + func TestCodeCopyExtension(t *testing.T) { 172 + tests := []struct { 173 + name string 174 + markdown string 175 + contains string 176 + notContains string 177 + }{ 178 + { 179 + name: "fenced code block gets copy wrapper", 180 + markdown: "```go\nfunc main() {}\n```", 181 + contains: `<div class="code-copy-wrapper">`, 182 + }, 183 + { 184 + name: "fenced code block gets copy button", 185 + markdown: "```go\nfunc main() {}\n```", 186 + contains: `<button class="code-copy-btn"`, 187 + }, 188 + { 189 + name: "copy button has both icon spans", 190 + markdown: "```go\nfunc main() {}\n```", 191 + contains: `<span class="copy-icon">`, 192 + }, 193 + { 194 + name: "mermaid blocks are not wrapped", 195 + markdown: "```mermaid\ngraph TD\n A-->B\n```", 196 + notContains: `code-copy-wrapper`, 197 + }, 198 + { 199 + name: "inline code is not affected", 200 + markdown: "use `fmt.Println` here", 201 + notContains: `code-copy-wrapper`, 202 + }, 203 + { 204 + name: "fenced block without language gets wrapper", 205 + markdown: "```\nsome text\n```", 206 + contains: `<div class="code-copy-wrapper">`, 207 + }, 208 + } 209 + 210 + for _, tt := range tests { 211 + t.Run(tt.name, func(t *testing.T) { 212 + md := NewMarkdown("tangled.org") 213 + textension.NewCodeCopyExt(nil).Extend(md) 214 + 215 + var buf bytes.Buffer 216 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 217 + t.Fatalf("failed to convert markdown: %v", err) 218 + } 219 + 220 + result := buf.String() 221 + if tt.contains != "" && !strings.Contains(result, tt.contains) { 222 + t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result) 223 + } 224 + if tt.notContains != "" && strings.Contains(result, tt.notContains) { 225 + t.Errorf("expected output NOT to contain:\n%s\ngot:\n%s", tt.notContains, result) 226 + } 227 + }) 228 + } 229 + } 230 + 231 + func TestCodeCopyButtonSurvivesSanitization(t *testing.T) { 232 + md := NewMarkdown("tangled.org") 233 + textension.NewCodeCopyExt(nil).Extend(md) 234 + 235 + var buf bytes.Buffer 236 + if err := md.Convert([]byte("```go\nfunc main() {}\n```"), &buf); err != nil { 237 + t.Fatalf("failed to convert markdown: %v", err) 238 + } 239 + 240 + sanitizer := NewSanitizer() 241 + result := sanitizer.SanitizeDefault(buf.String()) 242 + 243 + for _, expected := range []string{ 244 + `<div class="code-copy-wrapper">`, 245 + `<button class="code-copy-btn"`, 246 + `<span class="copy-icon">`, 247 + `<span class="check-icon">`, 248 + } { 249 + if !strings.Contains(result, expected) { 250 + t.Errorf("expected sanitized output to contain:\n%s\ngot:\n%s", expected, result) 251 + } 252 + } 253 + }

History

6 rounds 6 comments
sign up or login to add to the discussion
1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
no conflicts, ready to merge
expand 0 comments
1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
expand 3 comments

works brilliantly! one nit: the copy button seems to add an extra newline for each line on my browser. do you notice the same behavior on your end?

will do a more thorough code review shortly!

I still need to look into this!! I asked Claude in the meantime, and it thinks you're right - there's an unnecessary carriage return

1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
expand 0 comments
1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
expand 0 comments
1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
expand 3 comments

Thank you for the contribution! Few feedbacks:

  • here we can use icon template like others.
  • can we use tailwindcss for styling? using @applysyntax in css file.

i would prefer if we added this button using a goldmark extension instead of a clientside script!

Thank you for the feedback! I've updating the diff to use goldmark and apply tailwind classes.

1 commit
expand
appview/pages: add copy-to-clipboard button for markdown code blocks
expand 0 comments