this repo has no description
1
fork

Configure Feed

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

kinda works

Signed-off-by: Marius Kimmina <mar.kimmina@gmail.com>

+655 -135
+1 -55
README.md
··· 1 1 # leaflet-hugo-sync 2 2 3 - A CLI tool to hydrate Hugo blogs with content hosted on Leaflet (via AT Protocol). 4 - 5 - ## Features 6 - 7 - - **Configurable Mapping**: Use a YAML config file to define source and output paths. 8 - - **Rich Text to Markdown**: Converts Leaflet blog entries to Hugo-compatible Markdown. 9 - - **Image Mirroring**: Automatically downloads image blobs and updates paths. 10 - - **Customizable Frontmatter**: Use Go templates to define your Hugo frontmatter. 11 - 12 - ## Installation 13 - 14 - ```bash 15 - go install github.com/marius/leaflet-hugo-sync/cmd/leaflet-hugo-sync@latest 16 - ``` 17 - 18 - ## Configuration 19 - 20 - Create a `.leaflet-sync.yaml` in your Hugo project root: 21 - 22 - ```yaml 23 - source: 24 - handle: "yourname.bsky.social" 25 - collection: "com.whtwnd.blog.entry" 26 - 27 - output: 28 - posts_dir: "content/posts/leaflet" 29 - images_dir: "static/images/leaflet" 30 - image_path_prefix: "/images/leaflet" 31 - 32 - template: 33 - frontmatter: | 34 - --- 35 - title: "{{ .Title }}" 36 - date: {{ .CreatedAt }} 37 - original_url: "https://leaflet.pub/{{ .Handle }}/{{ .Slug }}" 38 - --- 39 - ``` 40 - 41 - ## Usage 42 - 43 - Run the tool from your Hugo project root: 44 - 45 - ```bash 46 - leaflet-hugo-sync 47 - ``` 48 - 49 - You can specify a custom config path: 50 - 51 - ```bash 52 - leaflet-hugo-sync -config my-config.yaml 53 - ``` 54 - 55 - ## License 56 - 57 - MIT 3 + WIP
+238 -37
cmd/leaflet-hugo-sync/main.go
··· 10 10 11 11 "github.com/marius/leaflet-hugo-sync/internal/atproto" 12 12 "github.com/marius/leaflet-hugo-sync/internal/config" 13 + "github.com/marius/leaflet-hugo-sync/internal/converter" 13 14 "github.com/marius/leaflet-hugo-sync/internal/generator" 14 15 "github.com/marius/leaflet-hugo-sync/internal/media" 15 16 ) ··· 29 30 } 30 31 31 32 ctx := context.Background() 32 - client := atproto.NewClient("") 33 - 34 - did, err := client.ResolveHandle(ctx, cfg.Source.Handle) 33 + 34 + // 1. Resolve Handle to DID using public resolver (bsky.social) 35 + baseClient := atproto.NewClient("https://bsky.social") 36 + did, err := baseClient.ResolveHandle(ctx, cfg.Source.Handle) 35 37 if err != nil { 36 38 log.Fatalf("failed to resolve handle: %v", err) 37 39 } 38 - 39 40 fmt.Printf("Resolved %s to %s\n", cfg.Source.Handle, did) 40 41 41 - records, err := client.FetchEntries(ctx, did, cfg.Source.Collection) 42 + // 2. Resolve PDS Endpoint for the DID 43 + pdsEndpoint, err := baseClient.ResolvePDS(ctx, did) 42 44 if err != nil { 43 - log.Fatalf("failed to fetch entries: %v", err) 45 + log.Fatalf("failed to resolve PDS: %v", err) 44 46 } 47 + fmt.Printf("PDS Endpoint: %s\n", pdsEndpoint) 48 + 49 + // 3. Connect to User's PDS 50 + pdsClient := atproto.NewClient(pdsEndpoint) 45 51 46 - fmt.Printf("Found %d entries\n", len(records)) 52 + // 4. Resolve Publication (if configured) 53 + 54 + var publicationURI string 55 + 56 + if cfg.Source.PublicationName != "" { 57 + 58 + fmt.Printf("Resolving publication '%s'...\n", cfg.Source.PublicationName) 59 + 60 + pubRecords, err := pdsClient.FetchEntries(ctx, did, "pub.leaflet.publication") 61 + 62 + if err != nil { 63 + 64 + log.Fatalf("failed to fetch publications: %v", err) 65 + 66 + } 67 + 68 + for _, rec := range pubRecords { 69 + 70 + var pub atproto.LeafletPublication 71 + 72 + if err := json.Unmarshal(rec.Value, &pub); err == nil { 73 + 74 + if pub.Name == cfg.Source.PublicationName { 47 75 48 - downloader := media.NewDownloader(cfg.Output.ImagesDir, cfg.Output.ImagePathPrefix, client.XRPC.Host) 49 - gen := generator.NewGenerator(cfg) 76 + publicationURI = rec.Uri 50 77 51 - for _, rec := range records { 52 - var entry atproto.BlogEntry 53 - valBytes, _ := json.Marshal(rec.Value) 54 - if err := json.Unmarshal(valBytes, &entry); err != nil { 55 - fmt.Printf("Failed to unmarshal record %s: %v\n", rec.Uri, err) 56 - continue 78 + fmt.Printf("Found publication URI: %s\n", publicationURI) 79 + 80 + break 81 + 82 + } 83 + 84 + } 85 + 86 + } 87 + 88 + if publicationURI == "" { 89 + 90 + log.Fatalf("Publication '%s' not found", cfg.Source.PublicationName) 91 + 92 + } 93 + 57 94 } 58 95 59 - if entry.Slug == "" { 60 - entry.Slug = lastPathPart(rec.Uri) 96 + 97 + 98 + // 5. Fetch Entries 99 + 100 + // Update collection to Leaflet Document if user hasn't specified it 101 + 102 + collection := cfg.Source.Collection 103 + 104 + if collection == "com.whtwnd.blog.entry" { 105 + 106 + fmt.Println("Warning: Defaulting to 'pub.leaflet.document' as 'com.whtwnd.blog.entry' seems deprecated/unused for Leaflet.") 107 + 108 + collection = "pub.leaflet.document" 109 + 61 110 } 62 111 63 - fmt.Printf("Processing: %s\n", entry.Title) 112 + 113 + 114 + records, err := pdsClient.FetchEntries(ctx, did, collection) 115 + 116 + if err != nil { 117 + 118 + log.Fatalf("failed to fetch entries: %v", err) 119 + 120 + } 121 + 122 + 123 + 124 + fmt.Printf("Found %d entries\n", len(records)) 125 + 126 + 127 + 128 + downloader := media.NewDownloader(cfg.Output.ImagesDir, cfg.Output.ImagePathPrefix, pdsClient.XRPC.Host) 129 + 130 + gen := generator.NewGenerator(cfg) 131 + 132 + conv := converter.NewConverter() 133 + 134 + 135 + 136 + for _, rec := range records { 137 + 138 + // Try to unmarshal as LeafletDocument 139 + 140 + var doc atproto.LeafletDocument 141 + 142 + 143 + 144 + // Check type first 145 + 146 + var typeCheck struct { 147 + 148 + Type string `json:"$type"` 149 + 150 + } 151 + 152 + if err := json.Unmarshal(rec.Value, &typeCheck); err != nil { 153 + 154 + fmt.Printf("Failed to check type for record %s: %v\n", rec.Uri, err) 155 + 156 + continue 157 + 158 + } 159 + 160 + 161 + 162 + if typeCheck.Type != "pub.leaflet.document" { 163 + 164 + // Skip or try legacy 165 + 166 + continue 167 + 168 + } 169 + 170 + 171 + 172 + if err := json.Unmarshal(rec.Value, &doc); err != nil { 173 + 174 + fmt.Printf("Failed to unmarshal record %s: %v\n", rec.Uri, err) 175 + 176 + continue 177 + 178 + } 179 + 180 + 181 + 182 + // Filter by Publication 183 + 184 + if publicationURI != "" && doc.Publication != publicationURI { 185 + 186 + // Skip entries not matching the publication 187 + 188 + continue 189 + 190 + } 191 + 192 + 193 + 194 + fmt.Printf("Processing: %s\n", doc.Title) 195 + 196 + 197 + 198 + // Convert to Markdown 199 + 200 + result, err := conv.ConvertLeaflet(&doc) 201 + 202 + if err != nil { 203 + 204 + fmt.Printf(" Failed to convert document: %v\n", err) 205 + 206 + continue 207 + 208 + } 209 + 210 + 64 211 65 - // Handle images 66 - if entry.Embed != nil && len(entry.Embed.Images) > 0 { 67 - imageMarkdown := "\n\n" 68 - for _, img := range entry.Embed.Images { 69 - localPath, err := downloader.DownloadBlob(ctx, did, img.Image.Ref.Link) 212 + // Download Images 213 + 214 + finalContent := result.Markdown 215 + 216 + for _, imgRef := range result.Images { 217 + 218 + localPath, err := downloader.DownloadBlob(ctx, did, imgRef.Blob.Ref.Link) 219 + 70 220 if err != nil { 221 + 71 222 fmt.Printf(" Failed to download image: %v\n", err) 223 + 72 224 continue 225 + 73 226 } 74 - imageMarkdown += fmt.Sprintf("![%s](%s)\n", img.Alt, localPath) 227 + 228 + finalContent = strings.ReplaceAll(finalContent, imgRef.Blob.Ref.Link, localPath) 229 + 230 + } 231 + 232 + 233 + 234 + // Slug generation 235 + 236 + slug := lastPathPart(rec.Uri) 237 + 238 + 239 + 240 + // Construct Original URL (Assuming toolbox.leaflet.pub structure or generic) 241 + 242 + // User config usually handles the "base" part in template. 243 + 244 + // But here we construct a full URL for the OriginalURL field. 245 + 246 + // If we don't know the base, we can't fully construct it. 247 + 248 + // However, user provided "https://toolbox.leaflet.pub/{{ .Slug }}" in template. 249 + 250 + // Let's pass the slug and handle. 251 + 252 + // Ideally, we should construct the full URL if we can. 253 + 254 + // For now, let's just pass what we have. 255 + 256 + 257 + 258 + originalURL := fmt.Sprintf("https://leaflet.pub/%s", slug) // Fallback generic 259 + 260 + 261 + 262 + postData := generator.PostData{ 263 + 264 + Title: doc.Title, 265 + 266 + CreatedAt: doc.PublishedAt, 267 + 268 + Slug: slug, 269 + 270 + Handle: cfg.Source.Handle, 271 + 272 + OriginalURL: originalURL, 273 + 274 + Content: finalContent, 275 + 75 276 } 76 - entry.Content += imageMarkdown 77 - } 78 277 79 - postData := generator.PostData{ 80 - Title: entry.Title, 81 - CreatedAt: entry.CreatedAt, 82 - Slug: entry.Slug, 83 - Handle: cfg.Source.Handle, 84 - Content: entry.Content, 85 - } 278 + 279 + 280 + if err := gen.GeneratePost(postData); err != nil { 281 + 282 + fmt.Printf(" Failed to generate post: %v\n", err) 86 283 87 - if err := gen.GeneratePost(postData); err != nil { 88 - fmt.Printf(" Failed to generate post: %v\n", err) 284 + } 285 + 89 286 } 287 + 288 + 289 + 290 + fmt.Println("Done!") 291 + 90 292 } 91 293 92 - fmt.Println("Done!") 93 - } 294 +
+105 -4
internal/atproto/client.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "fmt" 7 + "net/http" 6 8 7 9 "github.com/bluesky-social/indigo/api/atproto" 8 10 "github.com/bluesky-social/indigo/xrpc" ··· 12 14 XRPC *xrpc.Client 13 15 } 14 16 17 + type Record struct { 18 + Uri string `json:"uri"` 19 + Cid string `json:"cid"` 20 + Value json.RawMessage `json:"value"` 21 + } 22 + 23 + type ListRecordsResponse struct { 24 + Cursor *string `json:"cursor"` 25 + Records []Record `json:"records"` 26 + } 27 + 15 28 func NewClient(pdsHost string) *Client { 16 29 if pdsHost == "" { 17 30 pdsHost = "https://bsky.social" ··· 31 44 return out.Did, nil 32 45 } 33 46 34 - func (c *Client) FetchEntries(ctx context.Context, repo string, collection string) ([]*atproto.RepoListRecords_Record, error) { 35 - var records []*atproto.RepoListRecords_Record 47 + type DIDDocument struct { 48 + Service []Service `json:"service"` 49 + } 50 + 51 + type Service struct { 52 + ID string `json:"id"` 53 + Type string `json:"type"` 54 + ServiceEndpoint string `json:"serviceEndpoint"` 55 + } 56 + 57 + // ResolvePDS finds the PDS endpoint for a given DID using plc.directory 58 + func (c *Client) ResolvePDS(ctx context.Context, did string) (string, error) { 59 + // 1. Try describeRepo first (if we are on the AppView, it works and is faster) 60 + desc, err := atproto.RepoDescribeRepo(ctx, c.XRPC, did) 61 + if err == nil && desc.DidDoc != nil { 62 + // Parse the DidDoc from the response 63 + // Note: indigo might return it as a map or struct. 64 + // Let's rely on the public directory if this fails or is complex to parse from the generic type. 65 + // Actually, let's just use the PLC directory directly for simplicity and reliability. 66 + } 67 + 68 + // 2. Fallback to PLC Directory 69 + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://plc.directory/%s", did), nil) 70 + if err != nil { 71 + return "", err 72 + } 73 + 74 + resp, err := http.DefaultClient.Do(req) 75 + if err != nil { 76 + return "", err 77 + } 78 + defer resp.Body.Close() 79 + 80 + if resp.StatusCode != http.StatusOK { 81 + return "", fmt.Errorf("failed to fetch DID doc: %s", resp.Status) 82 + } 83 + 84 + var doc DIDDocument 85 + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 86 + return "", err 87 + } 88 + 89 + for _, svc := range doc.Service { 90 + if svc.ID == "#atproto_pds" || svc.Type == "AtprotoPersonalDataServer" { 91 + return svc.ServiceEndpoint, nil 92 + } 93 + } 94 + 95 + return "", fmt.Errorf("no PDS service found for DID %s", did) 96 + } 97 + 98 + func (c *Client) FetchEntries(ctx context.Context, repo string, collection string) ([]Record, error) { 99 + 100 + var records []Record 101 + 36 102 cursor := "" 37 103 104 + 105 + 38 106 for { 39 - out, err := atproto.RepoListRecords(ctx, c.XRPC, collection, cursor, 100, repo, false) 40 - if err != nil { 107 + 108 + params := map[string]interface{}{ 109 + 110 + "repo": repo, 111 + 112 + "collection": collection, 113 + 114 + "limit": 100, 115 + 116 + } 117 + 118 + if cursor != "" { 119 + 120 + params["cursor"] = cursor 121 + 122 + } 123 + 124 + 125 + 126 + var out ListRecordsResponse 127 + 128 + if err := c.XRPC.Do(ctx, xrpc.Query, "", "com.atproto.repo.listRecords", params, nil, &out); err != nil { 129 + 41 130 return nil, fmt.Errorf("listing records: %w", err) 131 + 42 132 } 133 + 134 + 43 135 44 136 records = append(records, out.Records...) 45 137 138 + 139 + 46 140 if out.Cursor == nil || *out.Cursor == "" { 141 + 47 142 break 143 + 48 144 } 145 + 49 146 cursor = *out.Cursor 147 + 50 148 } 51 149 150 + 151 + 52 152 return records, nil 153 + 53 154 }
+97 -26
internal/atproto/types.go
··· 1 1 package atproto 2 2 3 - type Entry struct { 4 - Content string `json:"content"` 5 - Title string `json:"title"` 6 - CreatedAt string `json:"createdAt"` 7 - // RichText facets might be here if it's using standard RT, 8 - // but WhiteWind usually stores Markdown in 'content' 9 - // and facets in a separate field if applicable. 10 - // Actually, WhiteWind uses Markdown. 3 + import "encoding/json" 4 + 5 + // Generic wrapper to handle different record types 6 + type RecordValue struct { 7 + Type string `json:"$type"` 8 + // We will unmarshal into specific types based on Type 11 9 } 12 10 11 + // Old WhiteWind Format 13 12 type BlogEntry struct { 14 13 Content string `json:"content"` 15 14 Title string `json:"title"` ··· 20 19 Embed *Embed `json:"embed,omitempty"` 21 20 } 22 21 23 - type Embed struct { 24 - Type string `json:"$type"` 25 - Images []ImageEmbed `json:"images,omitempty"` 26 - External *ExternalEmbed `json:"external,omitempty"` 22 + // New Leaflet Format 23 + type LeafletDocument struct { 24 + Type string `json:"$type"` 25 + Title string `json:"title"` 26 + PublishedAt string `json:"publishedAt"` 27 + Description string `json:"description"` 28 + Tags []string `json:"tags"` 29 + Publication string `json:"publication"` // URI of the publication 30 + Pages []Page `json:"pages"` 31 + } 32 + 33 + type LeafletPublication struct { 34 + Type string `json:"$type"` 35 + Name string `json:"name"` 36 + } 37 + 38 + type Page struct { 39 + Type string `json:"$type"` // pub.leaflet.pages.linearDocument 40 + Blocks []BlockWrapper `json:"blocks"` 41 + } 42 + 43 + type BlockWrapper struct { 44 + Type string `json:"$type"` // pub.leaflet.pages.linearDocument#block 45 + Block json.RawMessage `json:"block"` // Deferred unmarshaling 46 + } 47 + 48 + // We'll use a helper to determine block type and unmarshal accordingly 49 + type BaseBlock struct { 50 + Type string `json:"$type"` 51 + } 52 + 53 + type TextBlock struct { 54 + Type string `json:"$type"` 55 + Plaintext string `json:"plaintext"` 56 + Facets []Facet `json:"facets"` 57 + } 58 + 59 + type CodeBlock struct { 60 + Type string `json:"$type"` 61 + Language string `json:"language"` 62 + Plaintext string `json:"plaintext"` 63 + } 64 + 65 + type UnorderedListBlock struct { 66 + Type string `json:"$type"` 67 + Children []ListItem `json:"children"` 68 + } 69 + 70 + type ListItem struct { 71 + Type string `json:"$type"` // pub.leaflet.blocks.unorderedList#listItem 72 + Content json.RawMessage `json:"content"` // Usually a TextBlock 73 + Children []ListItem `json:"children"` // Nested lists? 27 74 } 28 75 29 - type ImageEmbed struct { 76 + type ImageBlock struct { 77 + Type string `json:"$type"` 30 78 Image Blob `json:"image"` 31 79 Alt string `json:"alt"` 32 80 } 33 81 34 - type ExternalEmbed struct { 35 - Uri string `json:"uri"` 36 - Title string `json:"title"` 37 - Description string `json:"description"` 38 - Thumb *Blob `json:"thumb,omitempty"` 82 + type BskyPostBlock struct { 83 + Type string `json:"$type"` 84 + PostRef PostRef `json:"postRef"` 39 85 } 40 86 41 - type Blob struct { 42 - Ref BlobRef `json:"ref"` 43 - Mime string `json:"mimeType"` 44 - Size int `json:"size"` 87 + type PostRef struct { 88 + Uri string `json:"uri"` 89 + Cid string `json:"cid"` 45 90 } 46 91 47 - type BlobRef struct { 48 - Link string `json:"$link"` 49 - } 92 + // Shared Types 50 93 51 94 type Facet struct { 52 - Index Features `json:"index"` 95 + Index Features `json:"index"` 53 96 Features []Feature `json:"features"` 54 97 } 55 98 ··· 63 106 URI string `json:"uri,omitempty"` 64 107 Did string `json:"did,omitempty"` 65 108 } 109 + 110 + type Embed struct { 111 + Type string `json:"$type"` 112 + Images []ImageEmbed `json:"images,omitempty"` 113 + External *ExternalEmbed `json:"external,omitempty"` 114 + } 115 + 116 + type ImageEmbed struct { 117 + Image Blob `json:"image"` 118 + Alt string `json:"alt"` 119 + } 120 + 121 + type ExternalEmbed struct { 122 + Uri string `json:"uri"` 123 + Title string `json:"title"` 124 + Description string `json:"description"` 125 + Thumb *Blob `json:"thumb,omitempty"` 126 + } 127 + 128 + type Blob struct { 129 + Ref BlobRef `json:"ref"` 130 + Mime string `json:"mimeType"` 131 + Size int `json:"size"` 132 + } 133 + 134 + type BlobRef struct { 135 + Link string `json:"$link"` 136 + }
+5 -3
internal/config/config.go
··· 13 13 } 14 14 15 15 type Source struct { 16 - Handle string `yaml:"handle"` 17 - Collection string `yaml:"collection"` 16 + Handle string `yaml:"handle"` 17 + Collection string `yaml:"collection"` 18 + PublicationName string `yaml:"publication_name"` 18 19 } 19 20 20 21 type Output struct { 21 22 PostsDir string `yaml:"posts_dir"` 22 - ImagesDir string `yaml:"images_dir"` 23 + ImagesDir string `yaml:"images_dir"` 23 24 ImagePathPrefix string `yaml:"image_path_prefix"` 24 25 } 25 26 26 27 type Template struct { 27 28 Frontmatter string `yaml:"frontmatter"` 29 + Content string `yaml:"content"` 28 30 } 29 31 30 32 func LoadConfig(path string) (*Config, error) {
+181
internal/converter/markdown.go
··· 1 + package converter 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/marius/leaflet-hugo-sync/internal/atproto" 9 + ) 10 + 11 + type Converter struct { 12 + // We might need to track image downloads here or just return image references? 13 + // For now, let's just return the Markdown text and a list of image blobs to download. 14 + } 15 + 16 + type ConversionResult struct { 17 + Markdown string 18 + Images []ImageRef 19 + } 20 + 21 + type ImageRef struct { 22 + Blob atproto.Blob 23 + Alt string 24 + } 25 + 26 + func NewConverter() *Converter { 27 + return &Converter{} 28 + } 29 + 30 + func (c *Converter) ConvertLeaflet(doc *atproto.LeafletDocument) (*ConversionResult, error) { 31 + var sb strings.Builder 32 + var images []ImageRef 33 + 34 + for _, page := range doc.Pages { 35 + for _, blockWrapper := range page.Blocks { 36 + // Unmarshal base block to check type 37 + var base atproto.BaseBlock 38 + if err := json.Unmarshal(blockWrapper.Block, &base); err != nil { 39 + continue 40 + } 41 + 42 + switch base.Type { 43 + case "pub.leaflet.blocks.text": 44 + var textBlock atproto.TextBlock 45 + if err := json.Unmarshal(blockWrapper.Block, &textBlock); err != nil { 46 + continue 47 + } 48 + sb.WriteString(c.renderText(&textBlock) + "\n\n") 49 + 50 + case "pub.leaflet.blocks.code": 51 + var codeBlock atproto.CodeBlock 52 + if err := json.Unmarshal(blockWrapper.Block, &codeBlock); err != nil { 53 + continue 54 + } 55 + // Ensure language is not nil or empty 56 + lang := codeBlock.Language 57 + if lang == "" { 58 + lang = "text" 59 + } 60 + sb.WriteString(fmt.Sprintf("\n```%s\n%s\n```\n\n", lang, codeBlock.Plaintext)) 61 + 62 + case "pub.leaflet.blocks.unorderedList": 63 + var listBlock atproto.UnorderedListBlock 64 + if err := json.Unmarshal(blockWrapper.Block, &listBlock); err != nil { 65 + continue 66 + } 67 + c.renderList(&sb, listBlock.Children, 0) 68 + sb.WriteString("\n") 69 + 70 + case "pub.leaflet.blocks.image": 71 + var imgBlock atproto.ImageBlock 72 + if err := json.Unmarshal(blockWrapper.Block, &imgBlock); err != nil { 73 + continue 74 + } 75 + // We use a placeholder path that main.go will resolve 76 + // Actually, main.go needs to know about this. 77 + // Let's assume standard markdown image syntax: ![alt](cid) 78 + // The downloader will replace it or we pre-calculate the path? 79 + // Better: Return the blob CID as the URL, and main.go does a string replace or we handle it here if we pass config. 80 + // For now, let's use the blob ref link as the URL. 81 + sb.WriteString(fmt.Sprintf("![%s](%s)\n\n", imgBlock.Alt, imgBlock.Image.Ref.Link)) 82 + images = append(images, ImageRef{Blob: imgBlock.Image, Alt: imgBlock.Alt}) 83 + 84 + case "pub.leaflet.blocks.bskyPost": 85 + var postBlock atproto.BskyPostBlock 86 + if err := json.Unmarshal(blockWrapper.Block, &postBlock); err != nil { 87 + continue 88 + } 89 + // Render a link to the post for now, as we can't easily embed it without JS 90 + postURL := fmt.Sprintf("https://bsky.app/profile/%s/post/%s", "did:...", lastPathPart(postBlock.PostRef.Uri)) 91 + // We don't have the handle here easily to make a pretty URL, but we can try. 92 + // Actually, let's just make a blockquote link. 93 + sb.WriteString(fmt.Sprintf("> [View on Bluesky](%s)\n\n", postURL)) // TODO: Improve this 94 + } 95 + } 96 + } 97 + 98 + return &ConversionResult{ 99 + Markdown: sb.String(), 100 + Images: images, 101 + }, nil 102 + } 103 + 104 + func (c *Converter) renderText(block *atproto.TextBlock) string { 105 + // Apply facets 106 + // Facets are ranges. We need to insert markdown syntax at specific indices. 107 + // This is tricky because inserting characters shifts indices. 108 + // Best approach: Slice the string and reconstruct it. 109 + 110 + // Sort facets by start index (descending) to avoid shifting issues? 111 + // Actually, we should iterate from start to end, keeping track of current index. 112 + 113 + // Simplify: Just handle links for now. 114 + // NOTE: Facets in ATProto are byte-offsets, not rune-offsets. Go strings are UTF-8. 115 + 116 + // Convert string to byte slice for easier indexing 117 + data := []byte(block.Plaintext) 118 + 119 + // Map of byte_index -> string_to_insert 120 + // But we wrap text. 121 + 122 + // Let's just do a linear pass if facets are non-overlapping and sorted. 123 + // They should be. 124 + 125 + var sb strings.Builder 126 + lastPos := 0 127 + 128 + for _, facet := range block.Facets { 129 + start := facet.Index.ByteStart 130 + end := facet.Index.ByteEnd 131 + 132 + if start < lastPos { 133 + continue // Overlap or out of order 134 + } 135 + 136 + // Append text before facet 137 + sb.Write(data[lastPos:start]) 138 + 139 + // Handle feature 140 + text := string(data[start:end]) 141 + replacement := text 142 + 143 + for _, feat := range facet.Features { 144 + if feat.Type == "pub.leaflet.richtext.facet#link" { 145 + replacement = fmt.Sprintf("[%s](%s)", text, feat.URI) 146 + } else if feat.Type == "pub.leaflet.richtext.facet#didMention" { 147 + replacement = fmt.Sprintf("[%s](https://bsky.app/profile/%s)", text, feat.Did) 148 + } 149 + // code facet? pub.leaflet.richtext.facet#code -> `text` 150 + if feat.Type == "pub.leaflet.richtext.facet#code" { 151 + replacement = fmt.Sprintf("`%s`", text) 152 + } 153 + } 154 + 155 + sb.WriteString(replacement) 156 + lastPos = end 157 + } 158 + 159 + sb.Write(data[lastPos:]) 160 + 161 + return sb.String() 162 + } 163 + 164 + func (c *Converter) renderList(sb *strings.Builder, items []atproto.ListItem, depth int) { 165 + indent := strings.Repeat(" ", depth) 166 + for _, item := range items { 167 + // Unmarshal content (TextBlock) 168 + var textBlock atproto.TextBlock 169 + if err := json.Unmarshal(item.Content, &textBlock); err == nil { 170 + sb.WriteString(fmt.Sprintf("%s- %s\n", indent, c.renderText(&textBlock))) 171 + } 172 + if len(item.Children) > 0 { 173 + c.renderList(sb, item.Children, depth+1) 174 + } 175 + } 176 + } 177 + 178 + func lastPathPart(uri string) string { 179 + parts := strings.Split(uri, "/") 180 + return parts[len(parts)-1] 181 + }
+28 -10
internal/generator/hugo.go
··· 14 14 } 15 15 16 16 type PostData struct { 17 - Title string 18 - CreatedAt string 19 - Slug string 20 - Handle string 21 - Content string 22 - Data map[string]interface{} 17 + Title string 18 + CreatedAt string 19 + Slug string 20 + Handle string 21 + OriginalURL string 22 + Content string 23 + Data map[string]interface{} 23 24 } 24 25 25 26 func NewGenerator(cfg *config.Config) *Generator { ··· 27 28 } 28 29 29 30 func (g *Generator) GeneratePost(data PostData) error { 30 - tmpl, err := template.New("frontmatter").Parse(g.Cfg.Template.Frontmatter) 31 + // 1. Generate Frontmatter 32 + tmplFM, err := template.New("frontmatter").Parse(g.Cfg.Template.Frontmatter) 31 33 if err != nil { 32 34 return err 33 35 } 34 36 35 - var buf bytes.Buffer 36 - if err := tmpl.Execute(&buf, data); err != nil { 37 + var bufFM bytes.Buffer 38 + if err := tmplFM.Execute(&bufFM, data); err != nil { 39 + return err 40 + } 41 + 42 + // 2. Generate Content 43 + contentTmplStr := g.Cfg.Template.Content 44 + if contentTmplStr == "" { 45 + contentTmplStr = "{{ .Content }}" // Default 46 + } 47 + 48 + tmplContent, err := template.New("content").Parse(contentTmplStr) 49 + if err != nil { 50 + return err 51 + } 52 + 53 + var bufContent bytes.Buffer 54 + if err := tmplContent.Execute(&bufContent, data); err != nil { 37 55 return err 38 56 } 39 57 ··· 44 62 fileName := data.Slug + ".md" 45 63 filePath := filepath.Join(g.Cfg.Output.PostsDir, fileName) 46 64 47 - fullContent := buf.String() + "\n" + data.Content 65 + fullContent := bufFM.String() + "\n" + bufContent.String() 48 66 49 67 return os.WriteFile(filePath, []byte(fullContent), 0644) 50 68 }