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
+287 -1
Diff #4
+134
appview/pages/markup/extension/codecopy.go
··· 1 + package extension 2 + 3 + import ( 4 + "fmt" 5 + "io/fs" 6 + "path/filepath" 7 + "strings" 8 + 9 + "github.com/yuin/goldmark" 10 + "github.com/yuin/goldmark/ast" 11 + "github.com/yuin/goldmark/parser" 12 + "github.com/yuin/goldmark/renderer" 13 + "github.com/yuin/goldmark/text" 14 + "github.com/yuin/goldmark/util" 15 + ) 16 + 17 + // KindCodeCopyBlock is a NodeKind of the CodeCopyBlock node. 18 + var KindCodeCopyBlock = ast.NewNodeKind("CodeCopyBlock") 19 + 20 + // CodeCopyBlock wraps a FencedCodeBlock to add a copy-to-clipboard button. 21 + type CodeCopyBlock struct { 22 + ast.BaseBlock 23 + } 24 + 25 + var _ ast.Node = (*CodeCopyBlock)(nil) 26 + 27 + func (n *CodeCopyBlock) Kind() ast.NodeKind { 28 + return KindCodeCopyBlock 29 + } 30 + 31 + func (n *CodeCopyBlock) Dump(source []byte, level int) { 32 + ast.DumpHelper(n, source, level, nil, nil) 33 + } 34 + 35 + // codeCopyTransformer wraps FencedCodeBlock nodes in CodeCopyBlock nodes. 36 + type codeCopyTransformer struct{} 37 + 38 + func (t *codeCopyTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { 39 + var fencedBlocks []*ast.FencedCodeBlock 40 + 41 + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 42 + if !entering { 43 + return ast.WalkContinue, nil 44 + } 45 + if fcb, ok := n.(*ast.FencedCodeBlock); ok { 46 + lang := string(fcb.Language(reader.Source())) 47 + if lang != "mermaid" { 48 + fencedBlocks = append(fencedBlocks, fcb) 49 + } 50 + } 51 + return ast.WalkContinue, nil 52 + }) 53 + 54 + for _, fcb := range fencedBlocks { 55 + wrapper := &CodeCopyBlock{} 56 + parent := fcb.Parent() 57 + parent.ReplaceChild(parent, fcb, wrapper) 58 + wrapper.AppendChild(wrapper, fcb) 59 + } 60 + } 61 + 62 + // codeCopyRenderer renders the CodeCopyBlock wrapper with a copy button. 63 + type codeCopyRenderer struct { 64 + copyIconSVG string 65 + checkIconSVG string 66 + } 67 + 68 + func (r *codeCopyRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 69 + reg.Register(KindCodeCopyBlock, r.renderCodeCopyBlock) 70 + } 71 + 72 + func (r *codeCopyRenderer) renderCodeCopyBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 73 + if entering { 74 + _, _ = w.WriteString(`<div class="code-copy-wrapper">`) 75 + } else { 76 + _, _ = w.WriteString(fmt.Sprintf( 77 + `<button class="code-copy-btn" type="button" aria-label="Copy code to clipboard">`+ 78 + `<span class="copy-icon">%s</span>`+ 79 + `<span class="check-icon">%s</span>`+ 80 + `</button></div>`, 81 + r.copyIconSVG, r.checkIconSVG, 82 + )) 83 + } 84 + return ast.WalkContinue, nil 85 + } 86 + 87 + // CodeCopyExt is a goldmark extension that adds copy-to-clipboard buttons to fenced code blocks. 88 + type CodeCopyExt struct { 89 + files fs.FS 90 + } 91 + 92 + func NewCodeCopyExt(files fs.FS) *CodeCopyExt { 93 + return &CodeCopyExt{files: files} 94 + } 95 + 96 + func (e *CodeCopyExt) Extend(md goldmark.Markdown) { 97 + copyIcon := readIcon(e.files, "copy", "size-4") 98 + checkIcon := readIcon(e.files, "check", "size-4") 99 + 100 + md.Parser().AddOptions( 101 + parser.WithASTTransformers( 102 + util.Prioritized(&codeCopyTransformer{}, 500), 103 + ), 104 + ) 105 + md.Renderer().AddOptions( 106 + renderer.WithNodeRenderers( 107 + util.Prioritized(&codeCopyRenderer{ 108 + copyIconSVG: copyIcon, 109 + checkIconSVG: checkIcon, 110 + }, 500), 111 + ), 112 + ) 113 + } 114 + 115 + func readIcon(files fs.FS, name string, classes ...string) string { 116 + if files == nil { 117 + return "" 118 + } 119 + 120 + iconPath := filepath.Join("static", "icons", name+".svg") 121 + data, err := fs.ReadFile(files, iconPath) 122 + if err != nil { 123 + return "" 124 + } 125 + 126 + svgStr := string(data) 127 + svgTagEnd := strings.Index(svgStr, ">") 128 + if svgTagEnd == -1 { 129 + return svgStr 130 + } 131 + 132 + classTag := ` class="` + strings.Join(classes, " ") + `"` 133 + return svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:] 134 + }
+3 -1
appview/pages/markup/markdown.go
··· 85 85 } 86 86 87 87 func (rctx *RenderContext) RenderMarkdown(source string) string { 88 - return rctx.RenderMarkdownWith(source, NewMarkdown(rctx.Hostname)) 88 + md := NewMarkdown(rctx.Hostname) 89 + textension.NewCodeCopyExt(rctx.Files).Extend(md) 90 + return rctx.RenderMarkdownWith(source, md) 89 91 } 90 92 91 93 func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string {
+86
appview/pages/markup/markdown_test.go
··· 4 4 "bytes" 5 5 "strings" 6 6 "testing" 7 + 8 + textension "tangled.org/core/appview/pages/markup/extension" 7 9 ) 8 10 9 11 func TestMermaidExtension(t *testing.T) { ··· 165 167 }) 166 168 } 167 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 + }
+13
appview/pages/markup/sanitizer.go
··· 120 120 // goldmark-callout 121 121 policy.AllowAttrs("data-callout").OnElements("details") 122 122 123 + // code copy button 124 + policy.AllowElements("button") 125 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`code-copy-btn`)).OnElements("button") 126 + policy.AllowAttrs("type", "aria-label").OnElements("button") 127 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`code-copy-wrapper`)).OnElements("div") 128 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`copy-icon|check-icon`)).OnElements("span") 129 + 130 + // SVG elements for code copy icons 131 + policy.AllowElements("svg", "path", "rect") 132 + policy.AllowAttrs("xmlns", "viewBox", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "class").OnElements("svg") 133 + policy.AllowAttrs("d").OnElements("path") 134 + policy.AllowAttrs("x", "y", "rx", "ry").OnElements("rect") 135 + 123 136 return policy 124 137 } 125 138
+15
appview/pages/templates/layouts/base.html
··· 97 97 {{ template "layouts/fragments/footer" . }} 98 98 </footer> 99 99 {{ end }} 100 + 101 + <script> 102 + document.addEventListener('click', function(e) { 103 + var btn = e.target.closest('.code-copy-btn'); 104 + if (!btn) return; 105 + var pre = btn.closest('.code-copy-wrapper').querySelector('pre'); 106 + if (!pre) return; 107 + var code = pre.querySelector('code'); 108 + var text = code ? code.innerText : pre.innerText; 109 + navigator.clipboard.writeText(text).then(function() { 110 + btn.classList.add('copied'); 111 + setTimeout(function() { btn.classList.remove('copied'); }, 2000); 112 + }); 113 + }); 114 + </script> 100 115 </body> 101 116 </html> 102 117 {{ end }}
+36
input.css
··· 220 220 @apply flex justify-center my-4 overflow-x-auto bg-transparent border-0; 221 221 } 222 222 223 + .code-copy-wrapper { 224 + @apply relative; 225 + } 226 + 227 + .code-copy-btn { 228 + @apply absolute top-2 right-2 p-1.5 rounded cursor-pointer; 229 + @apply text-gray-500 bg-gray-200/80 dark:text-gray-400 dark:bg-gray-800/80; 230 + @apply opacity-0 transition-opacity duration-150 ease-in-out; 231 + @apply leading-none border-0 z-10; 232 + } 233 + 234 + .code-copy-wrapper:hover .code-copy-btn, 235 + .code-copy-btn:focus { 236 + @apply opacity-100; 237 + } 238 + 239 + .code-copy-btn:hover { 240 + @apply bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-200; 241 + } 242 + 243 + .code-copy-btn.copied { 244 + @apply text-green-600 dark:text-green-400; 245 + } 246 + 247 + .code-copy-btn .check-icon { 248 + @apply hidden; 249 + } 250 + 251 + .code-copy-btn.copied .copy-icon { 252 + @apply hidden; 253 + } 254 + 255 + .code-copy-btn.copied .check-icon { 256 + @apply block; 257 + } 258 + 223 259 /* Base callout */ 224 260 details[data-callout] { 225 261 @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4;

History

5 rounds 5 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 2 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