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
32 changes: 32 additions & 0 deletions cmd/hype/cli/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ type Export struct {
NoCSS bool // output raw HTML without styling
ListThemes bool // list available themes and exit

CheckLinks bool // enable link reachability checking
LinkTimeout time.Duration // per-link check timeout
LinkExclude string // comma-separated URL patterns to exclude
LinkRate float64 // requests per second per host

flags *flag.FlagSet

mu sync.RWMutex
Expand Down Expand Up @@ -108,6 +113,9 @@ Examples:
hype export -themes
hype export -f README.md -format markdown -timeout=10s
hype export -f input.md -format markdown -o README.md
hype export -f hype.md -check-links
hype export -f hype.md -check-links -link-timeout=20s -link-rate=1
hype export -f hype.md -check-links -link-exclude="https://localhost:*,https://internal.example.com/*"
`

if err := cmd.validate(); err != nil {
Expand All @@ -132,6 +140,10 @@ Examples:
cmd.flags.StringVar(&cmd.CustomCSS, "css", "", "path to custom CSS file for HTML export")
cmd.flags.BoolVar(&cmd.NoCSS, "no-css", false, "output raw HTML without styling")
cmd.flags.BoolVar(&cmd.ListThemes, "themes", false, "list available themes and exit")
cmd.flags.BoolVar(&cmd.CheckLinks, "check-links", false, "enable URL reachability checking for links")
cmd.flags.DurationVar(&cmd.LinkTimeout, "link-timeout", 10*time.Second, "per-link check timeout")
cmd.flags.StringVar(&cmd.LinkExclude, "link-exclude", "", "comma-separated URL patterns to exclude from link checking")
cmd.flags.Float64Var(&cmd.LinkRate, "link-rate", 2, "max requests per second per host for link checking")

cmd.flags.Usage = func() {
fmt.Fprintf(stderr, "Usage of %s:\n", os.Args[0])
Expand Down Expand Up @@ -272,6 +284,26 @@ func (cmd *Export) execute(ctx context.Context, pwd string) error {
p.FS = parserFS
}

if cmd.CheckLinks {
cfg := hype.DefaultLinkCheckConfig()
cfg.Enabled = true
cfg.Timeout = cmd.LinkTimeout
cfg.RatePerHost = cmd.LinkRate
if cmd.LinkExclude != "" {
for _, pattern := range strings.Split(cmd.LinkExclude, ",") {
pattern = strings.TrimSpace(pattern)
if pattern != "" {
cfg.ExcludePatterns = append(cfg.ExcludePatterns, pattern)
}
}
}
p.LinkCheck = cfg
p.LinkValidator = hype.NewLinkValidator(cfg)
} else {
p.LinkCheck = hype.LinkCheckConfig{}
p.LinkValidator = nil
}

p.Root = filepath.Join(filepath.Dir(mp), fileDir)

doc, err := p.ParseFile(fileName)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ require (
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.51.0
golang.org/x/sync v0.19.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -168,7 +169,6 @@ require (
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/api v0.266.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
Expand Down
27 changes: 27 additions & 0 deletions link.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package hype

import (
"context"
"encoding/json"
"fmt"
"strings"
Expand Down Expand Up @@ -56,6 +57,32 @@ func (l *Link) MD() string {
return fmt.Sprintf("[%s](%s)", l.Children().MD(), h)
}

func (l *Link) Execute(ctx context.Context, doc *Document) error {
if l == nil {
return ErrIsNil("link")
}

if doc == nil || doc.Parser == nil {
return nil
}

if !doc.Parser.LinkCheck.Enabled {
return nil
}

href, err := l.Href()
if err != nil {
return nil
}

v := doc.Parser.LinkValidator
if v == nil {
return nil
}

return v.Check(ctx, href)
}

func NewLink(el *Element) (*Link, error) {
if el == nil {
return nil, ErrIsNil("element")
Expand Down
27 changes: 27 additions & 0 deletions link_check_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package hype

import "time"

type LinkCheckConfig struct {
Enabled bool
Timeout time.Duration
AcceptedCodes []int
ExcludePatterns []string
RatePerHost float64
RateBurst int
MaxRedirects int
}

func DefaultLinkCheckConfig() LinkCheckConfig {
return LinkCheckConfig{
Enabled: false,
Timeout: 10 * time.Second,
AcceptedCodes: []int{
200, 201, 202, 203, 204,
301, 302, 307, 308,
},
RatePerHost: 2,
RateBurst: 1,
MaxRedirects: 10,
}
}
25 changes: 25 additions & 0 deletions link_check_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package hype

import (
"testing"
"time"

"github.com/stretchr/testify/require"
)

func Test_DefaultLinkCheckConfig(t *testing.T) {
t.Parallel()
r := require.New(t)

cfg := DefaultLinkCheckConfig()

r.False(cfg.Enabled)
r.Equal(10*time.Second, cfg.Timeout)
r.Contains(cfg.AcceptedCodes, 200)
r.Contains(cfg.AcceptedCodes, 301)
r.Contains(cfg.AcceptedCodes, 302)
r.Equal(float64(2), cfg.RatePerHost)
r.Equal(1, cfg.RateBurst)
r.Equal(10, cfg.MaxRedirects)
r.Empty(cfg.ExcludePatterns)
}
38 changes: 38 additions & 0 deletions link_check_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package hype

import (
"encoding/json"
"fmt"
)

type LinkCheckError struct {
URL string
StatusCode int
Err error
}

func (e LinkCheckError) Error() string {
if e.StatusCode > 0 {
return fmt.Sprintf("link check failed for %q: status %d", e.URL, e.StatusCode)
}
if e.Err != nil {
return fmt.Sprintf("link check failed for %q: %s", e.URL, e.Err)
}
return fmt.Sprintf("link check failed for %q", e.URL)
}

func (e LinkCheckError) Unwrap() error {
return e.Err
}

func (e LinkCheckError) MarshalJSON() ([]byte, error) {
m := map[string]any{
"type": "link_check_error",
"url": e.URL,
"status_code": e.StatusCode,
}
if e.Err != nil {
m["error"] = e.Err.Error()
}
return json.MarshalIndent(m, "", " ")
}
121 changes: 121 additions & 0 deletions link_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package hype

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"

"github.com/stretchr/testify/require"
)
Expand All @@ -21,3 +25,120 @@ func Test_Link_MarshalJSON(t *testing.T) {

testJSON(t, "link", link)
}

func Test_Link_Execute_Disabled(t *testing.T) {
t.Parallel()
r := require.New(t)

link := &Link{Element: NewEl("a", nil)}
r.NoError(link.Set("href", "https://example.com"))

doc := &Document{
FS: fstest.MapFS{},
Parser: &Parser{},
}

err := link.Execute(context.Background(), doc)
r.NoError(err)
}

func Test_Link_Execute_HTTP_OK(t *testing.T) {
t.Parallel()
r := require.New(t)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

cfg := DefaultLinkCheckConfig()
cfg.Enabled = true

link := &Link{Element: NewEl("a", nil)}
r.NoError(link.Set("href", srv.URL+"/page"))

doc := &Document{
FS: fstest.MapFS{},
Parser: &Parser{
LinkCheck: cfg,
LinkValidator: NewLinkValidator(cfg),
},
}

err := link.Execute(context.Background(), doc)
r.NoError(err)
}

func Test_Link_Execute_HTTP_NotFound(t *testing.T) {
t.Parallel()
r := require.New(t)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()

cfg := DefaultLinkCheckConfig()
cfg.Enabled = true

link := &Link{Element: NewEl("a", nil)}
r.NoError(link.Set("href", srv.URL+"/missing"))

doc := &Document{
FS: fstest.MapFS{},
Parser: &Parser{
LinkCheck: cfg,
LinkValidator: NewLinkValidator(cfg),
},
}

err := link.Execute(context.Background(), doc)
r.Error(err)

var lce LinkCheckError
r.ErrorAs(err, &lce)
r.Equal(404, lce.StatusCode)
}

func Test_Link_Execute_NonHTTP(t *testing.T) {
t.Parallel()
r := require.New(t)

cfg := DefaultLinkCheckConfig()
cfg.Enabled = true

link := &Link{Element: NewEl("a", nil)}
r.NoError(link.Set("href", "mailto:test@example.com"))

doc := &Document{
FS: fstest.MapFS{},
Parser: &Parser{
LinkCheck: cfg,
LinkValidator: NewLinkValidator(cfg),
},
}

err := link.Execute(context.Background(), doc)
r.NoError(err)
}

func Test_Link_Execute_NoHref(t *testing.T) {
t.Parallel()
r := require.New(t)

cfg := DefaultLinkCheckConfig()
cfg.Enabled = true

link := &Link{Element: NewEl("a", nil)}

doc := &Document{
FS: fstest.MapFS{},
Parser: &Parser{
LinkCheck: cfg,
LinkValidator: NewLinkValidator(cfg),
},
}

err := link.Execute(context.Background(), doc)
r.NoError(err)
}
Loading
Loading