cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
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("")
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}