Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d33e65e
feat: add ogimage subcommand for OGP image generation from markdown
rokuosanai Jul 26, 2026
fa5f260
feat: integrate OGP image generation into generate command
rokuosanai Jul 26, 2026
d2a1999
fix: go mod tidy
rokuosanai Jul 26, 2026
e265c87
fix: go mod tidy
rokuosanai Jul 26, 2026
1b1a086
Merge branch 'feat/ogp-image-rendering-pkg' into feat/ogimage-subcommand
rokuosanai Jul 27, 2026
906447b
Merge branch 'feat/ogimage-subcommand' into feat/ogimage-generate-int…
rokuosanai Jul 27, 2026
0b2e083
Merge branch 'feat/ogp-image-rendering-pkg' into feat/ogimage-subcommand
rokuosanai Jul 27, 2026
fc32fed
Merge branch 'feat/ogimage-subcommand' into feat/ogimage-generate-int…
rokuosanai Jul 27, 2026
5f4b4df
fix: terminate chromium process when browser connection fails (#158 r…
rokuosanai Jul 28, 2026
594b617
fix: add nil guards to articleToOGPData and resolveOGPOutputPath (#159)
rokuosanai Jul 28, 2026
860ec55
Merge branch 'feat/ogimage-subcommand' into feat/ogimage-generate-int…
rokuosanai Jul 28, 2026
c34f3e2
fix: OGP overwrite bug — use article Key for unique path (#160 advers…
rokuosanai Jul 28, 2026
f46e7b7
Merge branch 'feat/ogp-image-rendering-pkg' into feat/ogimage-subcommand
rokuosanai Jul 28, 2026
0c517df
Merge branch 'feat/ogp-image-rendering-pkg' into feat/ogimage-generat…
rokuosanai Jul 28, 2026
0616c8a
Merge branch 'feat/ogp-image-rendering-pkg' into feat/ogimage-subcommand
rokuosanai Jul 28, 2026
629af1b
fix: handle CRLF and EOF edge cases in article parser (adversarial au…
rokuosanai Jul 28, 2026
0ab16f0
Merge branch 'feat/ogimage-subcommand' into feat/ogimage-generate-int…
rokuosanai Jul 28, 2026
4dfa376
fix: track OGP success/failure count in generate output (adversarial …
rokuosanai Jul 28, 2026
65b5135
Merge branch 'feat/ogp-image-rendering-pkg' into feat/ogimage-generat…
rokuosanai Jul 29, 2026
b89184e
Merge branch 'feat/ogimage-subcommand' into feat/ogimage-generate-int…
rokuosanai Jul 29, 2026
4fe4466
fix: OGP path alignment with article save, apply frontmatter override…
rokuosanai Jul 29, 2026
a1e3d44
fix: OGP placement, error visibility, overridden date (#160 review)
rokuosanai Jul 29, 2026
20a8371
Merge branch 'main' into feat/ogimage-generate-integration
rokuosan Jul 29, 2026
24b0140
fix: keep OGP adjacent for non-markdown extensions (adversarial self-…
rokuosanai Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions cmd/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -31,13 +31,16 @@ func TestNewRootCommand(t *testing.T) {
hasMigrate = true
case "version":
hasVersion = true
case "ogimage":
hasOGImage = true
}
}

assert.True(t, hasGenerate, "Should have 'generate' subcommand")
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) {
Expand Down
136 changes: 130 additions & 6 deletions cmd/cli/subcommand/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 {
Expand All @@ -63,13 +75,125 @@ 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)
if err != nil {
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
}
119 changes: 101 additions & 18 deletions cmd/cli/subcommand/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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")
}

Expand All @@ -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/<key>/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)
})
}
7 changes: 7 additions & 0 deletions pkg/core/article_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading