diff --git a/helpers/content.go b/helpers/content.go index a5ad647ba..ccbdb09c5 100644 --- a/helpers/content.go +++ b/helpers/content.go @@ -43,27 +43,29 @@ var SummaryDivider = []byte("") // Blackfriday holds configuration values for Blackfriday rendering. type Blackfriday struct { - Smartypants bool - AngledQuotes bool - Fractions bool - HrefTargetBlank bool - SmartDashes bool - LatexDashes bool - PlainIDAnchors bool - Extensions []string - ExtensionsMask []string + Smartypants bool + AngledQuotes bool + Fractions bool + HrefTargetBlank bool + SmartDashes bool + LatexDashes bool + PlainIDAnchors bool + SourceRelativeLinksEval bool + Extensions []string + ExtensionsMask []string } // NewBlackfriday creates a new Blackfriday filled with site config or some sane defaults func NewBlackfriday() *Blackfriday { combinedParam := map[string]interface{}{ - "smartypants": true, - "angledQuotes": false, - "fractions": true, - "hrefTargetBlank": false, - "smartDashes": true, - "latexDashes": true, - "plainIDAnchors": false, + "smartypants": true, + "angledQuotes": false, + "fractions": true, + "hrefTargetBlank": false, + "smartDashes": true, + "latexDashes": true, + "plainIDAnchors": false, + "sourceRelativeLinks": false, } siteParam := viper.GetStringMap("blackfriday") @@ -198,7 +200,9 @@ func GetHTMLRenderer(defaultFlags int, ctx *RenderingContext) blackfriday.Render } return &HugoHtmlRenderer{ - blackfriday.HtmlRendererWithParameters(htmlFlags, "", "", renderParameters), + FileResolver: ctx.FileResolver, + LinkResolver: ctx.LinkResolver, + Renderer: blackfriday.HtmlRendererWithParameters(htmlFlags, "", "", renderParameters), } } @@ -329,11 +333,13 @@ func ExtractTOC(content []byte) (newcontent []byte, toc []byte) { // RenderingContext holds contextual information, like content and configuration, // for a given content renderin.g type RenderingContext struct { - Content []byte - PageFmt string - DocumentID string - Config *Blackfriday - configInit sync.Once + Content []byte + PageFmt string + DocumentID string + Config *Blackfriday + FileResolver FileResolverFunc + LinkResolver LinkResolverFunc + configInit sync.Once } func (c *RenderingContext) getConfig() *Blackfriday { diff --git a/helpers/content_renderer.go b/helpers/content_renderer.go index 00997900d..81386e03b 100644 --- a/helpers/content_renderer.go +++ b/helpers/content_renderer.go @@ -19,12 +19,18 @@ import ( "github.com/miekg/mmark" "github.com/russross/blackfriday" + jww "github.com/spf13/jwalterweatherman" "github.com/spf13/viper" ) +type LinkResolverFunc func(ref string) (string, error) +type FileResolverFunc func(ref string) (string, error) + // Wraps a blackfriday.Renderer, typically a blackfriday.Html // Enabling Hugo to customise the rendering experience type HugoHtmlRenderer struct { + FileResolver FileResolverFunc + LinkResolver LinkResolverFunc blackfriday.Renderer } @@ -38,6 +44,33 @@ func (renderer *HugoHtmlRenderer) BlockCode(out *bytes.Buffer, text []byte, lang } } +func (renderer *HugoHtmlRenderer) Link(out *bytes.Buffer, link []byte, title []byte, content []byte) { + if renderer.LinkResolver == nil || bytes.HasPrefix(link, []byte("{@{@HUGOSHORTCODE")) { + // Use the blackfriday built in Link handler + renderer.Renderer.Link(out, link, title, content) + } else { + newLink, err := renderer.LinkResolver(string(link)) + if err != nil { + newLink = string(link) + jww.ERROR.Printf("LinkResolver: %s", err) + } + renderer.Renderer.Link(out, []byte(newLink), title, content) + } +} +func (renderer *HugoHtmlRenderer) Image(out *bytes.Buffer, link []byte, title []byte, alt []byte) { + if renderer.FileResolver == nil || bytes.HasPrefix(link, []byte("{@{@HUGOSHORTCODE")) { + // Use the blackfriday built in Image handler + renderer.Renderer.Image(out, link, title, alt) + } else { + newLink, err := renderer.FileResolver(string(link)) + if err != nil { + newLink = string(link) + jww.ERROR.Printf("FileResolver: %s", err) + } + renderer.Renderer.Image(out, []byte(newLink), title, alt) + } +} + // Wraps a mmark.Renderer, typically a mmark.html // Enabling Hugo to customise the rendering experience type HugoMmarkHtmlRenderer struct { diff --git a/hugolib/page.go b/hugolib/page.go index 0a2a4a5e9..a37d21663 100644 --- a/hugolib/page.go +++ b/hugolib/page.go @@ -234,14 +234,34 @@ func (p *Page) setSummary() { } func (p *Page) renderBytes(content []byte) []byte { + var fn helpers.LinkResolverFunc + var fileFn helpers.FileResolverFunc + if p.getRenderingConfig().SourceRelativeLinksEval { + fn = func(ref string) (string, error) { + return p.Node.Site.GitHub(ref, p) + } + fileFn = func(ref string) (string, error) { + return p.Node.Site.GitHubFileLink(ref, p) + } + } return helpers.RenderBytes( &helpers.RenderingContext{Content: content, PageFmt: p.guessMarkupType(), - DocumentID: p.UniqueID(), Config: p.getRenderingConfig()}) + DocumentID: p.UniqueID(), Config: p.getRenderingConfig(), LinkResolver: fn, FileResolver: fileFn}) } func (p *Page) renderContent(content []byte) []byte { + var fn helpers.LinkResolverFunc + var fileFn helpers.FileResolverFunc + if p.getRenderingConfig().SourceRelativeLinksEval { + fn = func(ref string) (string, error) { + return p.Node.Site.GitHub(ref, p) + } + fileFn = func(ref string) (string, error) { + return p.Node.Site.GitHubFileLink(ref, p) + } + } return helpers.RenderBytesWithTOC(&helpers.RenderingContext{Content: content, PageFmt: p.guessMarkupType(), - DocumentID: p.UniqueID(), Config: p.getRenderingConfig()}) + DocumentID: p.UniqueID(), Config: p.getRenderingConfig(), LinkResolver: fn, FileResolver: fileFn}) } func (p *Page) getRenderingConfig() *helpers.Blackfriday { diff --git a/hugolib/site.go b/hugolib/site.go index 6a6760dba..ec0eb886f 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -100,7 +100,7 @@ type SiteInfo struct { Social SiteSocial Sections Taxonomy Pages *Pages - Files []*source.File + Files *[]*source.File Menus *Menus Hugo *HugoInfo Title string @@ -217,6 +217,166 @@ func (s *SiteInfo) RelRef(ref string, page *Page) (string, error) { return s.refLink(ref, page, true) } +func (s *SiteInfo) GitHub(ref string, page *Page) (string, error) { + return s.githubLink(ref, page, true) +} + +func (s *SiteInfo) githubLink(ref string, currentPage *Page, relative bool) (string, error) { + var refURL *url.URL + var err error + + // TODO can I make this a param to `hugo --use-github-links=/docs`? + // SVEN: add more tests - the prefix might be a real dir inside tho - add some pages that have it as a legitimate path + repositoryPathPrefix := "/docs" + + refURL, err = url.Parse(strings.TrimPrefix(ref, repositoryPathPrefix)) + if err != nil { + return "", err + } + + if refURL.Scheme != "" { + // TODO: consider looking for http(s?)://github.com/user/project/prefix and replacing it - tho this may be intentional, so idk + //return "", fmt.Errorf("Not a plain filepath link (%s)", ref) + // Treat this as not an error, as the link is used as-is + return ref, nil + } + + var target *Page + var link string + + if refURL.Path != "" { + refPath := filepath.Clean(filepath.FromSlash(refURL.Path)) + + if strings.IndexRune(refPath, os.PathSeparator) == 0 { // filepath.IsAbs fails to me. + refPath = refPath[1:] + } else { + if currentPage != nil { + refPath = filepath.Join(currentPage.Source.Dir(), refURL.Path) + } + } + + for _, page := range []*Page(*s.Pages) { + if page.Source.Path() == refPath { + target = page + break + } + } + // need to exhaust the test, then try with the others :/ + // if the refPath doesn't end in a filename with extension `.md`, then try with `.md` , and then `/index.md` + mdPath := strings.TrimSuffix(refPath, string(os.PathSeparator)) + ".md" + for _, page := range []*Page(*s.Pages) { + if page.Source.Path() == mdPath { + target = page + break + } + } + indexPath := filepath.Join(refPath, "index.md") + for _, page := range []*Page(*s.Pages) { + if page.Source.Path() == indexPath { + target = page + break + } + } + + if target == nil { + return "", fmt.Errorf("No page found for \"%s\" on page \"%s\".\n", ref, currentPage.Source.Path()) + } + + // SVEN: look at filepath.Rel() it might help, got the rel/non-rel url's (dangerous tho) + if relative { + link, err = target.RelPermalink() + } else { + link, err = target.Permalink() + } + + if err != nil { + return "", err + } + } + + // SVEN: add tests for github style relative fragments + if refURL.Fragment != "" { + link = link + "#" + refURL.Fragment + + if refURL.Path != "" && target != nil && !target.getRenderingConfig().PlainIDAnchors { + link = link + ":" + target.UniqueID() + } else if currentPage != nil && !currentPage.getRenderingConfig().PlainIDAnchors { + link = link + ":" + currentPage.UniqueID() + } + } + + return link, nil +} + +func (s *SiteInfo) GitHubFileLink(ref string, page *Page) (string, error) { + return s.githubFileLink(ref, page, false) +} + +// for non-pages in the site tree +func (s *SiteInfo) githubFileLink(ref string, currentPage *Page, relative bool) (string, error) { + var refURL *url.URL + var err error + + // TODO can I make this a param to `hugo --use-github-links=/docs`? + // SVEN: add more tests - the prefix might be a real dir inside tho - add some pages that have it as a legitimate path + repositoryPathPrefix := "/docs" + + refURL, err = url.Parse(strings.TrimPrefix(ref, repositoryPathPrefix)) + if err != nil { + return "", err + } + + if refURL.Scheme != "" { + // TODO: consider looking for http(s?)://github.com/user/project/prefix and replacing it - tho this may be intentional, so idk + //return "", fmt.Errorf("Not a plain filepath link (%s)", ref) + // Treat this as not an error, as the link is used as-is + return ref, nil + } + + var target *source.File + var link string + + if refURL.Path != "" { + refPath := filepath.Clean(filepath.FromSlash(refURL.Path)) + + if strings.IndexRune(refPath, os.PathSeparator) == 0 { // filepath.IsAbs fails to me. + refPath = refPath[1:] + } else { + if currentPage != nil { + refPath = filepath.Join(currentPage.Source.Dir(), refURL.Path) + } + } + + for _, file := range []*source.File(*s.Files) { + if file.Path() == refPath { + target = file + break + } + } + + if target == nil { + return "", fmt.Errorf("No file found for \"%s\" on page \"%s\".\n", ref, currentPage.Source.Path()) + } + + link = target.Path() + // SVEN: look at filepath.Rel() it might help, got the rel/non-rel url's (dangerous tho) + // SVEN: reconsider the fact I hardcoded the `relative` bool in both github resolvers + if relative { + return "./" + filepath.ToSlash(link), nil + } else { + return "/" + filepath.ToSlash(link), nil + } + + if err != nil { + return "", err + } + + return link, nil + } + + return "", fmt.Errorf("failed to find a file to match \"%s\" on page \"%s\"", ref, currentPage.Source.Path()) +} + func (s *SiteInfo) addToPaginationPageCount(cnt uint64) { atomic.AddUint64(&s.paginationPageCount, cnt) } @@ -479,6 +639,7 @@ func (s *Site) initializeSiteInfo() { canonifyURLs: viper.GetBool("CanonifyURLs"), preserveTaxonomyNames: viper.GetBool("PreserveTaxonomyNames"), Pages: &s.Pages, + Files: &s.Files, Menus: &s.Menus, Params: params, Permalinks: permalinks, @@ -1395,6 +1556,7 @@ func (s *Site) Stats() { jww.FEEDBACK.Println(s.draftStats()) jww.FEEDBACK.Println(s.futureStats()) jww.FEEDBACK.Printf("%d pages created\n", len(s.Pages)) + jww.FEEDBACK.Printf("%d non-page files copied\n", len(s.Files)) jww.FEEDBACK.Printf("%d paginator pages created\n", s.Info.paginationPageCount) taxonomies := viper.GetStringMapString("Taxonomies") diff --git a/hugolib/site_test.go b/hugolib/site_test.go index d99fadf52..15cdc02d8 100644 --- a/hugolib/site_test.go +++ b/hugolib/site_test.go @@ -14,7 +14,6 @@ package hugolib import ( - "bitbucket.org/pkg/inflect" "bytes" "fmt" "html/template" @@ -23,6 +22,8 @@ import ( "strings" "testing" + "bitbucket.org/pkg/inflect" + "github.com/spf13/afero" "github.com/spf13/hugo/helpers" "github.com/spf13/hugo/hugofs" @@ -1026,26 +1027,30 @@ func findPage(site *Site, f string) *Page { return nil } -func TestRefLinking(t *testing.T) { - viper.Reset() - defer viper.Reset() - +func setupLinkingMockSite(t *testing.T) *Site { hugofs.DestinationFS = new(afero.MemMapFs) sources := []source.ByteSource{ {filepath.FromSlash("index.md"), []byte("")}, {filepath.FromSlash("rootfile.md"), []byte("")}, + {filepath.FromSlash("root-image.png"), []byte("")}, {filepath.FromSlash("level2/2-root.md"), []byte("")}, {filepath.FromSlash("level2/index.md"), []byte("")}, {filepath.FromSlash("level2/common.md"), []byte("")}, - {filepath.FromSlash("level2b/2b-root.md"), []byte("")}, - {filepath.FromSlash("level2b/index.md"), []byte("")}, - {filepath.FromSlash("level2b/common.md"), []byte("")}, + +// {filepath.FromSlash("level2b/2b-root.md"), []byte("")}, +// {filepath.FromSlash("level2b/index.md"), []byte("")}, +// {filepath.FromSlash("level2b/common.md"), []byte("")}, + + {filepath.FromSlash("level2/2-image.png"), []byte("")}, + {filepath.FromSlash("level2/common.png"), []byte("")}, {filepath.FromSlash("level2/level3/3-root.md"), []byte("")}, {filepath.FromSlash("level2/level3/index.md"), []byte("")}, {filepath.FromSlash("level2/level3/common.md"), []byte("")}, + {filepath.FromSlash("level2/level3/3-image.png"), []byte("")}, + {filepath.FromSlash("level2/level3/common.png"), []byte("")}, } site := &Site{ @@ -1064,7 +1069,13 @@ func TestRefLinking(t *testing.T) { viper.Set("PluralizeListTitles", false) viper.Set("CanonifyURLs", false) - // END init mock site + return site +} + +func TestRefLinking(t *testing.T) { + viper.Reset() + defer viper.Reset() + site := setupLinkingMockSite(t) currentPage := findPage(site, "level2/level3/index.md") if currentPage == nil { @@ -1084,3 +1095,189 @@ func TestRefLinking(t *testing.T) { } // TODO: and then the failure cases. } + +func TestSourceRelativeLinksing(t *testing.T) { + viper.Reset() + defer viper.Reset() + site := setupLinkingMockSite(t) + + type resultMap map[string]string + + okresults := map[string]resultMap{ + "index.md": map[string]string{ + "/docs/rootfile.md": "/rootfile/", + "/docs/index.md": "/", + "rootfile.md": "/rootfile/", + "index.md": "/", + "level2/2-root.md": "/level2/2-root/", + "level2/index.md": "/level2/", + "/docs/level2/2-root.md": "/level2/2-root/", + "/docs/level2/index.md": "/level2/", + "level2/level3/3-root.md": "/level2/level3/3-root/", + "level2/level3/index.md": "/level2/level3/", + "/docs/level2/level3/3-root.md": "/level2/level3/3-root/", + "/docs/level2/level3/index.md": "/level2/level3/", + "/docs/level2/2-root/": "/level2/2-root/", + "/docs/level2/": "/level2/", + "/docs/level2/2-root": "/level2/2-root/", + "/docs/level2": "/level2/", + "/level2/2-root/": "/level2/2-root/", + "/level2/": "/level2/", + "/level2/2-root": "/level2/2-root/", + "/level2": "/level2/", + }, "rootfile.md": map[string]string{ + "/docs/rootfile.md": "/rootfile/", + "/docs/index.md": "/", + "rootfile.md": "/rootfile/", + "index.md": "/", + "level2/2-root.md": "/level2/2-root/", + "level2/index.md": "/level2/", + "/docs/level2/2-root.md": "/level2/2-root/", + "/docs/level2/index.md": "/level2/", + "level2/level3/3-root.md": "/level2/level3/3-root/", + "level2/level3/index.md": "/level2/level3/", + "/docs/level2/level3/3-root.md": "/level2/level3/3-root/", + "/docs/level2/level3/index.md": "/level2/level3/", + }, "level2/2-root.md": map[string]string{ + "../rootfile.md": "/rootfile/", + "../index.md": "/", + "/docs/rootfile.md": "/rootfile/", + "/docs/index.md": "/", + "2-root.md": "/level2/2-root/", + "index.md": "/level2/", + "../level2/2-root.md": "/level2/2-root/", + "../level2/index.md": "/level2/", + "./2-root.md": "/level2/2-root/", + "./index.md": "/level2/", + "/docs/level2/index.md": "/level2/", + "/docs/level2/2-root.md": "/level2/2-root/", + "level3/3-root.md": "/level2/level3/3-root/", + "level3/index.md": "/level2/level3/", + "../level2/level3/index.md": "/level2/level3/", + "../level2/level3/3-root.md": "/level2/level3/3-root/", + "/docs/level2/level3/index.md": "/level2/level3/", + "/docs/level2/level3/3-root.md": "/level2/level3/3-root/", + }, "level2/index.md": map[string]string{ + "../rootfile.md": "/rootfile/", + "../index.md": "/", + "/docs/rootfile.md": "/rootfile/", + "/docs/index.md": "/", + "2-root.md": "/level2/2-root/", + "index.md": "/level2/", + "../level2/2-root.md": "/level2/2-root/", + "../level2/index.md": "/level2/", + "./2-root.md": "/level2/2-root/", + "./index.md": "/level2/", + "/docs/level2/index.md": "/level2/", + "/docs/level2/2-root.md": "/level2/2-root/", + "level3/3-root.md": "/level2/level3/3-root/", + "level3/index.md": "/level2/level3/", + "../level2/level3/index.md": "/level2/level3/", + "../level2/level3/3-root.md": "/level2/level3/3-root/", + "/docs/level2/level3/index.md": "/level2/level3/", + "/docs/level2/level3/3-root.md": "/level2/level3/3-root/", + }, "level2/level3/3-root.md": map[string]string{ + "../../rootfile.md": "/rootfile/", + "../../index.md": "/", + "/docs/rootfile.md": "/rootfile/", + "/docs/index.md": "/", + "../2-root.md": "/level2/2-root/", + "../index.md": "/level2/", + "/docs/level2/2-root.md": "/level2/2-root/", + "/docs/level2/index.md": "/level2/", + "3-root.md": "/level2/level3/3-root/", + "index.md": "/level2/level3/", + "./3-root.md": "/level2/level3/3-root/", + "./index.md": "/level2/level3/", + // "../level2/level3/3-root.md": "/level2/level3/3-root/", + // "../level2/level3/index.md": "/level2/level3/", + "/docs/level2/level3/3-root.md": "/level2/level3/3-root/", + "/docs/level2/level3/index.md": "/level2/level3/", + }, "level2/level3/index.md": map[string]string{ + "../../rootfile.md": "/rootfile/", + "../../index.md": "/", + "/docs/rootfile.md": "/rootfile/", + "/docs/index.md": "/", + "../2-root.md": "/level2/2-root/", + "../index.md": "/level2/", + "/docs/level2/2-root.md": "/level2/2-root/", + "/docs/level2/index.md": "/level2/", + "3-root.md": "/level2/level3/3-root/", + "index.md": "/level2/level3/", + "./3-root.md": "/level2/level3/3-root/", + "./index.md": "/level2/level3/", + // "../level2/level3/3-root.md": "/level2/level3/3-root/", + // "../level2/level3/index.md": "/level2/level3/", + "/docs/level2/level3/3-root.md": "/level2/level3/3-root/", + "/docs/level2/level3/index.md": "/level2/level3/", + }, + } + + for currentFile, results := range okresults { + currentPage := findPage(site, currentFile) + if currentPage == nil { + t.Fatalf("failed to find current page in site") + } + for link, url := range results { + if out, err := site.Info.githubLink(link, currentPage, true); err != nil || out != url { + t.Errorf("Expected %s to resolve to (%s), got (%s) - error: %s", link, url, out, err) + } else { + //t.Logf("tested ok %s maps to %s", link, out) + } + } + } + // TODO: and then the failure cases. + // "https://docker.com": "", + // site_test.go:1094: Expected https://docker.com to resolve to (), got () - error: Not a plain filepath link (https://docker.com) + +} + +func TestGitHubFileLinking(t *testing.T) { + viper.Reset() + defer viper.Reset() + site := setupLinkingMockSite(t) + + type resultMap map[string]string + + okresults := map[string]resultMap{ + "index.md": map[string]string{ + "/root-image.png": "/root-image.png", + "root-image.png": "/root-image.png", + }, "rootfile.md": map[string]string{ + "/root-image.png": "/root-image.png", + }, "level2/2-root.md": map[string]string{ + "/root-image.png": "/root-image.png", + "common.png": "/level2/common.png", + }, "level2/index.md": map[string]string{ + "/root-image.png": "/root-image.png", + "common.png": "/level2/common.png", + "./common.png": "/level2/common.png", + }, "level2/level3/3-root.md": map[string]string{ + "/root-image.png": "/root-image.png", + "common.png": "/level2/level3/common.png", + "../common.png": "/level2/common.png", + }, "level2/level3/index.md": map[string]string{ + "/root-image.png": "/root-image.png", + "common.png": "/level2/level3/common.png", + "../common.png": "/level2/common.png", + }, + } + + for currentFile, results := range okresults { + currentPage := findPage(site, currentFile) + if currentPage == nil { + t.Fatalf("failed to find current page in site") + } + for link, url := range results { + if out, err := site.Info.githubFileLink(link, currentPage, false); err != nil || out != url { + t.Errorf("Expected %s to resolve to (%s), got (%s) - error: %s", link, url, out, err) + } else { + //t.Logf("tested ok %s maps to %s", link, out) + } + } + } + // TODO: and then the failure cases. + // "https://docker.com": "", + // site_test.go:1094: Expected https://docker.com to resolve to (), got () - error: Not a plain filepath link (https://docker.com) + +}