···3636}
37373838type Page struct {
3939- Type string `json:"$type"` // pub.leaflet.pages.linearDocument
3939+ Type string `json:"$type"` // pub.leaflet.pages.linearDocument
4040 Blocks []BlockWrapper `json:"blocks"`
4141}
4242···6868}
69697070type ListItem struct {
7171- Type string `json:"$type"` // pub.leaflet.blocks.unorderedList#listItem
7272- Content json.RawMessage `json:"content"` // Usually a TextBlock
7373- Children []ListItem `json:"children"` // Nested lists?
7171+ Type string `json:"$type"` // pub.leaflet.blocks.unorderedList#listItem
7272+ Content json.RawMessage `json:"content"` // Usually a TextBlock
7373+ Children []ListItem `json:"children"` // Nested lists?
7474}
75757676type ImageBlock struct {
···8080}
81818282type BskyPostBlock struct {
8383- Type string `json:"$type"`
8484- PostRef PostRef `json:"postRef"`
8383+ Type string `json:"$type"`
8484+ PostRef PostRef `json:"postRef"`
8585}
86868787type PostRef struct {
8888- Uri string `json:"uri"`
8989- Cid string `json:"cid"`
8888+ Uri string `json:"uri"`
8989+ Cid string `json:"cid"`
9090}
91919292// Shared Types
···108108}
109109110110type Embed struct {
111111- Type string `json:"$type"`
112112- Images []ImageEmbed `json:"images,omitempty"`
113113- External *ExternalEmbed `json:"external,omitempty"`
111111+ Type string `json:"$type"`
112112+ Images []ImageEmbed `json:"images,omitempty"`
113113+ External *ExternalEmbed `json:"external,omitempty"`
114114}
115115116116type ImageEmbed struct {
···133133134134type BlobRef struct {
135135 Link string `json:"$link"`
136136-}136136+}
+50-74
internal/converter/markdown.go
···99)
10101111type Converter struct {
1212- // We might need to track image downloads here or just return image references?
1313- // For now, let's just return the Markdown text and a list of image blobs to download.
1212+ // No state needed; conversion is stateless
1413}
15141615type ConversionResult struct {
···7271 if err := json.Unmarshal(blockWrapper.Block, &imgBlock); err != nil {
7372 continue
7473 }
7575- // We use a placeholder path that main.go will resolve
7676- // Actually, main.go needs to know about this.
7777- // Let's assume standard markdown image syntax: 
7878- // The downloader will replace it or we pre-calculate the path?
7979- // Better: Return the blob CID as the URL, and main.go does a string replace or we handle it here if we pass config.
8080- // For now, let's use the blob ref link as the URL.
7474+ // Use blob CID as placeholder URL; main.go replaces it with the local path
8175 sb.WriteString(fmt.Sprintf("\n\n", imgBlock.Alt, imgBlock.Image.Ref.Link))
8276 images = append(images, ImageRef{Blob: imgBlock.Image, Alt: imgBlock.Alt})
83778484- case "pub.leaflet.blocks.bskyPost":
8585- var postBlock atproto.BskyPostBlock
8686- if err := json.Unmarshal(blockWrapper.Block, &postBlock); err != nil {
8787- continue
8888- }
8989- // Render a link to the post for now, as we can't easily embed it without JS
9090- postURL := fmt.Sprintf("https://bsky.app/profile/%s/post/%s", "did:...", lastPathPart(postBlock.PostRef.Uri))
9191- // We don't have the handle here easily to make a pretty URL, but we can try.
9292- // Actually, let's just make a blockquote link.
9393- sb.WriteString(fmt.Sprintf("> [View on Bluesky](%s)\n\n", postURL)) // TODO: Improve this
7878+ case "pub.leaflet.blocks.bskyPost":
7979+ var postBlock atproto.BskyPostBlock
8080+ if err := json.Unmarshal(blockWrapper.Block, &postBlock); err != nil {
8181+ continue
8282+ }
8383+ // Render as a blockquote link to the Bluesky post
8484+ postURL := fmt.Sprintf("https://bsky.app/profile/%s/post/%s", "did:...", lastPathPart(postBlock.PostRef.Uri))
8585+ sb.WriteString(fmt.Sprintf("> [View on Bluesky](%s)\n\n", postURL))
9486 }
9587 }
9688 }
···10294}
1039510496func (c *Converter) renderText(block *atproto.TextBlock) string {
105105- // Apply facets
106106- // Facets are ranges. We need to insert markdown syntax at specific indices.
107107- // This is tricky because inserting characters shifts indices.
108108- // Best approach: Slice the string and reconstruct it.
9797+ // Apply facets to convert rich text to markdown
9898+ // Note: ATProto facets use byte offsets, not rune offsets
9999+ data := []byte(block.Plaintext)
109100110110- // Sort facets by start index (descending) to avoid shifting issues?
111111- // Actually, we should iterate from start to end, keeping track of current index.
112112-113113- // Simplify: Just handle links for now.
114114- // NOTE: Facets in ATProto are byte-offsets, not rune-offsets. Go strings are UTF-8.
115115-116116- // Convert string to byte slice for easier indexing
117117- data := []byte(block.Plaintext)
118118-119119- // Map of byte_index -> string_to_insert
120120- // But we wrap text.
121121-122122- // Let's just do a linear pass if facets are non-overlapping and sorted.
123123- // They should be.
124124-125125- var sb strings.Builder
126126- lastPos := 0
127127-128128- for _, facet := range block.Facets {
129129- start := facet.Index.ByteStart
130130- end := facet.Index.ByteEnd
131131-132132- if start < lastPos {
133133- continue // Overlap or out of order
134134- }
135135-136136- // Append text before facet
137137- sb.Write(data[lastPos:start])
138138-139139- // Handle feature
140140- text := string(data[start:end])
141141- replacement := text
142142-143143- for _, feat := range facet.Features {
144144- if feat.Type == "pub.leaflet.richtext.facet#link" {
145145- replacement = fmt.Sprintf("[%s](%s)", text, feat.URI)
146146- } else if feat.Type == "pub.leaflet.richtext.facet#didMention" {
147147- replacement = fmt.Sprintf("[%s](https://bsky.app/profile/%s)", text, feat.Did)
148148- }
149149- // code facet? pub.leaflet.richtext.facet#code -> `text`
150150- if feat.Type == "pub.leaflet.richtext.facet#code" {
151151- replacement = fmt.Sprintf("`%s`", text)
152152- }
153153- }
154154-155155- sb.WriteString(replacement)
156156- lastPos = end
157157- }
158158-159159- sb.Write(data[lastPos:])
160160-161161- return sb.String()
101101+ var sb strings.Builder
102102+ lastPos := 0
103103+104104+ for _, facet := range block.Facets {
105105+ start := facet.Index.ByteStart
106106+ end := facet.Index.ByteEnd
107107+108108+ if start < lastPos {
109109+ continue // Overlap or out of order
110110+ }
111111+112112+ // Append text before facet
113113+ sb.Write(data[lastPos:start])
114114+115115+ // Handle feature
116116+ text := string(data[start:end])
117117+ replacement := text
118118+119119+ for _, feat := range facet.Features {
120120+ if feat.Type == "pub.leaflet.richtext.facet#link" {
121121+ replacement = fmt.Sprintf("[%s](%s)", text, feat.URI)
122122+ } else if feat.Type == "pub.leaflet.richtext.facet#didMention" {
123123+ replacement = fmt.Sprintf("[%s](https://bsky.app/profile/%s)", text, feat.Did)
124124+ }
125125+ // code facet? pub.leaflet.richtext.facet#code -> `text`
126126+ if feat.Type == "pub.leaflet.richtext.facet#code" {
127127+ replacement = fmt.Sprintf("`%s`", text)
128128+ }
129129+ }
130130+131131+ sb.WriteString(replacement)
132132+ lastPos = end
133133+ }
134134+135135+ sb.Write(data[lastPos:])
136136+137137+ return sb.String()
162138}
163139164140func (c *Converter) renderList(sb *strings.Builder, items []atproto.ListItem, depth int) {