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
+288 -1
Diff #5
+132
appview/pages/markup/extension/codecopy.go
··· 1 + package extension 2 + 3 + import ( 4 + "io/fs" 5 + "path/filepath" 6 + "strings" 7 + 8 + "github.com/yuin/goldmark" 9 + "github.com/yuin/goldmark/ast" 10 + "github.com/yuin/goldmark/parser" 11 + "github.com/yuin/goldmark/renderer" 12 + "github.com/yuin/goldmark/text" 13 + "github.com/yuin/goldmark/util" 14 + ) 15 + 16 + // KindCodeCopyBlock is a NodeKind of the CodeCopyBlock node. 17 + var KindCodeCopyBlock = ast.NewNodeKind("CodeCopyBlock") 18 + 19 + // CodeCopyBlock wraps a FencedCodeBlock to add a copy-to-clipboard button. 20 + type CodeCopyBlock struct { 21 + ast.BaseBlock 22 + } 23 + 24 + var _ ast.Node = (*CodeCopyBlock)(nil) 25 + 26 + func (n *CodeCopyBlock) Kind() ast.NodeKind { 27 + return KindCodeCopyBlock 28 + } 29 + 30 + func (n *CodeCopyBlock) Dump(source []byte, level int) { 31 + ast.DumpHelper(n, source, level, nil, nil) 32 + } 33 + 34 + // codeCopyTransformer wraps FencedCodeBlock nodes in CodeCopyBlock nodes. 35 + type codeCopyTransformer struct{} 36 + 37 + func (t *codeCopyTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { 38 + var fencedBlocks []*ast.FencedCodeBlock 39 + 40 + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 41 + if !entering { 42 + return ast.WalkContinue, nil 43 + } 44 + if fcb, ok := n.(*ast.FencedCodeBlock); ok { 45 + lang := string(fcb.Language(reader.Source())) 46 + if lang != "mermaid" { 47 + fencedBlocks = append(fencedBlocks, fcb) 48 + } 49 + } 50 + return ast.WalkContinue, nil 51 + }) 52 + 53 + for _, fcb := range fencedBlocks { 54 + wrapper := &CodeCopyBlock{} 55 + parent := fcb.Parent() 56 + parent.ReplaceChild(parent, fcb, wrapper) 57 + wrapper.AppendChild(wrapper, fcb) 58 + } 59 + } 60 + 61 + // codeCopyRenderer renders the CodeCopyBlock wrapper with a copy button. 62 + type codeCopyRenderer struct { 63 + copyIconSVG string 64 + checkIconSVG string 65 + } 66 + 67 + func (r *codeCopyRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 68 + reg.Register(KindCodeCopyBlock, r.renderCodeCopyBlock) 69 + } 70 + 71 + func (r *codeCopyRenderer) renderCodeCopyBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 72 + if entering { 73 + _, _ = w.WriteString(`<div class="code-copy-wrapper">`) 74 + } else { 75 + _, _ = w.WriteString( 76 + `<button class="code-copy-btn" type="button" aria-label="Copy code to clipboard">` + 77 + `<span class="copy-icon">` + r.copyIconSVG + `</span>` + 78 + `<span class="check-icon">` + r.checkIconSVG + `</span>` + 79 + `</button></div>`, 80 + ) 81 + } 82 + return ast.WalkContinue, nil 83 + } 84 + 85 + // CodeCopyExt is a goldmark extension that adds copy-to-clipboard buttons to fenced code blocks. 86 + type CodeCopyExt struct { 87 + files fs.FS 88 + } 89 + 90 + func NewCodeCopyExt(files fs.FS) *CodeCopyExt { 91 + return &CodeCopyExt{files: files} 92 + } 93 + 94 + func (e *CodeCopyExt) Extend(md goldmark.Markdown) { 95 + copyIcon := readIcon(e.files, "copy", "size-4") 96 + checkIcon := readIcon(e.files, "check", "size-4") 97 + 98 + md.Parser().AddOptions( 99 + parser.WithASTTransformers( 100 + util.Prioritized(&codeCopyTransformer{}, 500), 101 + ), 102 + ) 103 + md.Renderer().AddOptions( 104 + renderer.WithNodeRenderers( 105 + util.Prioritized(&codeCopyRenderer{ 106 + copyIconSVG: copyIcon, 107 + checkIconSVG: checkIcon, 108 + }, 500), 109 + ), 110 + ) 111 + } 112 + 113 + func readIcon(files fs.FS, name string, classes ...string) string { 114 + if files == nil { 115 + return "" 116 + } 117 + 118 + iconPath := filepath.Join("static", "icons", name+".svg") 119 + data, err := fs.ReadFile(files, iconPath) 120 + if err != nil { 121 + return "" 122 + } 123 + 124 + svgStr := string(data) 125 + svgTagEnd := strings.Index(svgStr, ">") 126 + if svgTagEnd == -1 { 127 + return svgStr 128 + } 129 + 130 + classTag := ` class="` + strings.Join(classes, " ") + `"` 131 + return svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:] 132 + }
+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
+18
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 el = pre.querySelector('code') || pre; 108 + var lines = el.querySelectorAll('.line'); 109 + var text = lines.length 110 + ? Array.prototype.map.call(lines, function(l) { return l.textContent; }).join('\n') 111 + : el.innerText; 112 + navigator.clipboard.writeText(text).then(function() { 113 + btn.classList.add('copied'); 114 + setTimeout(function() { btn.classList.remove('copied'); }, 2000); 115 + }); 116 + }); 117 + </script> 100 118 </body> 101 119 </html> 102 120 {{ 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

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