Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions docs/content/configuration/parameters.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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内部で一意で、連番で割り振られます。

Expand Down
5 changes: 5 additions & 0 deletions docs/content/configuration/parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 10 additions & 7 deletions gic.config.yaml
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
rokuosan marked this conversation as resolved.

# For page bundle
# output:
Expand Down
70 changes: 70 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
20 changes: 17 additions & 3 deletions pkg/config/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" mapstructure:"targets"`
}

var defaultImageTargets = []string{
"https://github.com/user-attachments/",
"https://user-images.githubusercontent.com/",
"https://private-user-images.githubusercontent.com/",
}

type HugoConfig struct {
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions pkg/config/type_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
94 changes: 78 additions & 16 deletions pkg/core/issue_articles.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package core
import (
"fmt"
"net/url"
"path"
"regexp"
"strings"

Expand All @@ -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.
Expand Down Expand Up @@ -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{}{}
Expand Down Expand Up @@ -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 {
Expand All @@ -128,21 +124,87 @@ 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
}
host := strings.ToLower(parsed.Host)
if _, ok := gitHubAssetHosts[host]; !ok {

for _, targetURL := range targetURLs {
if targetURL == "" {
continue
}
if matchTargetPattern(parsedRaw, targetURL) {
return true
}
}
return false
}

func matchTargetPattern(parsedRaw *url.URL, targetURL string) bool {
parsedTarget, err := url.Parse(targetURL)
if err != nil {
return false
}
switch host {
case "github.com":
return strings.HasPrefix(parsed.Path, "/user-attachments/")
default:
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 parsedTarget.Path != "" && !matchesPathPrefixPattern(parsedRaw.Path, parsedTarget.Path) {
return false
}

return true
}

func matchesURLComponents(parsedRaw *url.URL, parsedTarget *url.URL) bool {
if parsedTarget.Scheme != "" && !strings.EqualFold(parsedTarget.Scheme, parsedRaw.Scheme) {
return false
}
if parsedTarget.Host != "" && !strings.Contains(parsedTarget.Host, "*") && !strings.EqualFold(parsedTarget.Host, parsedRaw.Host) {
return false
}
if parsedTarget.Path != "" && !strings.Contains(parsedTarget.Path, "*") && !strings.HasPrefix(parsedRaw.Path, parsedTarget.Path) {
return false
}
if parsedTarget.RawQuery != "" {
if !strings.HasPrefix(parsedRaw.RawQuery, 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
}
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 {
Expand Down
Loading
Loading