diff --git a/cmd/cli/cli_test.go b/cmd/cli/cli_test.go index ebbef34..bdd286e 100644 --- a/cmd/cli/cli_test.go +++ b/cmd/cli/cli_test.go @@ -18,9 +18,9 @@ func TestNewRootCommand(t *testing.T) { // Ensure subcommands are registered. commands := cmd.Commands() - assert.GreaterOrEqual(t, len(commands), 4, "Should have at least 4 subcommands") + assert.GreaterOrEqual(t, len(commands), 5, "Should have at least 5 subcommands") - var hasGenerate, hasInit, hasMigrate, hasVersion bool + var hasGenerate, hasInit, hasMigrate, hasVersion, hasOGImage bool for _, subCmd := range commands { switch subCmd.Use { case "generate": @@ -31,6 +31,8 @@ func TestNewRootCommand(t *testing.T) { hasMigrate = true case "version": hasVersion = true + case "ogimage": + hasOGImage = true } } @@ -38,6 +40,7 @@ func TestNewRootCommand(t *testing.T) { assert.True(t, hasInit, "Should have 'init' subcommand") assert.True(t, hasMigrate, "Should have 'migrate' subcommand") assert.True(t, hasVersion, "Should have 'version' subcommand") + assert.True(t, hasOGImage, "Should have 'ogimage' subcommand") } func TestRootCommand_Flags(t *testing.T) { diff --git a/cmd/cli/subcommand/generate.go b/cmd/cli/subcommand/generate.go index f25e072..bb6259c 100644 --- a/cmd/cli/subcommand/generate.go +++ b/cmd/cli/subcommand/generate.go @@ -3,16 +3,23 @@ package subcommand import ( "fmt" "log/slog" - "strconv" + "os" + "path/filepath" + "strings" + "time" "github.com/rokuosan/github-issue-cms/pkg/config" "github.com/rokuosan/github-issue-cms/pkg/core" + "github.com/rokuosan/github-issue-cms/pkg/ogimage" "github.com/spf13/cobra" ) // NewGenerateCommand creates the generate subcommand. func NewGenerateCommand() *cobra.Command { - var githubToken string + var ( + githubToken string + withOGImage bool + ) cmd := &cobra.Command{ Use: "generate", @@ -31,20 +38,25 @@ Examples: github-issue-cms -v generate --token YOUR_GITHUB_TOKEN # Generate with debug logging - github-issue-cms -vv generate --token YOUR_GITHUB_TOKEN`, + github-issue-cms -vv generate --token YOUR_GITHUB_TOKEN + + # Generate articles with OGP images + github-issue-cms generate --token YOUR_GITHUB_TOKEN --with-ogimage`, + RunE: func(cmd *cobra.Command, args []string) error { - return runGenerate(cmd, githubToken) + return runGenerate(cmd, githubToken, withOGImage) }, } // Define flags. cmd.Flags().StringVarP(&githubToken, "token", "t", "", "GitHub API Token (required)") + cmd.Flags().BoolVar(&withOGImage, "with-ogimage", false, "Generate OGP images alongside articles") _ = cmd.MarkFlagRequired("token") return cmd } -func runGenerate(cmd *cobra.Command, githubToken string) error { +func runGenerate(cmd *cobra.Command, githubToken string, withOGImage bool) error { // Load configuration. conf, err := config.Get() if err != nil { @@ -63,6 +75,25 @@ func runGenerate(cmd *cobra.Command, githubToken string) error { return fmt.Errorf("failed to create generator: %w", err) } + // Set up OGP image generation hook if requested. + var ogpOK, ogpFail int + if withOGImage { + renderer, err := ogimage.NewRenderer("") + if err != nil { + return fmt.Errorf("failed to create OGP renderer: %w", err) + } + generator.SetOnArticleSaved(func(article *core.Article) error { + err := generateOGPForArticle(cmd, conf, renderer, article) + if err != nil { + ogpFail++ + return err + } + ogpOK++ + return nil + }) + slog.Info("OGP image generation enabled (--with-ogimage)") + } + // Generate articles. slog.Info("Generating articles...") count, err := generator.Generate(cmd.Context(), conf.GitHub.Username, conf.GitHub.Repository) @@ -70,6 +101,99 @@ func runGenerate(cmd *cobra.Command, githubToken string) error { return fmt.Errorf("failed to generate articles: %w", err) } - slog.Info("Complete: " + strconv.Itoa(count) + " articles generated") + if withOGImage { + summary := fmt.Sprintf("Complete: %d articles generated, %d OGP images (%d failed)", count, ogpOK, ogpFail) + if ogpFail > 0 { + // Log at Error level so the failure summary is visible even at + // the default verbosity (the root logger threshold is Error). + slog.Error(summary) + } else { + slog.Info(summary) + } + } else { + slog.Info(fmt.Sprintf("Complete: %d articles generated", count)) + } + return nil +} + +// generateOGPForArticle renders an OGP image for the given article and saves +// it alongside the article markdown file. +func generateOGPForArticle(cmd *cobra.Command, conf config.Config, renderer *ogimage.Renderer, article *core.Article) error { + if article == nil { + return fmt.Errorf("article is nil") + } + + // Check context before expensive render. + if err := cmd.Context().Err(); err != nil { + return fmt.Errorf("context cancelled before OGP render: %w", err) + } + + // Apply frontmatter overrides so the OGP image reflects the final + // rendered values, not the original GitHub issue metadata. + rendered := article.Clone() + core.ApplyFrontMatterOverrides(rendered, rendered.FrontMatter.Values()) + data := articleToOGPData(rendered) + + jpeg, err := renderer.Render(cmd.Context(), data) + if err != nil { + return fmt.Errorf("render OGP: %w", err) + } + + outputPath, err := resolveOGPArticlePath(conf, rendered) + if err != nil { + return fmt.Errorf("resolve OGP path: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + + if err := os.WriteFile(outputPath, jpeg, 0o644); err != nil { + return fmt.Errorf("write OGP image: %w", err) + } + + slog.Debug("OGP image generated: " + outputPath) return nil } + +// resolveOGPArticlePath returns the path where the OGP image should be saved +// for an article. It reconstructs the article's save directory to place +// ogp.jpeg alongside the markdown file. +func resolveOGPArticlePath(conf config.Config, article *core.Article) (string, error) { + if article == nil { + return "", fmt.Errorf("article is nil") + } + if conf.Output == nil || conf.Output.Articles == nil { + return "", fmt.Errorf("output articles config is not set") + } + + datetime, err := article.ParseDateTime() + if err != nil { + datetime = time.Now() + } + + articleDir := conf.Output.Articles.Directory + if articleDir == "" { + return "", fmt.Errorf("output articles directory is not configured") + } + articleDir = config.CompileTimeTemplate(datetime, articleDir) + + // If the article is saved as a page bundle (index.md), the directory + // already uniquely identifies the article — place ogp.jpeg there. + // Otherwise (flat layout), save the OGP image as a unique file adjacent + // to the markdown file. The OGP name is derived by appending ".ogp.jpeg" + // after stripping ONLY a known markdown extension (.md/.markdown); for + // any other extension we append to the full filename so the image always + // stays adjacent to the markdown (e.g. "my.post" → "my.post.ogp.jpeg"). + articleFilename := config.CompileTimeTemplate(datetime, conf.Output.Articles.Filename) + if articleFilename == "index.md" { + return filepath.Clean(filepath.Join(articleDir, "ogp.jpeg")), nil + } + + base := articleFilename + if ext := filepath.Ext(articleFilename); ext == ".md" || ext == ".markdown" { + base = strings.TrimSuffix(articleFilename, ext) + } + ogpName := base + ".ogp.jpeg" + return filepath.Clean(filepath.Join(articleDir, ogpName)), nil +} diff --git a/cmd/cli/subcommand/generate_test.go b/cmd/cli/subcommand/generate_test.go index 579309a..dba8c4c 100644 --- a/cmd/cli/subcommand/generate_test.go +++ b/cmd/cli/subcommand/generate_test.go @@ -3,7 +3,10 @@ package subcommand import ( "testing" + "github.com/rokuosan/github-issue-cms/pkg/config" + "github.com/rokuosan/github-issue-cms/pkg/core" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewGenerateCommand(t *testing.T) { @@ -18,7 +21,11 @@ func TestNewGenerateCommand(t *testing.T) { assert.NotNil(t, tokenFlag) assert.Equal(t, "t", tokenFlag.Shorthand) - // Ensure the flag is marked as required. + // Verify the --with-ogimage flag exists. + ogimageFlag := cmd.Flags().Lookup("with-ogimage") + assert.NotNil(t, ogimageFlag, "--with-ogimage flag should exist") + + // Ensure the token flag is marked as required. assert.Contains(t, cmd.Flags().Lookup("token").Annotations, "cobra_annotation_bash_completion_one_required_flag") } @@ -29,33 +36,109 @@ func TestGenerateCommand_Flags(t *testing.T) { tokenFlag := cmd.Flags().Lookup("token") assert.NotNil(t, tokenFlag, "token flag should exist") assert.Equal(t, "t", tokenFlag.Shorthand, "token shorthand should be 't'") -} - -func TestGenerateCommand_Help(t *testing.T) { - cmd := NewGenerateCommand() - cmd.SetArgs([]string{"--help"}) - err := cmd.Execute() - assert.NoError(t, err) + // Test the --with-ogimage flag. + ogimageFlag := cmd.Flags().Lookup("with-ogimage") + assert.NotNil(t, ogimageFlag, "--with-ogimage flag should exist") } -func TestGenerateCommand_MissingToken(t *testing.T) { +func TestGenerateCommand_WithOGImageFlag(t *testing.T) { cmd := NewGenerateCommand() - cmd.SetArgs([]string{}) // No token provided. - + cmd.SetArgs([]string{"--token", "test-token", "--with-ogimage"}) + // This will fail because no config exists, but verifies the flag is parsed. err := cmd.Execute() - assert.Error(t, err, "Should error when token is missing") -} - -func TestGenerateCommand_WithToken(t *testing.T) { - // Skip because this requires an integration test. - t.Skip("Integration test required - needs valid config file") + assert.Error(t, err) // Missing config is expected. } func TestGenerateCommand_Examples(t *testing.T) { cmd := NewGenerateCommand() - // Ensure the examples are present. + // Ensure the examples are present and mention --with-ogimage. assert.NotEmpty(t, cmd.Long) assert.Contains(t, cmd.Long, "Examples:") + assert.Contains(t, cmd.Long, "--with-ogimage") +} + +func TestResolveOGPArticlePath(t *testing.T) { + t.Run("flat layout places OGP adjacent to markdown with swapped extension", func(t *testing.T) { + conf := testConfig(t.TempDir() + "/articles") + article := &core.Article{ + Date: "2024-01-15T10:30:00Z", + Key: "2024-01-15_103000", + } + + path, err := resolveOGPArticlePath(conf, article) + require.NoError(t, err) + // Markdown is saved as content/posts/2024-01-15_103000.md, so the + // OGP image must be the adjacent file 2024-01-15_103000.ogp.jpeg — + // not an orphaned content/posts//ogp.jpeg subdirectory. + assert.Equal(t, "content/posts/2024-01-15_103000.ogp.jpeg", path) + }) + + t.Run("page bundle layout places ogp.jpeg in the bundle directory", func(t *testing.T) { + conf := testConfig(t.TempDir() + "/articles") + conf.Output.Articles.Filename = "index.md" + article := &core.Article{ + Date: "2024-01-15T10:30:00Z", + Key: "2024-01-15_103000", + } + + path, err := resolveOGPArticlePath(conf, article) + require.NoError(t, err) + assert.Equal(t, "content/posts/ogp.jpeg", path) + }) + + t.Run("flat layout uses datetime from date, not the article key", func(t *testing.T) { + conf := testConfig(t.TempDir() + "/articles") + article := &core.Article{ + Date: "2024-01-15T10:30:00Z", + Key: "some-other-key", + } + + path, err := resolveOGPArticlePath(conf, article) + require.NoError(t, err) + assert.Equal(t, "content/posts/2024-01-15_103000.ogp.jpeg", path) + }) + + t.Run("non-md extension stays adjacent by appending", func(t *testing.T) { + conf := testConfig(t.TempDir() + "/articles") + conf.Output.Articles.Filename = "%Y-%m-%d.post" + article := &core.Article{ + Date: "2024-01-15T10:30:00Z", + } + + path, err := resolveOGPArticlePath(conf, article) + require.NoError(t, err) + // Non-markdown extensions are NOT stripped — the OGP appends to the + // full filename so it always stays adjacent to the markdown. + assert.Equal(t, "content/posts/2024-01-15.post.ogp.jpeg", path) + }) + + t.Run(".markdown extension is stripped like .md", func(t *testing.T) { + conf := testConfig(t.TempDir() + "/articles") + conf.Output.Articles.Filename = "%Y-%m-%d.markdown" + article := &core.Article{ + Date: "2024-01-15T10:30:00Z", + } + + path, err := resolveOGPArticlePath(conf, article) + require.NoError(t, err) + assert.Equal(t, "content/posts/2024-01-15.ogp.jpeg", path) + }) + + t.Run("nil article returns error", func(t *testing.T) { + conf := testConfig(t.TempDir() + "/articles") + _, err := resolveOGPArticlePath(conf, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "article is nil") + }) + + t.Run("nil output config returns error", func(t *testing.T) { + conf := config.Config{} + article := &core.Article{ + Date: "2024-01-15T10:30:00Z", + } + _, err := resolveOGPArticlePath(conf, article) + assert.Error(t, err) + }) } diff --git a/pkg/core/article_renderer.go b/pkg/core/article_renderer.go index 564afcb..fca5866 100644 --- a/pkg/core/article_renderer.go +++ b/pkg/core/article_renderer.go @@ -43,6 +43,13 @@ func (HugoArticleRenderer) Render(article *Article) (string, error) { return fmt.Sprintf("---\n%s---\n\n%s\n", frontMatter, rendered.Content), nil } +// ApplyFrontMatterOverrides applies frontmatter metadata overrides to an article. +// This is used when saving articles (to merge issue-body frontmatter with GitHub metadata) +// and when generating OGP images (so the image reflects the final rendered values). +func ApplyFrontMatterOverrides(article *Article, extra map[string]any) { + applyFrontMatterOverrides(article, extra) +} + func applyFrontMatterOverrides(article *Article, extra map[string]any) { if author, ok := stringValue(extra["author"]); ok { article.Author = author diff --git a/pkg/core/generator.go b/pkg/core/generator.go index 8a35a09..cdd77ec 100644 --- a/pkg/core/generator.go +++ b/pkg/core/generator.go @@ -26,11 +26,20 @@ type ArticleStore interface { // ArticleGenerator generates Hugo articles from GitHub issues. type ArticleGenerator struct { - issueRepo IssueStore - articleRepo ArticleStore - service *ArticleService - config config.Config - logger *slog.Logger + issueRepo IssueStore + articleRepo ArticleStore + service *ArticleService + config config.Config + logger *slog.Logger + onArticleSaved func(article *Article) error +} + +// SetOnArticleSaved sets an optional callback that is invoked after each +// article is successfully saved. The callback can be used to perform +// post-processing such as OGP image generation. Return an error to +// log a warning but continue processing remaining articles. +func (g *ArticleGenerator) SetOnArticleSaved(fn func(article *Article) error) { + g.onArticleSaved = fn } // NewArticleGenerator creates a new ArticleGenerator. @@ -112,6 +121,14 @@ func (g *ArticleGenerator) Generate(ctx context.Context, username, repository st saveErr = errors.Join(saveErr, fmt.Errorf("issue #%d: %w", issue.GetNumber(), err)) continue } + if g.onArticleSaved != nil { + if err := g.onArticleSaved(article); err != nil { + // Log at Error level: the CLI's default verbosity filters + // out Warn, which would make hook failures (e.g. OGP image + // generation) completely invisible in a normal run. + g.logger.Error("Post-save hook failed for article", "issue", issue.GetNumber(), "error", err) + } + } successCount++ }