From 11c849efc0eea2b6017b09b88c86c49e50f985dd Mon Sep 17 00:00:00 2001 From: Raphael Pour Date: Mon, 15 Jun 2026 09:08:59 +0200 Subject: [PATCH 1/3] Refactor render pipeline into reusable internal/site package The render command was a single ~250-line RunE closure that mixed config loading, post discovery, markdown rendering, templating, RSS and asset copying, all wired to package-global flags. This made new features hard to add and left the logic untestable. Extract it into a new internal/site package: - New() loads the config and posts (discovery, metadata filtering, markdown rendering, IMAGE() resolution, sorting, prev/next linking). - Render() runs an ordered list of build stages (renderPosts, renderIndex, generateRSS, copyAssets, copyChillFiles). A new output artifact is now a Stage appended to the list rather than surgery on a closure. cmd/render.go becomes a thin CLI wrapper. The embedded default theme moves from cmd/public to internal/site/public, the content-file helper moves to internal/common, and templates are parsed once instead of per post. The README gains an architecture diagram for new contributors. Output is byte-identical to before this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 60 ++++ cmd/add.go | 10 +- cmd/render.go | 307 +----------------- cmd/update.go | 2 +- internal/common/common.go | 8 + internal/site/load.go | 152 +++++++++ internal/site/post.go | 35 ++ {cmd => internal/site}/public/blogstyle.css | 0 {cmd => internal/site}/public/codestyle.css | 0 {cmd => internal/site}/public/index.tmpl.html | 0 {cmd => internal/site}/public/post.tmpl.html | 0 .../site}/public/static.tmpl.html | 0 internal/site/site.go | 87 +++++ internal/site/stages.go | 203 ++++++++++++ internal/site/templates.go | 31 ++ 15 files changed, 587 insertions(+), 308 deletions(-) create mode 100644 internal/site/load.go create mode 100644 internal/site/post.go rename {cmd => internal/site}/public/blogstyle.css (100%) rename {cmd => internal/site}/public/codestyle.css (100%) rename {cmd => internal/site}/public/index.tmpl.html (100%) rename {cmd => internal/site}/public/post.tmpl.html (100%) rename {cmd => internal/site}/public/static.tmpl.html (100%) create mode 100644 internal/site/site.go create mode 100644 internal/site/stages.go create mode 100644 internal/site/templates.go diff --git a/README.md b/README.md index 5738a78..bad7a7d 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,63 @@ Static markdown blog backend as a binary. Generated [my blog](https://evilcookie - one-shot: `bloctl post add --path blog --title="My first blog post"` - render html: `blogctl render --path blog -f` - find your ready-to-serve blog in `./out` + +## How it works (for contributors) + +A blog is just a directory of files. Each post is its own folder (the folder +name becomes the URL slug) holding the markdown body, a metadata sidecar and any +referenced images. `blogctl render` turns that source tree into a static site. + +``` + SOURCE (--path blog) RENDER PIPELINE OUTPUT (-o out) + ──────────────────── ─────────────── ─────────────── + + blog/ + ├── blog.json ............ site config (domain, author, title, chill-files, ...) + ├── robots.txt ........... "chill-files": copied verbatim + └── my-first-post/ ....... one dir per post (dir name = slug) + ├── content.md ....... markdown body, may contain IMAGE(pic.png) shortcodes + ├── metadata.json .... title, status (draft|public), static, createdAt, ... + └── pic.png .......... images referenced from content.md + + │ + ▼ site.New(opts) [internal/site/load.go] + ┌──────────────────────────────────────────────┐ + │ • config.Load(blog.json) │ + │ • discover post dirs, load metadata.json │ + │ • skip anything not status:"public" │ + │ • content.md → HTML (gomarkdown + chroma) │ + │ • resolve IMAGE() shortcodes │ + │ • sort newest-first, link prev/next nav │ + └──────────────────────────────────────────────┘ + │ + ▼ site.Render() — ordered stages [internal/site/stages.go] + ┌──────────────────────────────────────────────┐ + │ 1. renderPosts ....→ out/.html (+images)│ + │ 2. renderIndex ....→ out/index.html │ + │ 3. generateRSS ....→ out/rss.xml │ + │ 4. copyAssets .....→ out/*.css (embedded) │ + │ 5. copyChillFiles .→ out/ │ + └──────────────────────────────────────────────┘ + │ + ▼ + out/ ← ready-to-serve static site +``` + +The render flow lives in `internal/site`; the `cmd/` package is a thin +[cobra](https://github.com/spf13/cobra) layer that parses flags and calls into it. + +``` + cmd/ ................. CLI commands (add, publish, draft, list, render, ...) + internal/site/ ....... the render pipeline: New() loads, Render() runs stages ← add features here + internal/config/ ..... blog.json + internal/metadata/ ... per-post metadata.json + internal/highlighter/ code-block syntax highlighting (chroma) + internal/common/ ..... file helpers, slugs, $EDITOR integration +``` + +**Adding a new output artifact** (sitemap, tag pages, JSON feed, ...) is a +self-contained change: implement the `Stage` interface and append it to the list +in `Site.stages()` (`internal/site/stages.go`). A stage receives the loaded +`*Site` and writes into the output directory — no changes to the loading code or +the other stages required. diff --git a/cmd/add.go b/cmd/add.go index 7037cbd..3c6c6f5 100755 --- a/cmd/add.go +++ b/cmd/add.go @@ -29,12 +29,6 @@ import ( "github.com/fatih/color" ) -const CONTENT_FILE = "content.md" - -func GetContentFile(postPath string) string { - return filepath.Join(postPath, CONTENT_FILE) -} - var addCmd = &cobra.Command{ Use: "add", Short: "Add a new post", @@ -72,7 +66,7 @@ var addCmd = &cobra.Command{ return fmt.Errorf("Error creating post dir: %s", err) } - if err := os.WriteFile(GetContentFile(postPath), []byte(content), os.ModePerm); err != nil { + if err := os.WriteFile(common.GetContentFile(postPath), []byte(content), os.ModePerm); err != nil { rescuePost(content) return fmt.Errorf("Error writing post: %s", err) } @@ -89,7 +83,7 @@ var addCmd = &cobra.Command{ return err } - color.Green(GetContentFile(postPath)) + color.Green(common.GetContentFile(postPath)) return nil }, } diff --git a/cmd/render.go b/cmd/render.go index 8fda1e6..558305f 100644 --- a/cmd/render.go +++ b/cmd/render.go @@ -17,323 +17,32 @@ along with this program. If not, see . package cmd import ( - "embed" - "fmt" - "html/template" - "os" - "path" - "path/filepath" - "regexp" - "sort" - "strings" - plainTemplate "text/template" - "time" + "github.com/RaphaelPour/blogctl/internal/site" - "github.com/RaphaelPour/blogctl/internal/common" - "github.com/RaphaelPour/blogctl/internal/config" - "github.com/RaphaelPour/blogctl/internal/highlighter" - "github.com/RaphaelPour/blogctl/internal/metadata" - - "github.com/gomarkdown/markdown" - "github.com/gomarkdown/markdown/parser" - "github.com/gorilla/feeds" "github.com/spf13/cobra" ) -const ( - PUBLIC_DIR_PATH = "public" -) - -var ( - //go:embed public/* - content embed.FS - - indexTemplatePath = path.Join(PUBLIC_DIR_PATH, "index.tmpl.html") - indexTemplate = string(common.Unwrap(content.ReadFile(indexTemplatePath))) - - postTemplatePath = path.Join(PUBLIC_DIR_PATH, "post.tmpl.html") - postTemplate = string(common.Unwrap(content.ReadFile(postTemplatePath))) - - staticTemplatePath = path.Join(PUBLIC_DIR_PATH, "static.tmpl.html") - staticTemplate = string(common.Unwrap(content.ReadFile(staticTemplatePath))) -) - -type Post struct { - Title string - Link string - PermaLink string - PreviousPostLink string - NextPostLink string - HomeLink string - Timestamp int64 - CreatedAt string - Content string - FeaturedImage string - Discussion bool - Rendered template.HTML - Metadata *metadata.Metadata -} - // renderCmd represents the render command var renderCmd = &cobra.Command{ Use: "render", Short: "Renders blog to static website", Long: "Collects all posts and renders the markdown using the metadata as static website", RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(BlogPath) - if err != nil { - return err - } - - feed := &feeds.Feed{ - Title: cfg.Title, - Link: &feeds.Link{Href: fmt.Sprintf("https://%s", cfg.Domain)}, - Description: cfg.Description, - Author: &feeds.Author{Name: cfg.Author}, - Created: time.Now(), - } - - if _, err := os.Stat(OutPath); !os.IsNotExist(err) && !Force { - return fmt.Errorf("Output folder already exists") - } - - if err := os.MkdirAll(OutPath, os.ModePerm); err != nil { - return fmt.Errorf("Error creating output folder: %s", err) - } - - postDirs, err := os.ReadDir(BlogPath) - if err != nil { - return fmt.Errorf("Error reading blog path: %s", err) - } - - feed.Items = make([]*feeds.Item, 0) - posts := make([]Post, 0) - for i, dir := range postDirs { - - if !dir.IsDir() { - continue - } - - postPath := filepath.Join(BlogPath, dir.Name()) - files, err := os.ReadDir(postPath) - if err != nil { - return fmt.Errorf("Error reading post path of %s: %s", postPath, err) - } - - if len(files) < 2 { - return fmt.Errorf( - "Unexpected count of files in post path %s. Found: %d", - postPath, - len(files), - ) - } - - meta, err := metadata.Load(postPath) - if err != nil { - return err - } - - /* Overstep posts which aren't set to 'public' */ - if meta.Status != metadata.PUBLIC_STATUS { - continue - } - - fmt.Printf("Rendering post #%02d: %s\n", i, dir.Name()) - slugTitle := common.Slug(meta.Title) - - content, err := os.ReadFile(GetContentFile(postPath)) - if err != nil { - return fmt.Errorf("Error reading post content %s: %s", postPath, err) - } - - rendered := markdown.ToHTML( - content, parser.NewWithExtensions(parser.CommonExtensions|parser.Footnotes), - highlighter.GetRenderer(), - ) - - /* replace all IMAGE() with valid path to filename */ - re := regexp.MustCompile(`IMAGE\(([\w\.]+)\)`) - renderedStr := re.ReplaceAllString(string(rendered), fmt.Sprintf(``, slugTitle)) - - for _, file := range re.FindAllStringSubmatch(string(rendered), -1) { - src := filepath.Join(BlogPath, fmt.Sprintf("%s/%s", slugTitle, file[1])) - dst := filepath.Join(OutPath, fmt.Sprintf("%s_%s", slugTitle, file[1])) - if err := common.CopyFile(src, dst); err != nil { - return fmt.Errorf("error copying '%s' to '%s': %w", src, dst, err) - } - } - - postFileName := fmt.Sprintf( - POST_FILE_TEMPLATE, - slugTitle, - ) - var featuredImage string - if len(meta.FeaturedImage) > 0 { - featuredImage = fmt.Sprintf("https://%s/%s_%s", cfg.Domain, slugTitle, meta.FeaturedImage) - } - post := Post{ - Title: meta.Title, - Link: postFileName, - PermaLink: fmt.Sprintf("https://%s/%s.html", cfg.Domain, slugTitle), - HomeLink: INDEX_FILE, - Timestamp: meta.CreatedAt, - CreatedAt: meta.Date(), - Content: renderedStr, - Discussion: cfg.Discussion && !meta.Static, - Rendered: template.HTML(renderedStr), - FeaturedImage: featuredImage, - Metadata: meta, - } - posts = append(posts, post) - } - - /* Sort posts */ - sort.Slice(posts, func(i, j int) bool { - return posts[i].Timestamp > posts[j].Timestamp + s, err := site.New(site.Options{ + BlogPath: BlogPath, + OutPath: OutPath, + Force: Force, }) - - /* set previous/next post of any post */ - nextPost := -1 - for i := 0; i < len(posts); i++ { - if posts[i].Metadata.Static { - continue - } - - if nextPost >= 0 { - posts[i].NextPostLink = posts[nextPost].Link - posts[nextPost].PreviousPostLink = posts[i].Link - - fmt.Println(posts[i].Title, "<->", posts[nextPost].Title) - } - nextPost = i - } - - /* render all posts */ - publishedPosts := make([]Post, 0) - for _, post := range posts { - /* Render single post */ - templateString := postTemplate - if post.Metadata.Static { - templateString = staticTemplate - } - postTemplate, err := template.New("post").Parse(templateString) - if err != nil { - return fmt.Errorf("Error creating post file '%s': %s", post.Title, err) - } - - postFilePath := filepath.Join(OutPath, post.Link) - file, err := os.Create(postFilePath) - if err != nil { - return fmt.Errorf("Error creating post file '%s': %s", post.Title, err) - } - - if err := postTemplate.Execute(file, post); err != nil { - return fmt.Errorf("Error rendering post '%s': %s", post.Title, err) - } - - if err := file.Close(); err != nil { - return fmt.Errorf("Error closing post file '%s': %s", post.Title, err) - } - - /* skip static sites, add other to published+feed in order to list it at the start page */ - if post.Metadata.Static { - continue - } - publishedPosts = append(publishedPosts, post) - - feed.Items = append(feed.Items, &feeds.Item{ - Title: post.Title, - Content: string(post.Rendered), - Link: &feeds.Link{ - Href: fmt.Sprintf( - "https://%s/%s.html", - cfg.Domain, - common.Slug(post.Title), - ), - }, - Author: &feeds.Author{Name: cfg.Author}, - Created: time.Unix(post.Timestamp, 0), - }) - } - - /* Put everything together */ - t, err := plainTemplate.New("blog").Parse(indexTemplate) if err != nil { - return fmt.Errorf("Error parsing the html template: %s", err) - } - - /* Save site to out dir */ - sitePath := filepath.Join(OutPath, INDEX_FILE) - file, err := os.Create(sitePath) - if err != nil { - return fmt.Errorf("Error creating index file: %s", err) - } - - var indexVars = struct { - Posts []Post - Cfg config.Config - }{ - Posts: publishedPosts, - Cfg: *cfg, - } - - if err := t.Execute(file, indexVars); err != nil { - return fmt.Errorf("Error rendering posts: %s", err) - } - - if err := file.Close(); err != nil { - return fmt.Errorf("Error closing file: %s", err) - } - - rss, err := feed.ToRss() - if err != nil { - return fmt.Errorf("Error generating rss feed: %w", err) - } - - rssPath := filepath.Join(OutPath, "rss.xml") - if err := os.WriteFile(rssPath, []byte(rss), 0777); err != nil { - return fmt.Errorf("Error writing rss.xml: %w", err) - } - - // write content from public dir - for _, file := range common.Unwrap(content.ReadDir(PUBLIC_DIR_PATH)) { - if strings.Contains(file.Name(), "tmpl") { - continue - } - - inPath := path.Join(PUBLIC_DIR_PATH, file.Name()) - f, err := content.ReadFile(inPath) - if err != nil { - return fmt.Errorf("Error reading %s", inPath) - } - - outPath := filepath.Join(OutPath, file.Name()) - if err := os.WriteFile(outPath, f, 0777); err != nil { - return fmt.Errorf("Error writing %s: %w", file.Name(), err) - } - } - - /* copy chill-files to output dir */ - for _, chillFile := range cfg.ChillFiles { - src := chillFile - if !filepath.IsAbs(src) { - src = filepath.Join(BlogPath, src) - } - dst := filepath.Join(OutPath, filepath.Base(src)) - if err := common.CopyFile(src, dst); err != nil { - return fmt.Errorf("copy chill-file %s to %s failed: %w", src, dst, err) - } - fmt.Printf("copied chill-file %s to %s\n", src, dst) + return err } - return nil + return s.Render() }, } const ( - DEFAULT_OUT_PATH = "./out/" - INDEX_FILE = "index.html" - POST_FILE_TEMPLATE = "%s.html" + DEFAULT_OUT_PATH = "./out/" ) var ( diff --git a/cmd/update.go b/cmd/update.go index 6a22376..c153f12 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -41,7 +41,7 @@ var updateCmd = &cobra.Command{ return fmt.Errorf("Slug missing") } - contentFile := GetContentFile(filepath.Join(BlogPath, Slug)) + contentFile := common.GetContentFile(filepath.Join(BlogPath, Slug)) if _, err := os.Stat(contentFile); os.IsNotExist(err) { return fmt.Errorf("Error updating post '%s': Not existing", Slug) } diff --git a/internal/common/common.go b/internal/common/common.go index 2a9e2d4..8a30ddd 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -5,12 +5,20 @@ import ( "io" "os" "os/exec" + "path/filepath" "regexp" "strings" "github.com/kballard/go-shellquote" ) +const CONTENT_FILE = "content.md" + +// GetContentFile returns the path to a post's markdown content file. +func GetContentFile(postPath string) string { + return filepath.Join(postPath, CONTENT_FILE) +} + func Unwrap[T any](value T, err error) T { if err != nil { panic(err) diff --git a/internal/site/load.go b/internal/site/load.go new file mode 100644 index 0000000..930aa5a --- /dev/null +++ b/internal/site/load.go @@ -0,0 +1,152 @@ +package site + +import ( + "fmt" + "html/template" + "os" + "path/filepath" + "regexp" + "sort" + + "github.com/RaphaelPour/blogctl/internal/common" + "github.com/RaphaelPour/blogctl/internal/highlighter" + "github.com/RaphaelPour/blogctl/internal/metadata" + + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/parser" +) + +const ( + indexFile = "index.html" + postFileTemplate = "%s.html" +) + +// imageMacro matches the IMAGE() shortcode used in post bodies. +var imageMacro = regexp.MustCompile(`IMAGE\(([\w\.]+)\)`) + +// loadPosts discovers every post directory under BlogPath, loads and renders +// the public ones, sorts them newest-first and wires up navigation links. +func (s *Site) loadPosts() error { + postDirs, err := os.ReadDir(s.Options.BlogPath) + if err != nil { + return fmt.Errorf("Error reading blog path: %s", err) + } + + posts := make([]Post, 0) + for i, dir := range postDirs { + if !dir.IsDir() { + continue + } + + postPath := filepath.Join(s.Options.BlogPath, dir.Name()) + files, err := os.ReadDir(postPath) + if err != nil { + return fmt.Errorf("Error reading post path of %s: %s", postPath, err) + } + + if len(files) < 2 { + return fmt.Errorf( + "Unexpected count of files in post path %s. Found: %d", + postPath, + len(files), + ) + } + + meta, err := metadata.Load(postPath) + if err != nil { + return err + } + + /* Overstep posts which aren't set to 'public' */ + if meta.Status != metadata.PUBLIC_STATUS { + continue + } + + fmt.Printf("Rendering post #%02d: %s\n", i, dir.Name()) + + post, err := s.loadPost(postPath, meta) + if err != nil { + return err + } + posts = append(posts, post) + } + + /* Sort posts newest-first */ + sort.Slice(posts, func(i, j int) bool { + return posts[i].Timestamp > posts[j].Timestamp + }) + + linkNavigation(posts) + + s.Posts = posts + return nil +} + +// loadPost reads a single post's content, renders its markdown and resolves +// the IMAGE() shortcodes into tags plus a list of images to copy. +func (s *Site) loadPost(postPath string, meta *metadata.Metadata) (Post, error) { + slugTitle := common.Slug(meta.Title) + + content, err := os.ReadFile(common.GetContentFile(postPath)) + if err != nil { + return Post{}, fmt.Errorf("Error reading post content %s: %s", postPath, err) + } + + rendered := markdown.ToHTML( + content, parser.NewWithExtensions(parser.CommonExtensions|parser.Footnotes), + highlighter.GetRenderer(), + ) + + /* replace all IMAGE() with valid path to filename */ + renderedStr := imageMacro.ReplaceAllString( + string(rendered), + fmt.Sprintf(``, slugTitle), + ) + + images := make([]imageRef, 0) + for _, match := range imageMacro.FindAllStringSubmatch(string(rendered), -1) { + images = append(images, imageRef{ + src: filepath.Join(s.Options.BlogPath, fmt.Sprintf("%s/%s", slugTitle, match[1])), + dst: filepath.Join(s.Options.OutPath, fmt.Sprintf("%s_%s", slugTitle, match[1])), + }) + } + + var featuredImage string + if len(meta.FeaturedImage) > 0 { + featuredImage = fmt.Sprintf("https://%s/%s_%s", s.Config.Domain, slugTitle, meta.FeaturedImage) + } + + return Post{ + Title: meta.Title, + Link: fmt.Sprintf(postFileTemplate, slugTitle), + PermaLink: fmt.Sprintf("https://%s/%s.html", s.Config.Domain, slugTitle), + HomeLink: indexFile, + Timestamp: meta.CreatedAt, + CreatedAt: meta.Date(), + Content: renderedStr, + Discussion: s.Config.Discussion && !meta.Static, + Rendered: template.HTML(renderedStr), + FeaturedImage: featuredImage, + Metadata: meta, + images: images, + }, nil +} + +// linkNavigation wires up previous/next links between non-static posts in the +// already-sorted slice. +func linkNavigation(posts []Post) { + nextPost := -1 + for i := 0; i < len(posts); i++ { + if posts[i].Metadata.Static { + continue + } + + if nextPost >= 0 { + posts[i].NextPostLink = posts[nextPost].Link + posts[nextPost].PreviousPostLink = posts[i].Link + + fmt.Println(posts[i].Title, "<->", posts[nextPost].Title) + } + nextPost = i + } +} diff --git a/internal/site/post.go b/internal/site/post.go new file mode 100644 index 0000000..4f20853 --- /dev/null +++ b/internal/site/post.go @@ -0,0 +1,35 @@ +package site + +import ( + "html/template" + + "github.com/RaphaelPour/blogctl/internal/metadata" +) + +// Post is the in-memory representation of a single blog post ready to be +// written to disk. +type Post struct { + Title string + Link string + PermaLink string + PreviousPostLink string + NextPostLink string + HomeLink string + Timestamp int64 + CreatedAt string + Content string + FeaturedImage string + Discussion bool + Rendered template.HTML + Metadata *metadata.Metadata + + // images lists files referenced via IMAGE() in the post body that need to + // be copied from the post's source directory into the output directory. + images []imageRef +} + +// imageRef is a single image to copy from a post directory to the output dir. +type imageRef struct { + src string + dst string +} diff --git a/cmd/public/blogstyle.css b/internal/site/public/blogstyle.css similarity index 100% rename from cmd/public/blogstyle.css rename to internal/site/public/blogstyle.css diff --git a/cmd/public/codestyle.css b/internal/site/public/codestyle.css similarity index 100% rename from cmd/public/codestyle.css rename to internal/site/public/codestyle.css diff --git a/cmd/public/index.tmpl.html b/internal/site/public/index.tmpl.html similarity index 100% rename from cmd/public/index.tmpl.html rename to internal/site/public/index.tmpl.html diff --git a/cmd/public/post.tmpl.html b/internal/site/public/post.tmpl.html similarity index 100% rename from cmd/public/post.tmpl.html rename to internal/site/public/post.tmpl.html diff --git a/cmd/public/static.tmpl.html b/internal/site/public/static.tmpl.html similarity index 100% rename from cmd/public/static.tmpl.html rename to internal/site/public/static.tmpl.html diff --git a/internal/site/site.go b/internal/site/site.go new file mode 100644 index 0000000..b450594 --- /dev/null +++ b/internal/site/site.go @@ -0,0 +1,87 @@ +package site + +import ( + "fmt" + "os" + "time" + + "github.com/RaphaelPour/blogctl/internal/config" + + "github.com/gorilla/feeds" +) + +// Options configures a single render run. +type Options struct { + BlogPath string + OutPath string + Force bool +} + +// Site is the in-memory model of a blog ready to be rendered. It owns the +// loaded configuration, the loaded posts and the feed accumulated while +// rendering. Build stages share a *Site, so a later stage can rely on state +// produced by an earlier one. +type Site struct { + Config *config.Config + Options Options + Posts []Post // all public posts, sorted newest-first, including static pages + Published []Post // non-static posts shown on the index and in the feed + Feed *feeds.Feed +} + +// New loads the blog configuration and all public posts from disk, rendering +// their markdown and wiring up next/previous navigation. It performs no writes. +func New(opts Options) (*Site, error) { + cfg, err := config.Load(opts.BlogPath) + if err != nil { + return nil, err + } + + s := &Site{ + Config: cfg, + Options: opts, + Feed: &feeds.Feed{ + Title: cfg.Title, + Link: &feeds.Link{Href: fmt.Sprintf("https://%s", cfg.Domain)}, + Description: cfg.Description, + Author: &feeds.Author{Name: cfg.Author}, + Created: time.Now(), + Items: make([]*feeds.Item, 0), + }, + } + + if err := s.loadPosts(); err != nil { + return nil, err + } + + return s, nil +} + +// Render writes the static website to the configured output directory by +// running each build stage in order. New output artifacts (sitemaps, tag +// pages, ...) can be added by implementing Stage and appending it in stages(). +func (s *Site) Render() error { + if err := s.prepareOutput(); err != nil { + return err + } + + for _, stage := range s.stages() { + if err := stage.Run(s); err != nil { + return fmt.Errorf("%s: %w", stage.Name(), err) + } + } + + return nil +} + +func (s *Site) prepareOutput() error { + if _, err := os.Stat(s.Options.OutPath); !os.IsNotExist(err) && !s.Options.Force { + return fmt.Errorf("Output folder already exists") + } + + if err := os.MkdirAll(s.Options.OutPath, os.ModePerm); err != nil { + return fmt.Errorf("Error creating output folder: %s", err) + } + + return nil +} diff --git a/internal/site/stages.go b/internal/site/stages.go new file mode 100644 index 0000000..72b732f --- /dev/null +++ b/internal/site/stages.go @@ -0,0 +1,203 @@ +package site + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/RaphaelPour/blogctl/internal/common" + "github.com/RaphaelPour/blogctl/internal/config" + + "github.com/gorilla/feeds" +) + +const rssFile = "rss.xml" + +// Stage is one step of the render pipeline. Stages run in order and share the +// Site, so a later stage can rely on state produced by an earlier one (e.g. +// renderPostsStage populates Published and the feed before renderIndexStage +// and generateRSSStage run). Add a new output artifact by implementing Stage +// and appending it in stages(). +type Stage interface { + Name() string + Run(s *Site) error +} + +// stages returns the ordered pipeline of build steps. +func (s *Site) stages() []Stage { + return []Stage{ + renderPostsStage{}, + renderIndexStage{}, + generateRSSStage{}, + copyAssetsStage{}, + copyChillFilesStage{}, + } +} + +// renderPostsStage writes one HTML file per post, copies referenced images and +// accumulates the published (non-static) posts into Published and the feed. +type renderPostsStage struct{} + +func (renderPostsStage) Name() string { return "render posts" } + +func (renderPostsStage) Run(s *Site) error { + published := make([]Post, 0) + for _, post := range s.Posts { + /* copy referenced images next to the rendered post */ + for _, img := range post.images { + if err := common.CopyFile(img.src, img.dst); err != nil { + return fmt.Errorf("error copying '%s' to '%s': %w", img.src, img.dst, err) + } + } + + /* Render single post */ + tmpl := postTmpl + if post.Metadata.Static { + tmpl = staticTmpl + } + + postFilePath := filepath.Join(s.Options.OutPath, post.Link) + file, err := os.Create(postFilePath) + if err != nil { + return fmt.Errorf("Error creating post file '%s': %s", post.Title, err) + } + + if err := tmpl.Execute(file, post); err != nil { + return fmt.Errorf("Error rendering post '%s': %s", post.Title, err) + } + + if err := file.Close(); err != nil { + return fmt.Errorf("Error closing post file '%s': %s", post.Title, err) + } + + /* skip static sites, add others to published+feed to list them on the start page */ + if post.Metadata.Static { + continue + } + published = append(published, post) + + s.Feed.Items = append(s.Feed.Items, &feeds.Item{ + Title: post.Title, + Content: string(post.Rendered), + Link: &feeds.Link{ + Href: fmt.Sprintf( + "https://%s/%s.html", + s.Config.Domain, + common.Slug(post.Title), + ), + }, + Author: &feeds.Author{Name: s.Config.Author}, + Created: time.Unix(post.Timestamp, 0), + }) + } + + s.Published = published + return nil +} + +// renderIndexStage renders the start page listing all published posts. +type renderIndexStage struct{} + +func (renderIndexStage) Name() string { return "render index" } + +func (renderIndexStage) Run(s *Site) error { + sitePath := filepath.Join(s.Options.OutPath, indexFile) + file, err := os.Create(sitePath) + if err != nil { + return fmt.Errorf("Error creating index file: %s", err) + } + + indexVars := struct { + Posts []Post + Cfg config.Config + }{ + Posts: s.Published, + Cfg: *s.Config, + } + + if err := indexTmpl.Execute(file, indexVars); err != nil { + return fmt.Errorf("Error rendering posts: %s", err) + } + + if err := file.Close(); err != nil { + return fmt.Errorf("Error closing file: %s", err) + } + + return nil +} + +// generateRSSStage writes the RSS feed accumulated by renderPostsStage. +type generateRSSStage struct{} + +func (generateRSSStage) Name() string { return "generate rss" } + +func (generateRSSStage) Run(s *Site) error { + rss, err := s.Feed.ToRss() + if err != nil { + return fmt.Errorf("Error generating rss feed: %w", err) + } + + rssPath := filepath.Join(s.Options.OutPath, rssFile) + if err := os.WriteFile(rssPath, []byte(rss), 0777); err != nil { + return fmt.Errorf("Error writing rss.xml: %w", err) + } + + return nil +} + +// copyAssetsStage copies the default theme's static assets (CSS, ...) from the +// embedded filesystem into the output directory, skipping templates. +type copyAssetsStage struct{} + +func (copyAssetsStage) Name() string { return "copy assets" } + +func (copyAssetsStage) Run(s *Site) error { + entries, err := publicFS.ReadDir(publicDir) + if err != nil { + return fmt.Errorf("Error reading embedded assets: %w", err) + } + + for _, file := range entries { + if strings.Contains(file.Name(), "tmpl") { + continue + } + + inPath := path.Join(publicDir, file.Name()) + f, err := publicFS.ReadFile(inPath) + if err != nil { + return fmt.Errorf("Error reading %s", inPath) + } + + outPath := filepath.Join(s.Options.OutPath, file.Name()) + if err := os.WriteFile(outPath, f, 0777); err != nil { + return fmt.Errorf("Error writing %s: %w", file.Name(), err) + } + } + + return nil +} + +// copyChillFilesStage copies the extra files listed in the config into the +// output directory. +type copyChillFilesStage struct{} + +func (copyChillFilesStage) Name() string { return "copy chill-files" } + +func (copyChillFilesStage) Run(s *Site) error { + for _, chillFile := range s.Config.ChillFiles { + src := chillFile + if !filepath.IsAbs(src) { + src = filepath.Join(s.Options.BlogPath, src) + } + dst := filepath.Join(s.Options.OutPath, filepath.Base(src)) + if err := common.CopyFile(src, dst); err != nil { + return fmt.Errorf("copy chill-file %s to %s failed: %w", src, dst, err) + } + fmt.Printf("copied chill-file %s to %s\n", src, dst) + } + + return nil +} diff --git a/internal/site/templates.go b/internal/site/templates.go new file mode 100644 index 0000000..a3be322 --- /dev/null +++ b/internal/site/templates.go @@ -0,0 +1,31 @@ +package site + +import ( + "embed" + "html/template" + "path" + plainTemplate "text/template" + + "github.com/RaphaelPour/blogctl/internal/common" +) + +const publicDir = "public" + +var ( + //go:embed public/* + publicFS embed.FS + + // postTmpl and staticTmpl render individual post pages with html/template. + postTmpl = template.Must(template.New("post").Parse(mustReadAsset("post.tmpl.html"))) + staticTmpl = template.Must(template.New("static").Parse(mustReadAsset("static.tmpl.html"))) + + // indexTmpl renders the start page. It deliberately uses text/template to + // preserve the original (unescaped) rendering behaviour. + indexTmpl = plainTemplate.Must(plainTemplate.New("blog").Parse(mustReadAsset("index.tmpl.html"))) +) + +// mustReadAsset reads a file embedded from the default theme. The assets are +// guaranteed to exist at build time, so a failure here is a programmer error. +func mustReadAsset(name string) string { + return string(common.Unwrap(publicFS.ReadFile(path.Join(publicDir, name)))) +} From fa10f2270fe9b7d2f407aafe279e265e0239a1f8 Mon Sep 17 00:00:00 2001 From: Raphael Pour Date: Mon, 15 Jun 2026 09:09:17 +0200 Subject: [PATCH 2/3] Make the renderer silent by default, add render --verbose The internal/site package wrote progress directly to stdout, which is noise when the package is embedded as a library. Route progress through an injectable io.Writer on Options (nil = io.Discard, so silent by default) and let the render command opt in via a new --verbose/-v flag. Also drop the leftover " <-> <title>" debug print from the navigation-linking loop. Output files are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- cmd/render.go | 20 ++++++++++++++++++-- internal/site/load.go | 4 +--- internal/site/site.go | 18 ++++++++++++++++++ internal/site/stages.go | 2 +- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/cmd/render.go b/cmd/render.go index 558305f..e722efc 100644 --- a/cmd/render.go +++ b/cmd/render.go @@ -17,6 +17,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. package cmd import ( + "os" + "github.com/RaphaelPour/blogctl/internal/site" "github.com/spf13/cobra" @@ -28,11 +30,16 @@ var renderCmd = &cobra.Command{ Short: "Renders blog to static website", Long: "Collects all posts and renders the markdown using the metadata as static website", RunE: func(cmd *cobra.Command, args []string) error { - s, err := site.New(site.Options{ + opts := site.Options{ BlogPath: BlogPath, OutPath: OutPath, Force: Force, - }) + } + if Verbose { + opts.Log = os.Stdout + } + + s, err := site.New(opts) if err != nil { return err } @@ -48,6 +55,7 @@ const ( var ( OutPath string Force bool + Verbose bool ) func init() { @@ -68,4 +76,12 @@ func init() { false, "Overwrites an existing output folder.", ) + + renderCmd.Flags().BoolVarP( + &Verbose, + "verbose", + "v", + false, + "Print progress while rendering.", + ) } diff --git a/internal/site/load.go b/internal/site/load.go index 930aa5a..f04de34 100644 --- a/internal/site/load.go +++ b/internal/site/load.go @@ -62,7 +62,7 @@ func (s *Site) loadPosts() error { continue } - fmt.Printf("Rendering post #%02d: %s\n", i, dir.Name()) + s.logf("Rendering post #%02d: %s\n", i, dir.Name()) post, err := s.loadPost(postPath, meta) if err != nil { @@ -144,8 +144,6 @@ func linkNavigation(posts []Post) { if nextPost >= 0 { posts[i].NextPostLink = posts[nextPost].Link posts[nextPost].PreviousPostLink = posts[i].Link - - fmt.Println(posts[i].Title, "<->", posts[nextPost].Title) } nextPost = i } diff --git a/internal/site/site.go b/internal/site/site.go index b450594..c2bd52c 100644 --- a/internal/site/site.go +++ b/internal/site/site.go @@ -2,6 +2,7 @@ package site import ( "fmt" + "io" "os" "time" @@ -15,6 +16,10 @@ type Options struct { BlogPath string OutPath string Force bool + + // Log receives human-readable progress messages. If nil, progress output + // is discarded, which keeps the renderer silent when used as a library. + Log io.Writer } // Site is the in-memory model of a blog ready to be rendered. It owns the @@ -27,6 +32,13 @@ type Site struct { Posts []Post // all public posts, sorted newest-first, including static pages Published []Post // non-static posts shown on the index and in the feed Feed *feeds.Feed + + log io.Writer +} + +// logf writes a progress message to the configured log writer. +func (s *Site) logf(format string, a ...any) { + fmt.Fprintf(s.log, format, a...) } // New loads the blog configuration and all public posts from disk, rendering @@ -37,9 +49,15 @@ func New(opts Options) (*Site, error) { return nil, err } + logw := opts.Log + if logw == nil { + logw = io.Discard + } + s := &Site{ Config: cfg, Options: opts, + log: logw, Feed: &feeds.Feed{ Title: cfg.Title, Link: &feeds.Link{Href: fmt.Sprintf("https://%s", cfg.Domain)}, diff --git a/internal/site/stages.go b/internal/site/stages.go index 72b732f..a3b2f07 100644 --- a/internal/site/stages.go +++ b/internal/site/stages.go @@ -196,7 +196,7 @@ func (copyChillFilesStage) Run(s *Site) error { if err := common.CopyFile(src, dst); err != nil { return fmt.Errorf("copy chill-file %s to %s failed: %w", src, dst, err) } - fmt.Printf("copied chill-file %s to %s\n", src, dst) + s.logf("copied chill-file %s to %s\n", src, dst) } return nil From d15006005e8b5fd0f0363b526350a91b43f1cc3e Mon Sep 17 00:00:00 2001 From: Raphael Pour <info@raphaelpour.de> Date: Tue, 16 Jun 2026 07:53:11 +0200 Subject: [PATCH 3/3] dependency: update --- go.mod | 29 ++++++++++++++++------------- go.sum | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index d707a36..be95c77 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module github.com/RaphaelPour/blogctl -go 1.24.0 +go 1.25.0 require ( github.com/alecthomas/chroma v0.10.0 - github.com/fatih/color v1.18.0 - github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a + github.com/fatih/color v1.19.0 + github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 github.com/gorilla/feeds v1.2.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/olekukonko/tablewriter v1.1.0 - github.com/spf13/cobra v1.10.1 + github.com/olekukonko/tablewriter v1.1.4 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.8.4 ) @@ -17,10 +17,13 @@ require ( github.com/MarkusFreitag/changelogger v0.7.0 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/google/go-github/v30 v30.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -28,14 +31,14 @@ require ( github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.5 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect - github.com/olekukonko/errors v1.1.0 // indirect - github.com/olekukonko/ll v0.1.2 // indirect + github.com/olekukonko/errors v1.3.0 // indirect + github.com/olekukonko/ll v0.1.8 // indirect github.com/pelletier/go-toml v1.9.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rhysd/go-github-selfupdate v1.2.3 // indirect @@ -51,7 +54,7 @@ require ( github.com/ulikunitz/xz v0.5.15 // indirect golang.org/x/crypto v0.43.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.30.0 // indirect gopkg.in/AlecAivazis/survey.v1 v1.8.8 // indirect gopkg.in/ini.v1 v1.66.2 // indirect diff --git a/go.sum b/go.sum index 3a3e687..03e9c1d 100644 --- a/go.sum +++ b/go.sum @@ -78,17 +78,24 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -111,6 +118,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -127,6 +136,8 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= @@ -139,6 +150,8 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -176,6 +189,8 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 h1:Ltt9ldIaSYEsjA7sPY2c8r9dOmnKM1vlzhh3dxlhBHM= +github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -307,6 +322,8 @@ github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= @@ -315,8 +332,12 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= @@ -343,10 +364,16 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U= +github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.1.2 h1:lkg/k/9mlsy0SxO5aC+WEpbdT5K83ddnNhAepz7TQc0= github.com/olekukonko/ll v0.1.2/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= +github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.9.0 h1:R1uwffexN6Pr340GtYRIdZmAiN4J+iw6WG4wog1DUXg= @@ -398,6 +425,8 @@ github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra-cli v1.3.0 h1:Y/qy0X40kDT+k7PCyBQrsjh/qOf9t/ZVScbn0OyZD84= github.com/spf13/cobra-cli v1.3.0/go.mod h1:zq1KeHo/9SQm1tNdbJhwVDd9bVpokbQwuG6MR0TFCdE= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= @@ -447,6 +476,7 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -643,6 +673,8 @@ golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=