cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 594 lines 15 kB view raw
1// Package public provides conversion between markdown and leaflet block formats 2// 3// Image handling follows a two-pass approach: 4// 1. Gather all image URLs from the markdown AST 5// 2. Resolve images (fetch bytes, get dimensions, upload to blob storage) 6// 3. Convert markdown to blocks using the resolved image metadata 7package public 8 9import ( 10 "bytes" 11 "fmt" 12 "image" 13 _ "image/gif" 14 _ "image/jpeg" 15 _ "image/png" 16 "os" 17 "path/filepath" 18 "strings" 19 20 "github.com/gomarkdown/markdown/ast" 21 "github.com/gomarkdown/markdown/parser" 22) 23 24// Converter defines the interface for converting between a document and leaflet formats 25type Converter interface { 26 // ToLeaflet converts content to leaflet blocks 27 ToLeaflet(content string) ([]BlockWrap, error) 28 // FromLeaflet converts leaflet blocks back to the original format 29 FromLeaflet(blocks []BlockWrap) (string, error) 30} 31 32// ImageInfo contains resolved image metadata 33type ImageInfo struct { 34 Blob Blob 35 Width int 36 Height int 37} 38 39// ImageResolver resolves image URLs to blob data and metadata 40type ImageResolver interface { 41 // ResolveImage resolves an image URL to blob data and dimensions 42 // The url parameter may be a local file path or remote URL 43 ResolveImage(url string) (*ImageInfo, error) 44} 45 46// LocalImageResolver resolves local file paths to image metadata 47type LocalImageResolver struct { 48 // Called to upload image bytes and get a blob reference 49 BlobUploader func(data []byte, mimeType string) (Blob, error) 50} 51 52// ResolveImage reads a local image file and extracts metadata 53func (r *LocalImageResolver) ResolveImage(path string) (*ImageInfo, error) { 54 data, err := os.ReadFile(path) 55 if err != nil { 56 return nil, fmt.Errorf("failed to read image: %w", err) 57 } 58 59 img, format, err := image.DecodeConfig(bytes.NewReader(data)) 60 if err != nil { 61 return nil, fmt.Errorf("failed to decode image: %w", err) 62 } 63 64 mimeType := "image/" + format 65 66 var blob Blob 67 if r.BlobUploader != nil { 68 blob, err = r.BlobUploader(data, mimeType) 69 if err != nil { 70 return nil, fmt.Errorf("failed to upload blob: %w", err) 71 } 72 } else { 73 blob = Blob{ 74 Type: TypeBlob, 75 Ref: CID{Link: "bafkreiplaceholder"}, 76 MimeType: mimeType, 77 Size: len(data), 78 } 79 } 80 81 return &ImageInfo{ 82 Blob: blob, 83 Width: img.Width, 84 Height: img.Height, 85 }, nil 86} 87 88// MarkdownConverter implements the [Converter] interface 89type MarkdownConverter struct { 90 extensions parser.Extensions 91 imageResolver ImageResolver 92 basePath string // Base path for resolving relative image paths 93} 94 95type formatContext struct { 96 features []FacetFeature 97 start int 98} 99 100// NewMarkdownConverter creates a new markdown converter 101func NewMarkdownConverter() *MarkdownConverter { 102 extensions := parser.CommonExtensions | parser.AutoHeadingIDs 103 return &MarkdownConverter{ 104 extensions: extensions, 105 } 106} 107 108// WithImageResolver sets an image resolver for the converter 109func (c *MarkdownConverter) WithImageResolver(resolver ImageResolver, basePath string) *MarkdownConverter { 110 c.imageResolver = resolver 111 c.basePath = basePath 112 return c 113} 114 115// ToLeaflet converts markdown to leaflet blocks 116func (c *MarkdownConverter) ToLeaflet(markdown string) ([]BlockWrap, error) { 117 p := parser.NewWithExtensions(c.extensions) 118 doc := p.Parse([]byte(markdown)) 119 imageURLs := c.gatherImages(doc) 120 121 resolvedImages := make(map[string]*ImageInfo) 122 if c.imageResolver != nil { 123 for _, url := range imageURLs { 124 resolvedPath := url 125 if !filepath.IsAbs(url) && c.basePath != "" { 126 resolvedPath = filepath.Join(c.basePath, url) 127 } 128 129 info, err := c.imageResolver.ResolveImage(resolvedPath) 130 if err != nil { 131 return nil, fmt.Errorf("failed to resolve image %s: %w", url, err) 132 } 133 resolvedImages[url] = info 134 } 135 } 136 137 var blocks []BlockWrap 138 139 for _, child := range doc.GetChildren() { 140 switch n := child.(type) { 141 case *ast.Heading: 142 if block := c.convertHeading(n, resolvedImages); block != nil { 143 blocks = append(blocks, *block) 144 } 145 case *ast.Paragraph: 146 convertedBlocks := c.convertParagraph(n, resolvedImages) 147 blocks = append(blocks, convertedBlocks...) 148 case *ast.CodeBlock: 149 if block := c.convertCodeBlock(n); block != nil { 150 blocks = append(blocks, *block) 151 } 152 case *ast.BlockQuote: 153 if block := c.convertBlockquote(n, resolvedImages); block != nil { 154 blocks = append(blocks, *block) 155 } 156 case *ast.List: 157 if block := c.convertList(n, resolvedImages); block != nil { 158 blocks = append(blocks, *block) 159 } 160 case *ast.HorizontalRule: 161 blocks = append(blocks, BlockWrap{ 162 Type: TypeBlock, 163 Block: HorizontalRuleBlock{ 164 Type: TypeHorizontalRuleBlock, 165 }, 166 }) 167 case *ast.Image: 168 if block := c.convertImage(n, resolvedImages); block != nil { 169 blocks = append(blocks, *block) 170 } 171 } 172 } 173 174 return blocks, nil 175} 176 177// gatherImages walks the AST and collects all image URLs 178func (c *MarkdownConverter) gatherImages(node ast.Node) []string { 179 var urls []string 180 181 ast.WalkFunc(node, func(n ast.Node, entering bool) ast.WalkStatus { 182 if !entering { 183 return ast.GoToNext 184 } 185 186 if img, ok := n.(*ast.Image); ok { 187 urls = append(urls, string(img.Destination)) 188 } 189 190 return ast.GoToNext 191 }) 192 193 return urls 194} 195 196// convertHeading converts an AST heading to a leaflet HeaderBlock 197func (c *MarkdownConverter) convertHeading(node *ast.Heading, resolvedImages map[string]*ImageInfo) *BlockWrap { 198 text, facets, _ := c.extractTextAndFacets(node, resolvedImages) 199 return &BlockWrap{ 200 Type: TypeBlock, 201 Block: HeaderBlock{ 202 Type: TypeHeaderBlock, 203 Level: node.Level, 204 Plaintext: text, 205 Facets: facets, 206 }, 207 } 208} 209 210// convertParagraph converts an AST paragraph to leaflet blocks 211func (c *MarkdownConverter) convertParagraph(node *ast.Paragraph, resolvedImages map[string]*ImageInfo) []BlockWrap { 212 text, facets, imageBlocks := c.extractTextAndFacets(node, resolvedImages) 213 214 if len(imageBlocks) > 0 { 215 return imageBlocks 216 } 217 218 if strings.TrimSpace(text) == "" { 219 return nil 220 } 221 222 return []BlockWrap{{ 223 Type: TypeBlock, 224 Block: TextBlock{ 225 Type: TypeTextBlock, 226 Plaintext: text, 227 Facets: facets, 228 }, 229 }} 230} 231 232// convertCodeBlock converts an AST code block to a leaflet CodeBlock 233func (c *MarkdownConverter) convertCodeBlock(node *ast.CodeBlock) *BlockWrap { 234 return &BlockWrap{ 235 Type: TypeBlock, 236 Block: CodeBlock{ 237 Type: TypeCodeBlock, 238 Plaintext: string(node.Literal), 239 Language: string(node.Info), 240 SyntaxHighlightingTheme: "catppuccin-mocha", 241 }, 242 } 243} 244 245// convertBlockquote converts an AST blockquote to a leaflet BlockquoteBlock 246func (c *MarkdownConverter) convertBlockquote(node *ast.BlockQuote, resolvedImages map[string]*ImageInfo) *BlockWrap { 247 text, facets, _ := c.extractTextAndFacets(node, resolvedImages) 248 return &BlockWrap{ 249 Type: TypeBlock, 250 Block: BlockquoteBlock{ 251 Type: TypeBlockquoteBlock, 252 Plaintext: text, 253 Facets: facets, 254 }, 255 } 256} 257 258// convertList converts an AST list to a leaflet UnorderedListBlock 259func (c *MarkdownConverter) convertList(node *ast.List, resolvedImages map[string]*ImageInfo) *BlockWrap { 260 var items []ListItem 261 262 for _, child := range node.Children { 263 if listItem, ok := child.(*ast.ListItem); ok { 264 item := c.convertListItem(listItem, resolvedImages) 265 if item != nil { 266 items = append(items, *item) 267 } 268 } 269 } 270 271 return &BlockWrap{ 272 Type: TypeBlock, 273 Block: UnorderedListBlock{ 274 Type: TypeUnorderedListBlock, 275 Children: items, 276 }, 277 } 278} 279 280// convertListItem converts an AST list item to a leaflet ListItem 281func (c *MarkdownConverter) convertListItem(node *ast.ListItem, resolvedImages map[string]*ImageInfo) *ListItem { 282 text, facets, _ := c.extractTextAndFacets(node, resolvedImages) 283 return &ListItem{ 284 Type: TypeListItem, 285 Content: TextBlock{ 286 Type: TypeTextBlock, 287 Plaintext: text, 288 Facets: facets, 289 }, 290 } 291} 292 293// convertImage converts an AST image to a leaflet ImageBlock 294func (c *MarkdownConverter) convertImage(node *ast.Image, resolvedImages map[string]*ImageInfo) *BlockWrap { 295 alt := string(node.Title) 296 if alt == "" { 297 for _, child := range node.Children { 298 if text, ok := child.(*ast.Text); ok { 299 alt = string(text.Literal) 300 break 301 } 302 } 303 } 304 305 info, hasInfo := resolvedImages[string(node.Destination)] 306 307 var blob Blob 308 var aspectRatio AspectRatio 309 310 if hasInfo { 311 blob = info.Blob 312 aspectRatio = AspectRatio{ 313 Type: TypeAspectRatio, 314 Width: info.Width, 315 Height: info.Height, 316 } 317 } else { 318 blob = Blob{ 319 Type: TypeBlob, 320 Ref: CID{Link: "bafkreiplaceholder"}, 321 MimeType: "image/jpeg", 322 Size: 0, 323 } 324 aspectRatio = AspectRatio{ 325 Type: TypeAspectRatio, 326 Width: 1, 327 Height: 1, 328 } 329 } 330 331 return &BlockWrap{ 332 Type: TypeBlock, 333 Block: ImageBlock{ 334 Type: TypeImageBlock, 335 Image: blob, 336 Alt: alt, 337 AspectRatio: aspectRatio, 338 }, 339 } 340} 341 342// extractTextAndFacets extracts plaintext, facets, and image blocks from an AST node 343func (c *MarkdownConverter) extractTextAndFacets(node ast.Node, resolvedImages map[string]*ImageInfo) (string, []Facet, []BlockWrap) { 344 var buf bytes.Buffer 345 var facets []Facet 346 var blocks []BlockWrap 347 offset := 0 348 349 var stack []formatContext 350 351 ast.WalkFunc(node, func(n ast.Node, entering bool) ast.WalkStatus { 352 switch v := n.(type) { 353 case *ast.Text: 354 if entering { 355 content := string(v.Literal) 356 buf.WriteString(content) 357 358 if len(stack) > 0 { 359 var allFeatures []FacetFeature 360 for _, ctx := range stack { 361 allFeatures = append(allFeatures, ctx.features...) 362 } 363 facet := Facet{ 364 Type: TypeFacet, 365 Index: ByteSlice{ 366 Type: TypeByteSlice, 367 ByteStart: offset, 368 ByteEnd: offset + len(content), 369 }, 370 Features: allFeatures, 371 } 372 facets = append(facets, facet) 373 } 374 375 offset += len(content) 376 } 377 case *ast.Strong: 378 if entering { 379 stack = append(stack, formatContext{ 380 features: []FacetFeature{FacetBold{Type: TypeFacetBold}}, 381 start: offset, 382 }) 383 } else { 384 if len(stack) > 0 { 385 stack = stack[:len(stack)-1] 386 } 387 } 388 case *ast.Emph: 389 if entering { 390 stack = append(stack, formatContext{ 391 features: []FacetFeature{FacetItalic{Type: TypeFacetItalic}}, 392 start: offset, 393 }) 394 } else { 395 if len(stack) > 0 { 396 stack = stack[:len(stack)-1] 397 } 398 } 399 case *ast.Del: 400 if entering { 401 stack = append(stack, formatContext{ 402 features: []FacetFeature{FacetStrikethrough{Type: TypeFacetStrike}}, 403 start: offset, 404 }) 405 } else { 406 if len(stack) > 0 { 407 stack = stack[:len(stack)-1] 408 } 409 } 410 case *ast.Code: 411 if entering { 412 content := string(v.Literal) 413 buf.WriteString(content) 414 415 facet := Facet{ 416 Type: TypeFacet, 417 Index: ByteSlice{ 418 Type: TypeByteSlice, 419 ByteStart: offset, 420 ByteEnd: offset + len(content), 421 }, 422 Features: []FacetFeature{FacetCode{Type: TypeFacetCode}}, 423 } 424 facets = append(facets, facet) 425 426 offset += len(content) 427 } 428 case *ast.Link: 429 if entering { 430 stack = append(stack, formatContext{ 431 features: []FacetFeature{FacetLink{ 432 Type: TypeFacetLink, 433 URI: string(v.Destination), 434 }}, 435 start: offset, 436 }) 437 } else { 438 if len(stack) > 0 { 439 stack = stack[:len(stack)-1] 440 } 441 } 442 case *ast.Image: 443 if entering { 444 if buf.Len() > 0 { 445 blocks = append(blocks, BlockWrap{ 446 Type: TypeBlock, 447 Block: TextBlock{ 448 Type: TypeTextBlock, 449 Plaintext: buf.String(), 450 Facets: facets, 451 }, 452 }) 453 buf.Reset() 454 facets = nil 455 offset = 0 456 } 457 458 if imgBlock := c.convertImage(v, resolvedImages); imgBlock != nil { 459 blocks = append(blocks, *imgBlock) 460 } 461 } 462 case *ast.Softbreak, *ast.Hardbreak: 463 if entering { 464 buf.WriteString(" ") 465 offset++ 466 } 467 } 468 return ast.GoToNext 469 }) 470 471 // If we created blocks, add any remaining text 472 if len(blocks) > 0 && buf.Len() > 0 { 473 blocks = append(blocks, BlockWrap{ 474 Type: TypeBlock, 475 Block: TextBlock{ 476 Type: TypeTextBlock, 477 Plaintext: buf.String(), 478 Facets: facets, 479 }, 480 }) 481 } 482 483 return buf.String(), facets, blocks 484} 485 486// FromLeaflet converts leaflet blocks back to markdown 487func (c *MarkdownConverter) FromLeaflet(blocks []BlockWrap) (string, error) { 488 var buf bytes.Buffer 489 for i, wrap := range blocks { 490 if i > 0 { 491 buf.WriteString("\n\n") 492 } 493 494 switch block := wrap.Block.(type) { 495 case TextBlock: 496 buf.WriteString(c.facetsToMarkdown(block.Plaintext, block.Facets)) 497 case HeaderBlock: 498 buf.WriteString(strings.Repeat("#", block.Level)) 499 buf.WriteString(" ") 500 buf.WriteString(c.facetsToMarkdown(block.Plaintext, block.Facets)) 501 case CodeBlock: 502 buf.WriteString("```") 503 if block.Language != "" { 504 buf.WriteString(block.Language) 505 } 506 buf.WriteString("\n") 507 buf.WriteString(block.Plaintext) 508 if !strings.HasSuffix(block.Plaintext, "\n") { 509 buf.WriteString("\n") 510 } 511 buf.WriteString("```") 512 case BlockquoteBlock: 513 buf.WriteString("> ") 514 buf.WriteString(c.facetsToMarkdown(block.Plaintext, block.Facets)) 515 case UnorderedListBlock: 516 c.listToMarkdown(&buf, block.Children, 0) 517 case HorizontalRuleBlock: 518 buf.WriteString("---") 519 case ImageBlock: 520 buf.WriteString("![") 521 buf.WriteString(block.Alt) 522 buf.WriteString("](image-placeholder)") 523 default: 524 return "", fmt.Errorf("unsupported block type: %T", block) 525 } 526 } 527 528 return buf.String(), nil 529} 530 531// facetsToMarkdown applies facets to plaintext and generates markdown 532func (c *MarkdownConverter) facetsToMarkdown(text string, facets []Facet) string { 533 if len(facets) == 0 { 534 return text 535 } 536 537 var buf bytes.Buffer 538 lastEnd := 0 539 540 for _, facet := range facets { 541 if facet.Index.ByteStart > lastEnd { 542 buf.WriteString(text[lastEnd:facet.Index.ByteStart]) 543 } 544 545 facetText := text[facet.Index.ByteStart:facet.Index.ByteEnd] 546 547 for _, feature := range facet.Features { 548 switch f := feature.(type) { 549 case FacetBold: 550 facetText = "**" + facetText + "**" 551 case FacetItalic: 552 facetText = "*" + facetText + "*" 553 case FacetCode: 554 facetText = "`" + facetText + "`" 555 case FacetStrikethrough: 556 facetText = "~~" + facetText + "~~" 557 case FacetLink: 558 facetText = "[" + facetText + "](" + f.URI + ")" 559 } 560 } 561 562 buf.WriteString(facetText) 563 lastEnd = facet.Index.ByteEnd 564 } 565 566 if lastEnd < len(text) { 567 buf.WriteString(text[lastEnd:]) 568 } 569 570 return buf.String() 571} 572 573// listToMarkdown converts a list to markdown with proper indentation 574func (c *MarkdownConverter) listToMarkdown(buf *bytes.Buffer, items []ListItem, depth int) { 575 indent := strings.Repeat(" ", depth) 576 577 for _, item := range items { 578 buf.WriteString(indent) 579 buf.WriteString("- ") 580 581 switch content := item.Content.(type) { 582 case TextBlock: 583 buf.WriteString(c.facetsToMarkdown(content.Plaintext, content.Facets)) 584 case HeaderBlock: 585 buf.WriteString(c.facetsToMarkdown(content.Plaintext, content.Facets)) 586 } 587 588 buf.WriteString("\n") 589 590 if len(item.Children) > 0 { 591 c.listToMarkdown(buf, item.Children, depth+1) 592 } 593 } 594}