cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
29
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}