From c83efac195b1a7947008ab2bab5dc2e89d16665a Mon Sep 17 00:00:00 2001 From: Koki Matsumoto Date: Tue, 12 May 2026 14:40:51 +0900 Subject: [PATCH 1/5] feat(config): allow configurable image target URL patterns --- docs/content/configuration/parameters.ja.md | 5 ++ docs/content/configuration/parameters.md | 5 ++ pkg/config/type.go | 20 +++++-- pkg/config/type_test.go | 25 +++++++++ pkg/core/issue_articles.go | 58 +++++++++++++++------ pkg/core/issue_articles_test.go | 34 +++++++++++- 6 files changed, 125 insertions(+), 22 deletions(-) diff --git a/docs/content/configuration/parameters.ja.md b/docs/content/configuration/parameters.ja.md index 31b0b1e..515e680 100644 --- a/docs/content/configuration/parameters.ja.md +++ b/docs/content/configuration/parameters.ja.md @@ -51,6 +51,11 @@ GitHub の設定です。 - `directory`: 画像の保存先ディレクトリ - `filename`: 画像のファイル名 - `url`: Markdownから参照される画像のURL +- `targets`: Issue本文内で検出して置換するURLプレフィックス + +`targets` を省略した場合は、組み込みの GitHub 添付画像URL ルールが使われます。 +`targets: []` を指定した場合は、画像URLの検出も置換も行いません。 +`https://*.githubusercontent.com` のようなワイルドカード付きホスト指定も使えます。 ``[:id]`` は画像の ID に置き換わります。画像の ID はそのIssue内部で一意で、連番で割り振られます。 diff --git a/docs/content/configuration/parameters.md b/docs/content/configuration/parameters.md index f6e1600..220d28a 100644 --- a/docs/content/configuration/parameters.md +++ b/docs/content/configuration/parameters.md @@ -51,6 +51,11 @@ Output settings. - `directory`: Directory to save images - `filename`: Image filename - `url`: Image URL referenced from Markdown +- `targets`: URL prefixes to detect and replace in issue bodies + +If `targets` is omitted, the built-in GitHub attachment URL rules are used. +If `targets: []` is specified, no image URLs are detected or replaced. +Wildcard host patterns such as `https://*.githubusercontent.com` are also supported. `[:id]` will be replaced with the image ID. The image ID is unique within each issue and assigned sequentially. diff --git a/pkg/config/type.go b/pkg/config/type.go index 9e60d91..3aa023d 100644 --- a/pkg/config/type.go +++ b/pkg/config/type.go @@ -26,9 +26,16 @@ type OutputArticlesConfig struct { } type OutputImagesConfig struct { - Directory string `yaml:"directory" mapstructure:"directory"` - Filename string `yaml:"filename" mapstructure:"filename"` - BaseURL *string `yaml:"url" mapstructure:"url"` + Directory string `yaml:"directory" mapstructure:"directory"` + Filename string `yaml:"filename" mapstructure:"filename"` + BaseURL *string `yaml:"url" mapstructure:"url"` + Targets []string `yaml:"targets,omitempty" mapstructure:"targets"` +} + +var defaultImageTargets = []string{ + "https://github.com/user-attachments/", + "https://user-images.githubusercontent.com/", + "https://private-user-images.githubusercontent.com/", } type HugoConfig struct { @@ -115,6 +122,13 @@ func (c *OutputImagesConfig) URL() string { return *c.BaseURL } +func (c *OutputImagesConfig) TargetURLs() []string { + if c == nil || c.Targets == nil { + return defaultImageTargets + } + return c.Targets +} + func (c *Config) normalize() { if c.GitHub == nil { c.GitHub = NewGitHubConfig() diff --git a/pkg/config/type_test.go b/pkg/config/type_test.go index 2b73d52..cf47184 100644 --- a/pkg/config/type_test.go +++ b/pkg/config/type_test.go @@ -130,3 +130,28 @@ func TestConfigNormalize_BackfillsMissingOutputImageURLFromLegacyHugo(t *testing t.Fatalf("images url = %q", conf.Output.Images.URL()) } } + +func TestOutputImagesConfig_TargetURLs_DefaultsWhenUnset(t *testing.T) { + conf := &OutputImagesConfig{} + + got := conf.TargetURLs() + + if len(got) != 3 { + t.Fatalf("target count = %d", len(got)) + } + if got[0] != "https://github.com/user-attachments/" { + t.Fatalf("first target = %q", got[0]) + } +} + +func TestOutputImagesConfig_TargetURLs_PreservesExplicitEmptyList(t *testing.T) { + conf := &OutputImagesConfig{ + Targets: []string{}, + } + + got := conf.TargetURLs() + + if len(got) != 0 { + t.Fatalf("target count = %d", len(got)) + } +} diff --git a/pkg/core/issue_articles.go b/pkg/core/issue_articles.go index e8813b1..7bfdfba 100644 --- a/pkg/core/issue_articles.go +++ b/pkg/core/issue_articles.go @@ -3,6 +3,7 @@ package core import ( "fmt" "net/url" + "path" "regexp" "strings" @@ -14,11 +15,6 @@ import ( var ( regexURLCandidate = regexp.MustCompile(`https://[^\s<>"')\]]+`) - gitHubAssetHosts = map[string]struct{}{ - "github.com": {}, - "user-images.githubusercontent.com": {}, - "private-user-images.githubusercontent.com": {}, - } ) // ArticleService converts issues into articles. @@ -78,7 +74,7 @@ func (s *ArticleService) ConvertIssueToArticle(issue *github.Issue) *Article { content = strings.TrimLeft(content, "\n") time := issue.GetCreatedAt().Format("2006-01-02_150405") - images := extractGitHubHostedImages(content, time) + images := extractTargetImages(content, time, s.config.Output.Images.TargetURLs()) var tags []string excludedLabels := map[string]struct{}{} @@ -110,13 +106,13 @@ func (s *ArticleService) ConvertIssueToArticle(issue *github.Issue) *Article { } } -func extractGitHubHostedImages(content string, time string) []*Image { +func extractTargetImages(content string, time string, targetURLs []string) []*Image { var images []*Image seen := map[string]struct{}{} matches := regexURLCandidate.FindAllString(content, -1) for _, match := range matches { candidate := strings.TrimRight(match, ".,:;!?`") - if !isGitHubHostedAssetURL(candidate) { + if !matchesTargetURL(candidate, targetURLs) { continue } if _, ok := seen[candidate]; ok { @@ -128,21 +124,49 @@ func extractGitHubHostedImages(content string, time string) []*Image { return images } -func isGitHubHostedAssetURL(raw string) bool { - parsed, err := url.Parse(raw) +func matchesTargetURL(raw string, targetURLs []string) bool { + parsedRaw, err := url.Parse(raw) + if err != nil { + return false + } + + for _, targetURL := range targetURLs { + if targetURL == "" { + continue + } + if matchTargetPattern(raw, parsedRaw, targetURL) { + return true + } + } + return false +} + +func matchTargetPattern(raw string, parsedRaw *url.URL, targetURL string) bool { + if !strings.Contains(targetURL, "*") { + return strings.HasPrefix(raw, targetURL) + } + + parsedTarget, err := url.Parse(targetURL) if err != nil { return false } - host := strings.ToLower(parsed.Host) - if _, ok := gitHubAssetHosts[host]; !ok { + if parsedTarget.Scheme != "" && !strings.EqualFold(parsedTarget.Scheme, parsedRaw.Scheme) { return false } - switch host { - case "github.com": - return strings.HasPrefix(parsed.Path, "/user-attachments/") - default: - return true + if parsedTarget.Host != "" { + matched, err := path.Match(strings.ToLower(parsedTarget.Host), strings.ToLower(parsedRaw.Host)) + if err != nil || !matched { + return false + } + } + if parsedTarget.Path != "" { + matched, err := path.Match(parsedTarget.Path, parsedRaw.Path) + if err != nil || !matched { + return false + } } + + return true } func newMetadataParser() metadataParser { diff --git a/pkg/core/issue_articles_test.go b/pkg/core/issue_articles_test.go index 27ec67e..3027d51 100644 --- a/pkg/core/issue_articles_test.go +++ b/pkg/core/issue_articles_test.go @@ -162,7 +162,7 @@ func TestArticleService_ConvertIssueToArticle(t *testing.T) { }, }, { - name: "既知のGitHubコンテンツURLだけを収集", + name: "設定済みプレフィックスに一致するURLだけを収集", issue: &github.Issue{ Title: Ptr("GitHub Asset Issue"), Body: Ptr( @@ -223,6 +223,8 @@ func TestArticleService_ConvertIssueToArticle(t *testing.T) { } func TestExtractGitHubHostedImages(t *testing.T) { + targetURLs := config.NewOutputImagesConfig().TargetURLs() + tests := []struct { name string content string @@ -257,7 +259,7 @@ func TestExtractGitHubHostedImages(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := extractGitHubHostedImages(tt.content, "2021-01-01_000000") + got := extractTargetImages(tt.content, "2021-01-01_000000", targetURLs) require.Len(t, got, len(tt.wantURLs)) for i, wantURL := range tt.wantURLs { @@ -268,6 +270,34 @@ func TestExtractGitHubHostedImages(t *testing.T) { } } +func TestExtractTargetImages_UsesConfiguredPrefixes(t *testing.T) { + got := extractTargetImages(strings.Join([]string{ + "https://cdn.example.com/assets/alpha.png", + "https://media.example.net/ignored.png", + "https://cdn.example.com/assets/beta.png", + }, "\n"), "2021-01-01_000000", []string{ + "https://cdn.example.com/assets/", + }) + + require.Len(t, got, 2) + assertEqualCmp(t, "https://cdn.example.com/assets/alpha.png", got[0].URL) + assertEqualCmp(t, "https://cdn.example.com/assets/beta.png", got[1].URL) +} + +func TestExtractTargetImages_UsesWildcardHostPatterns(t *testing.T) { + got := extractTargetImages(strings.Join([]string{ + "https://user-images.githubusercontent.com/123/a.png", + "https://private-user-images.githubusercontent.com/456/b.png?jwt=token", + "https://example.com/c.png", + }, "\n"), "2021-01-01_000000", []string{ + "https://*.githubusercontent.com", + }) + + require.Len(t, got, 2) + assertEqualCmp(t, "https://user-images.githubusercontent.com/123/a.png", got[0].URL) + assertEqualCmp(t, "https://private-user-images.githubusercontent.com/456/b.png?jwt=token", got[1].URL) +} + func TestArticleService_ConvertIssueToArticle_PullRequest(t *testing.T) { service := NewArticleService(*config.NewConfig()) From 6d0bc9c8aa4cff66e52222a78dee2419df239b28 Mon Sep 17 00:00:00 2001 From: Koki Matsumoto Date: Tue, 12 May 2026 14:44:00 +0900 Subject: [PATCH 2/5] chore(config): configure image target URL patterns --- gic.config.yaml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/gic.config.yaml b/gic.config.yaml index cae57f9..7c89118 100644 --- a/gic.config.yaml +++ b/gic.config.yaml @@ -1,15 +1,18 @@ github: - username: 'rokuosan' - repository: 'github-issue-cms' + username: "rokuosan" + repository: "github-issue-cms" output: articles: - directory: 'content/posts' - filename: '%Y-%m-%d_%H%M%S.md' + directory: "content/posts" + filename: "%Y-%m-%d_%H%M%S.md" images: - directory: 'static/images/%Y-%m-%d_%H%M%S' - filename: '[:id].png' - url: '/images/%Y-%m-%d_%H%M%S' + directory: "static/images/%Y-%m-%d_%H%M%S" + filename: "[:id].png" + url: "/images/%Y-%m-%d_%H%M%S" + targets: + - "https://github.com/user-attachments/" + - "https://*.githubusercontent.com" # For page bundle # output: From 51e27f54865fe4730835341f519cadcdded4083c Mon Sep 17 00:00:00 2001 From: Koki Matsumoto Date: Tue, 12 May 2026 15:12:55 +0900 Subject: [PATCH 3/5] fix(config): preserve prefix matching for wildcard targets --- pkg/core/issue_articles.go | 25 ++++++++++++++++++++----- pkg/core/issue_articles_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/pkg/core/issue_articles.go b/pkg/core/issue_articles.go index 7bfdfba..b9bbee0 100644 --- a/pkg/core/issue_articles.go +++ b/pkg/core/issue_articles.go @@ -159,16 +159,31 @@ func matchTargetPattern(raw string, parsedRaw *url.URL, targetURL string) bool { return false } } - if parsedTarget.Path != "" { - matched, err := path.Match(parsedTarget.Path, parsedRaw.Path) - if err != nil || !matched { - return false - } + if !matchesPathPrefixPattern(parsedRaw.Path, parsedTarget.Path) { + return false } return true } +func matchesPathPrefixPattern(rawPath string, targetPath string) bool { + if targetPath == "" { + return true + } + if !strings.Contains(targetPath, "*") { + return strings.HasPrefix(rawPath, targetPath) + } + + for i := 1; i <= len(rawPath); i++ { + matched, err := path.Match(targetPath, rawPath[:i]) + if err == nil && matched { + return true + } + } + + return false +} + func newMetadataParser() metadataParser { return metadataParser{ parsers: []metadataBlockParser{ diff --git a/pkg/core/issue_articles_test.go b/pkg/core/issue_articles_test.go index 3027d51..ae871d3 100644 --- a/pkg/core/issue_articles_test.go +++ b/pkg/core/issue_articles_test.go @@ -298,6 +298,33 @@ func TestExtractTargetImages_UsesWildcardHostPatterns(t *testing.T) { assertEqualCmp(t, "https://private-user-images.githubusercontent.com/456/b.png?jwt=token", got[1].URL) } +func TestExtractTargetImages_PreservesPrefixMatchingForWildcardTargetPaths(t *testing.T) { + t.Run("matches any path below wildcard host root", func(t *testing.T) { + got := extractTargetImages(strings.Join([]string{ + "https://private-user-images.githubusercontent.com/01234/4578.png", + "https://private-user-images.githubusercontent.com/assets/4578/image.png", + }, "\n"), "2021-01-01_000000", []string{ + "https://*.githubusercontent.com/", + }) + + require.Len(t, got, 2) + assertEqualCmp(t, "https://private-user-images.githubusercontent.com/01234/4578.png", got[0].URL) + assertEqualCmp(t, "https://private-user-images.githubusercontent.com/assets/4578/image.png", got[1].URL) + }) + + t.Run("matches only paths under the wildcard host prefix", func(t *testing.T) { + got := extractTargetImages(strings.Join([]string{ + "https://private-user-images.githubusercontent.com/assets/4578/image.png", + "https://private-user-images.githubusercontent.com/static/image.png", + }, "\n"), "2021-01-01_000000", []string{ + "https://*.githubusercontent.com/assets/", + }) + + require.Len(t, got, 1) + assertEqualCmp(t, "https://private-user-images.githubusercontent.com/assets/4578/image.png", got[0].URL) + }) +} + func TestArticleService_ConvertIssueToArticle_PullRequest(t *testing.T) { service := NewArticleService(*config.NewConfig()) From 5556b46e874346d533b78dcdc2da716b0e946245 Mon Sep 17 00:00:00 2001 From: Koki Matsumoto Date: Tue, 12 May 2026 15:25:38 +0900 Subject: [PATCH 4/5] fix: preserve empty image targets and normalize target host matching --- pkg/config/config_test.go | 70 +++++++++++++++++++++++++++++++++ pkg/config/type.go | 2 +- pkg/core/issue_articles.go | 29 +++++++++++++- pkg/core/issue_articles_test.go | 12 ++++++ 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 pkg/config/config_test.go diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..b754121 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,70 @@ +package config + +import ( + "os" + "strings" + "testing" + + "github.com/spf13/viper" +) + +func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) { + tempDir := t.TempDir() + + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(originalWd); err != nil { + t.Fatalf("restore wd: %v", err) + } + config = Config{} + viper.Reset() + }) + + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("chdir: %v", err) + } + config = Config{} + viper.Reset() + + conf := Config{ + GitHub: NewGitHubConfig(), + Output: &OutputConfig{ + Articles: NewOutputArticlesConfig(), + Images: &OutputImagesConfig{ + Directory: "static/images", + Filename: "[:id].png", + BaseURL: Ptr("/images"), + Targets: []string{}, + }, + }, + } + + if err := Write(conf); err != nil { + t.Fatalf("write: %v", err) + } + + data, err := os.ReadFile(GetConfigPath()) + if err != nil { + t.Fatalf("read config: %v", err) + } + if !strings.Contains(string(data), "targets: []") { + t.Fatalf("expected explicit empty targets in config, got:\n%s", string(data)) + } + + reloaded, err := Reload() + if err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.Output == nil || reloaded.Output.Images == nil { + t.Fatalf("missing output images after reload") + } + if reloaded.Output.Images.Targets == nil { + t.Fatalf("targets became nil after reload") + } + if len(reloaded.Output.Images.TargetURLs()) != 0 { + t.Fatalf("target urls = %#v", reloaded.Output.Images.TargetURLs()) + } +} diff --git a/pkg/config/type.go b/pkg/config/type.go index 3aa023d..1d72027 100644 --- a/pkg/config/type.go +++ b/pkg/config/type.go @@ -29,7 +29,7 @@ type OutputImagesConfig struct { Directory string `yaml:"directory" mapstructure:"directory"` Filename string `yaml:"filename" mapstructure:"filename"` BaseURL *string `yaml:"url" mapstructure:"url"` - Targets []string `yaml:"targets,omitempty" mapstructure:"targets"` + Targets []string `yaml:"targets" mapstructure:"targets"` } var defaultImageTargets = []string{ diff --git a/pkg/core/issue_articles.go b/pkg/core/issue_articles.go index b9bbee0..7e52c32 100644 --- a/pkg/core/issue_articles.go +++ b/pkg/core/issue_articles.go @@ -143,7 +143,7 @@ func matchesTargetURL(raw string, targetURLs []string) bool { func matchTargetPattern(raw string, parsedRaw *url.URL, targetURL string) bool { if !strings.Contains(targetURL, "*") { - return strings.HasPrefix(raw, targetURL) + return matchesURLPrefix(parsedRaw, targetURL) } parsedTarget, err := url.Parse(targetURL) @@ -166,6 +166,33 @@ func matchTargetPattern(raw string, parsedRaw *url.URL, targetURL string) bool { return true } +func matchesURLPrefix(parsedRaw *url.URL, targetURL string) bool { + parsedTarget, err := url.Parse(targetURL) + if err != nil { + return false + } + if parsedTarget.Scheme != "" && !strings.EqualFold(parsedTarget.Scheme, parsedRaw.Scheme) { + return false + } + if parsedTarget.Host != "" && !strings.EqualFold(parsedTarget.Host, parsedRaw.Host) { + return false + } + if parsedTarget.Path != "" && !strings.HasPrefix(parsedRaw.Path, parsedTarget.Path) { + return false + } + if parsedTarget.RawQuery != "" { + rawQueryPrefix := parsedRaw.RawQuery + if !strings.HasPrefix(rawQueryPrefix, parsedTarget.RawQuery) { + return false + } + } + if parsedTarget.Fragment != "" && !strings.HasPrefix(parsedRaw.Fragment, parsedTarget.Fragment) { + return false + } + + return true +} + func matchesPathPrefixPattern(rawPath string, targetPath string) bool { if targetPath == "" { return true diff --git a/pkg/core/issue_articles_test.go b/pkg/core/issue_articles_test.go index ae871d3..16677d7 100644 --- a/pkg/core/issue_articles_test.go +++ b/pkg/core/issue_articles_test.go @@ -284,6 +284,18 @@ func TestExtractTargetImages_UsesConfiguredPrefixes(t *testing.T) { assertEqualCmp(t, "https://cdn.example.com/assets/beta.png", got[1].URL) } +func TestExtractTargetImages_MatchesNonWildcardHostsCaseInsensitively(t *testing.T) { + got := extractTargetImages(strings.Join([]string{ + "https://GitHub.com/user-attachments/assets/alpha.png", + "https://github.com/other/ignored.png", + }, "\n"), "2021-01-01_000000", []string{ + "https://github.com/user-attachments/", + }) + + require.Len(t, got, 1) + assertEqualCmp(t, "https://GitHub.com/user-attachments/assets/alpha.png", got[0].URL) +} + func TestExtractTargetImages_UsesWildcardHostPatterns(t *testing.T) { got := extractTargetImages(strings.Join([]string{ "https://user-images.githubusercontent.com/123/a.png", From 194eccca7884bb769bd58c66f6fe96dcfff68600 Mon Sep 17 00:00:00 2001 From: Koki Matsumoto Date: Tue, 12 May 2026 15:29:57 +0900 Subject: [PATCH 5/5] fix: preserve empty image targets and normalize target URL matching --- pkg/core/issue_articles.go | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/pkg/core/issue_articles.go b/pkg/core/issue_articles.go index 7e52c32..684a3d0 100644 --- a/pkg/core/issue_articles.go +++ b/pkg/core/issue_articles.go @@ -134,55 +134,51 @@ func matchesTargetURL(raw string, targetURLs []string) bool { if targetURL == "" { continue } - if matchTargetPattern(raw, parsedRaw, targetURL) { + if matchTargetPattern(parsedRaw, targetURL) { return true } } return false } -func matchTargetPattern(raw string, parsedRaw *url.URL, targetURL string) bool { - if !strings.Contains(targetURL, "*") { - return matchesURLPrefix(parsedRaw, targetURL) - } - +func matchTargetPattern(parsedRaw *url.URL, targetURL string) bool { parsedTarget, err := url.Parse(targetURL) if err != nil { return false } - if parsedTarget.Scheme != "" && !strings.EqualFold(parsedTarget.Scheme, parsedRaw.Scheme) { + if !matchesURLComponents(parsedRaw, parsedTarget) { return false } + + if !strings.Contains(targetURL, "*") { + return true + } + if parsedTarget.Host != "" { matched, err := path.Match(strings.ToLower(parsedTarget.Host), strings.ToLower(parsedRaw.Host)) if err != nil || !matched { return false } } - if !matchesPathPrefixPattern(parsedRaw.Path, parsedTarget.Path) { + if parsedTarget.Path != "" && !matchesPathPrefixPattern(parsedRaw.Path, parsedTarget.Path) { return false } return true } -func matchesURLPrefix(parsedRaw *url.URL, targetURL string) bool { - parsedTarget, err := url.Parse(targetURL) - if err != nil { - return false - } +func matchesURLComponents(parsedRaw *url.URL, parsedTarget *url.URL) bool { if parsedTarget.Scheme != "" && !strings.EqualFold(parsedTarget.Scheme, parsedRaw.Scheme) { return false } - if parsedTarget.Host != "" && !strings.EqualFold(parsedTarget.Host, parsedRaw.Host) { + if parsedTarget.Host != "" && !strings.Contains(parsedTarget.Host, "*") && !strings.EqualFold(parsedTarget.Host, parsedRaw.Host) { return false } - if parsedTarget.Path != "" && !strings.HasPrefix(parsedRaw.Path, parsedTarget.Path) { + if parsedTarget.Path != "" && !strings.Contains(parsedTarget.Path, "*") && !strings.HasPrefix(parsedRaw.Path, parsedTarget.Path) { return false } if parsedTarget.RawQuery != "" { - rawQueryPrefix := parsedRaw.RawQuery - if !strings.HasPrefix(rawQueryPrefix, parsedTarget.RawQuery) { + if !strings.HasPrefix(parsedRaw.RawQuery, parsedTarget.RawQuery) { return false } }