markup/asciidocext: Improve Asciidoctor integration

Fixes an issue where improper attribute derivation from the page's
relative permalink caused failures with `outdir`, `imagesoutdir`, and
`imagesdir` when `markup.asciidocext.workingFolderCurrent` is enabled.
The updated logic now correctly handles:

- Multi-byte characters
- Multilingual multi-host sites
- Site builds from a subdirectory
- Pages using ugly URLs

Supports diagram caching as implemented in v3.1.0 of the asciidoctor-diagram
extension:

- Enables caching by default
- Sets default cache location to the compiled value of caches.misc.dir

Reduces duration of integration tests by:

- Generating GoAT diagrams instead of Ditaa diagrams
- Taking advantage of asciidoctor-diagram caching

Closes #9202
Closes #10183
Closes #10473
Closes #14160
This commit is contained in:
Joe Mooring
2025-10-24 12:11:54 -07:00
committed by Bjørn Erik Pedersen
parent 34b0c15a54
commit 3d21b0687b
17 changed files with 1949 additions and 673 deletions

View File

@@ -46,19 +46,12 @@ jobs:
uses: ruby/setup-ruby@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0
with:
ruby-version: "3.4.5"
- name: Install gems
- name: Install Ruby gems
run: |
gem install asciidoctor -v "2.0.26"
gem install asciidoctor-diagram -v "3.1.0"
gem install asciidoctor-diagram-ditaamini -v "1.0.3"
- name: Install GoAT
run: go install github.com/blampe/goat/cmd/goat@177de93b192b8ffae608e5d9ec421cc99bf68402
- name: Install Java # required by asciidoctor-diagram-ditaamini
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: temurin
java-version: "25"
java-package: jre
- name: Install Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:

View File

@@ -51,11 +51,18 @@ const (
type Configs map[string]FileCacheConfig
// CacheDirModules returns the compiled path to the modules cache.
// For internal use.
func (c Configs) CacheDirModules() string {
return c[CacheKeyModules].DirCompiled
}
// CacheDirMisc returns the compiled path to the misc cache.
// For internal use.
func (c Configs) CacheDirMisc() string {
return c[CacheKeyMisc].DirCompiled
}
var defaultCacheConfigs = Configs{
CacheKeyModules: {
MaxAge: -1,

View File

@@ -135,6 +135,10 @@ func (c ConfigLanguage) WorkingDir() string {
return c.m.Base.WorkingDir
}
func (c ConfigLanguage) CacheDirMisc() string {
return c.config.Caches.CacheDirMisc()
}
func (c ConfigLanguage) Quiet() bool {
return c.m.Base.Internal.Quiet
}

View File

@@ -79,6 +79,7 @@ type AllProvider interface {
WorkingDir() string
EnableEmoji() bool
ConfiguredDimensions() *sitesmatrix.ConfiguredDimensions
CacheDirMisc() string
}
// We cannot import the media package as that would create a circular dependency.

File diff suppressed because it is too large Load Diff

View File

@@ -121,7 +121,7 @@ func IsGitHubAction() bool {
}
// SupportsAll reports whether the running system supports all Hugo features,
// e.g. Asciidoc, Pandoc etc.
// e.g. AsciiDoc, Pandoc etc.
func SupportsAll() bool {
return IsGitHubAction() || os.Getenv("CI_LOCAL") != ""
}

View File

@@ -345,7 +345,7 @@ func testAllMarkdownEnginesForPages(t *testing.T,
shouldExecute func() bool
}{
{"md", func() bool { return true }},
{"ad", func() bool { return asciidocext.Supports() }},
{"ad", func() bool { ok, _ := asciidocext.Supports(); return ok }},
{"rst", func() bool { return !htesting.IsRealCI() && rst.Supports() }},
}
@@ -574,7 +574,7 @@ func TestPageSummary(t *testing.T) {
assertFunc := func(t *testing.T, ext string, pages page.Pages) {
p := pages[0]
checkPageTitle(t, p, "SimpleWithoutSummaryDelimiter")
// Source is not Asciidoctor- or RST-compatible so don't test them
// Source is not AsciiDoc- or RST-compatible so don't test them
if ext != "ad" && ext != "rst" {
checkPageContent(t, p, normalizeExpected(ext, "<p><a href=\"https://lipsum.com/\">Lorem ipsum</a> dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>\n\n<p>Additional text.</p>\n\n<p>Further text.</p>\n"), ext)
checkPageSummary(t, p, normalizeExpected(ext, "<p><a href=\"https://lipsum.com/\">Lorem ipsum</a> dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p><p>Additional text.</p>"), ext)
@@ -604,7 +604,7 @@ func TestPageWithSummaryParameter(t *testing.T) {
p := pages[0]
checkPageTitle(t, p, "SimpleWithSummaryParameter")
checkPageContent(t, p, normalizeExpected(ext, "<p>Some text.</p>\n\n<p>Some more text.</p>\n"), ext)
// Summary is not Asciidoctor- or RST-compatible so don't test them
// Summary is not AsciiDoc- or RST-compatible so don't test them
if ext != "ad" && ext != "rst" {
checkPageSummary(t, p, normalizeExpected(ext, "Page with summary parameter and <a href=\"http://www.example.com/\">a link</a>"), ext)
}

View File

@@ -132,7 +132,7 @@ docs/p1/sub/mymixcasetext2.txt
b.AssertFileContent("public/docs/p3/index.html", "<strong>Hello World Default</strong>")
}
func TestPagesFromGoTmplAsciidocAndSimilar(t *testing.T) {
func TestPagesFromGoTmplAsciiDocAndSimilar(t *testing.T) {
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "rss", "sitemap"]
@@ -153,7 +153,7 @@ allow = ['asciidoctor', 'pandoc','rst2html', 'python']
b := hugolib.Test(t, files)
if asciidocext.Supports() {
if ok, _ := asciidocext.Supports(); ok {
b.AssertFileContent("public/docs/asciidoc/index.html",
"Mark my words, <mark>automation is essential</mark>",
"Path: /docs/asciidoc|",
@@ -503,7 +503,7 @@ baseURL = "https://example.com"
func TestPagesFromGoTmplCascade(t *testing.T) {
t.Parallel()
files := `
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "rss", "sitemap"]
baseURL = "https://example.com"
@@ -523,7 +523,7 @@ baseURL = "https://example.com"
func TestPagesFromGoBuildOptions(t *testing.T) {
t.Parallel()
files := `
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "rss", "sitemap"]
baseURL = "https://example.com"
@@ -644,11 +644,11 @@ func TestPagesFromGoTmplMenusMap(t *testing.T) {
-- hugo.toml --
disableKinds = ['rss','section','sitemap','taxonomy','term']
-- content/_content.gotmpl --
{{ $menu1 := dict
{{ $menu1 := dict
"parent" "main-page"
"identifier" "id1"
}}
{{ $menu2 := dict
{{ $menu2 := dict
"parent" "main-page"
"identifier" "id2"
}}
@@ -859,7 +859,7 @@ Title: {{ .Title }}|Content: {{ .Content }}|
func TestPagesFromGoTmplHome(t *testing.T) {
t.Parallel()
files := `
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "rss", "sitemap"]
baseURL = "https://example.com"

View File

@@ -1927,9 +1927,9 @@ Myotherpartial Inline.|{{ .Title }}|
b.AssertFileContent("public/index.html", "My inline partial in all Edited.")
}
func TestRebuildEditAsciidocContentFile(t *testing.T) {
if !asciidocext.Supports() {
t.Skip("skip asciidoc")
func TestRebuildEditAsciiDocContentFile(t *testing.T) {
if ok, err := asciidocext.Supports(); !ok {
t.Skip(err)
}
files := `
-- hugo.toml --

View File

@@ -54,10 +54,10 @@ baseURL = "https://example.org"
Test(c, files)
})
c.Run("Asciidoc, denied", func(c *qt.C) {
c.Run("AsciiDoc, denied", func(c *qt.C) {
c.Parallel()
if !asciidocext.Supports() {
c.Skip()
if ok, err := asciidocext.Supports(); !ok {
c.Skip(err)
}
files := `

File diff suppressed because it is too large Load Diff

View File

@@ -29,21 +29,38 @@ type provider struct{}
func (p provider) New(cfg converter.ProviderConfig) (converter.Provider, error) {
return converter.NewProvider("asciidocext", func(ctx converter.DocumentContext) (converter.Converter, error) {
return &internal.AsciidocConverter{
return &internal.AsciiDocConverter{
Ctx: ctx,
Cfg: cfg,
}, nil
}), nil
}
// Supports returns whether Asciidoctor is installed on this computer.
func Supports() bool {
hasBin := internal.HasAsciiDoc()
// Supports reports whether the AsciiDoc converter is installed. Only used in
// tests.
func Supports() (bool, error) {
hasAsciiDoc, err := internal.HasAsciiDoc()
if htesting.SupportsAll() {
if !hasBin {
panic("asciidoctor not installed")
if !hasAsciiDoc {
panic(err)
}
return true
return true, nil
}
return hasBin
return hasAsciiDoc, err
}
// SupportsGoATDiagrams reports whether the AsciiDoc converter can render GoAT
// diagrams. Only used in tests.
func SupportsGoATDiagrams() (bool, error) {
supportsGoATDiagrams, err := internal.CanRenderGoATDiagrams()
if htesting.SupportsAll() {
if !supportsGoATDiagrams {
panic(err)
}
return true, nil
}
return supportsGoATDiagrams, err
}

View File

@@ -11,14 +11,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Package asciidocext converts AsciiDoc to HTML using Asciidoctor
// Package asciidocext converts AsciiDoc to HTML using the Asciidoctor
// external binary. The `asciidoc` module is reserved for a future golang
// implementation.
package asciidocext_test
import (
"path/filepath"
"encoding/json"
"testing"
"github.com/gohugoio/hugo/common/collections"
@@ -28,6 +28,7 @@ import (
"github.com/gohugoio/hugo/config/security"
"github.com/gohugoio/hugo/config/testconfig"
"github.com/gohugoio/hugo/markup/asciidocext"
"github.com/gohugoio/hugo/markup/asciidocext/asciidocext_config"
"github.com/gohugoio/hugo/markup/asciidocext/internal"
"github.com/gohugoio/hugo/markup/converter"
"github.com/gohugoio/hugo/markup/markup_config"
@@ -36,6 +37,24 @@ import (
qt "github.com/frankban/quicktest"
)
var defaultAsciiDocExtConfigAsJSON []byte
func init() {
var err error
defaultAsciiDocExtConfigAsJSON, err = json.Marshal(markup_config.Default.AsciiDocExt)
if err != nil {
panic(err)
}
}
func resetDefaultAsciiDocExtConfig() {
markup_config.Default.AsciiDocExt = asciidocext_config.Config{}
err := json.Unmarshal(defaultAsciiDocExtConfigAsJSON, &markup_config.Default.AsciiDocExt)
if err != nil {
panic(err)
}
}
func TestAsciidoctorDefaultArgs(t *testing.T) {
c := qt.New(t)
cfg := config.New()
@@ -52,24 +71,30 @@ func TestAsciidoctorDefaultArgs(t *testing.T) {
conv, err := p.New(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
ac := conv.(*internal.AsciidocConverter)
ac := conv.(*internal.AsciiDocConverter)
c.Assert(ac, qt.Not(qt.IsNil))
args := ac.ParseArgs(converter.DocumentContext{})
args, err := ac.ParseArgs(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
expected := []string{"--no-header-footer"}
c.Assert(args, qt.DeepEquals, expected)
}
func TestAsciidoctorNonDefaultArgs(t *testing.T) {
c := qt.New(t)
t.Cleanup(func() {
resetDefaultAsciiDocExtConfig()
})
mconf := markup_config.Default
mconf.AsciidocExt.Backend = "manpage"
mconf.AsciidocExt.NoHeaderOrFooter = false
mconf.AsciidocExt.SafeMode = "safe"
mconf.AsciidocExt.SectionNumbers = true
mconf.AsciidocExt.Verbose = true
mconf.AsciidocExt.Trace = false
mconf.AsciidocExt.FailureLevel = "warn"
mconf.AsciiDocExt.Backend = "manpage"
mconf.AsciiDocExt.NoHeaderOrFooter = false
mconf.AsciiDocExt.SafeMode = "safe"
mconf.AsciiDocExt.SectionNumbers = true
mconf.AsciiDocExt.Verbose = true
mconf.AsciiDocExt.Trace = false
mconf.AsciiDocExt.FailureLevel = "warn"
conf := testconfig.GetTestConfigSectionFromStruct("markup", mconf)
@@ -84,22 +109,28 @@ func TestAsciidoctorNonDefaultArgs(t *testing.T) {
conv, err := p.New(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
ac := conv.(*internal.AsciidocConverter)
ac := conv.(*internal.AsciiDocConverter)
c.Assert(ac, qt.Not(qt.IsNil))
args := ac.ParseArgs(converter.DocumentContext{})
args, err := ac.ParseArgs(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
expected := []string{"-b", "manpage", "--section-numbers", "--verbose", "--failure-level", "warn", "--safe-mode", "safe"}
c.Assert(args, qt.DeepEquals, expected)
}
func TestAsciidoctorDisallowedArgs(t *testing.T) {
c := qt.New(t)
t.Cleanup(func() {
resetDefaultAsciiDocExtConfig()
})
mconf := markup_config.Default
mconf.AsciidocExt.Backend = "disallowed-backend"
mconf.AsciidocExt.Extensions = []string{"./disallowed-extension"}
mconf.AsciidocExt.Attributes = map[string]any{"outdir": "disallowed-attribute"}
mconf.AsciidocExt.SafeMode = "disallowed-safemode"
mconf.AsciidocExt.FailureLevel = "disallowed-failurelevel"
mconf.AsciiDocExt.Backend = "disallowed-backend"
mconf.AsciiDocExt.Extensions = []string{"./disallowed-extension"}
mconf.AsciiDocExt.Attributes = map[string]any{"outdir": "disallowed-attribute"}
mconf.AsciiDocExt.SafeMode = "disallowed-safemode"
mconf.AsciiDocExt.FailureLevel = "disallowed-failurelevel"
conf := testconfig.GetTestConfigSectionFromStruct("markup", mconf)
@@ -114,18 +145,24 @@ func TestAsciidoctorDisallowedArgs(t *testing.T) {
conv, err := p.New(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
ac := conv.(*internal.AsciidocConverter)
ac := conv.(*internal.AsciiDocConverter)
c.Assert(ac, qt.Not(qt.IsNil))
args := ac.ParseArgs(converter.DocumentContext{})
args, err := ac.ParseArgs(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
expected := []string{"--no-header-footer"}
c.Assert(args, qt.DeepEquals, expected)
}
func TestAsciidoctorArbitraryExtension(t *testing.T) {
c := qt.New(t)
t.Cleanup(func() {
resetDefaultAsciiDocExtConfig()
})
mconf := markup_config.Default
mconf.AsciidocExt.Extensions = []string{"arbitrary-extension"}
mconf.AsciiDocExt.Extensions = []string{"arbitrary-extension"}
conf := testconfig.GetTestConfigSectionFromStruct("markup", mconf)
p, err := asciidocext.Provider.New(
converter.ProviderConfig{
@@ -138,10 +175,11 @@ func TestAsciidoctorArbitraryExtension(t *testing.T) {
conv, err := p.New(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
ac := conv.(*internal.AsciidocConverter)
ac := conv.(*internal.AsciiDocConverter)
c.Assert(ac, qt.Not(qt.IsNil))
args := ac.ParseArgs(converter.DocumentContext{})
args, err := ac.ParseArgs(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
expected := []string{"-r", "arbitrary-extension", "--no-header-footer"}
c.Assert(args, qt.DeepEquals, expected)
}
@@ -149,6 +187,10 @@ func TestAsciidoctorArbitraryExtension(t *testing.T) {
func TestAsciidoctorDisallowedExtension(t *testing.T) {
c := qt.New(t)
t.Cleanup(func() {
resetDefaultAsciiDocExtConfig()
})
for _, disallowedExtension := range []string{
`foo-bar//`,
`foo-bar\\ `,
@@ -159,7 +201,7 @@ func TestAsciidoctorDisallowedExtension(t *testing.T) {
`foo.bar`,
} {
mconf := markup_config.Default
mconf.AsciidocExt.Extensions = []string{disallowedExtension}
mconf.AsciiDocExt.Extensions = []string{disallowedExtension}
conf := testconfig.GetTestConfigSectionFromStruct("markup", mconf)
p, err := asciidocext.Provider.New(
converter.ProviderConfig{
@@ -172,94 +214,23 @@ func TestAsciidoctorDisallowedExtension(t *testing.T) {
conv, err := p.New(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
ac := conv.(*internal.AsciidocConverter)
ac := conv.(*internal.AsciiDocConverter)
c.Assert(ac, qt.Not(qt.IsNil))
args := ac.ParseArgs(converter.DocumentContext{})
args, err := ac.ParseArgs(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
expected := []string{"--no-header-footer"}
c.Assert(args, qt.DeepEquals, expected)
}
}
func TestAsciidoctorWorkingFolderCurrent(t *testing.T) {
c := qt.New(t)
cfg := config.FromTOMLConfigString(`
[markup]
[markup.asciidocext]
workingFolderCurrent = true
trace = false
`)
conf := testconfig.GetTestConfig(afero.NewMemMapFs(), cfg)
p, err := asciidocext.Provider.New(
converter.ProviderConfig{
Conf: conf,
Logger: loggers.NewDefault(),
},
)
c.Assert(err, qt.IsNil)
ctx := converter.DocumentContext{Filename: "/tmp/hugo_asciidoc_ddd/docs/chapter2/index.adoc", DocumentName: "chapter2/index.adoc"}
conv, err := p.New(ctx)
c.Assert(err, qt.IsNil)
ac := conv.(*internal.AsciidocConverter)
c.Assert(ac, qt.Not(qt.IsNil))
args := ac.ParseArgs(ctx)
c.Assert(len(args), qt.Equals, 5)
c.Assert(args[0], qt.Equals, "--base-dir")
c.Assert(filepath.ToSlash(args[1]), qt.Matches, "/tmp/hugo_asciidoc_ddd/docs/chapter2")
c.Assert(args[2], qt.Equals, "-a")
c.Assert(args[3], qt.Matches, `outdir=.*chapter2`)
c.Assert(args[4], qt.Equals, "--no-header-footer")
}
func TestAsciidoctorWorkingFolderCurrentAndExtensions(t *testing.T) {
c := qt.New(t)
cfg := config.FromTOMLConfigString(`
[markup]
[markup.asciidocext]
backend = "html5s"
workingFolderCurrent = true
trace = false
noHeaderOrFooter = true
extensions = ["asciidoctor-html5s", "asciidoctor-diagram"]
`)
conf := testconfig.GetTestConfig(afero.NewMemMapFs(), cfg)
p, err := asciidocext.Provider.New(
converter.ProviderConfig{
Conf: conf,
Logger: loggers.NewDefault(),
},
)
c.Assert(err, qt.IsNil)
conv, err := p.New(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
ac := conv.(*internal.AsciidocConverter)
c.Assert(ac, qt.Not(qt.IsNil))
args := ac.ParseArgs(converter.DocumentContext{})
c.Assert(len(args), qt.Equals, 11)
c.Assert(args[0], qt.Equals, "-b")
c.Assert(args[1], qt.Equals, "html5s")
c.Assert(args[2], qt.Equals, "-r")
c.Assert(args[3], qt.Equals, "asciidoctor-html5s")
c.Assert(args[4], qt.Equals, "-r")
c.Assert(args[5], qt.Equals, "asciidoctor-diagram")
c.Assert(args[6], qt.Equals, "--base-dir")
c.Assert(args[7], qt.Equals, ".")
c.Assert(args[8], qt.Equals, "-a")
c.Assert(args[9], qt.Contains, "outdir=")
c.Assert(args[10], qt.Equals, "--no-header-footer")
}
func TestAsciidoctorAttributes(t *testing.T) {
c := qt.New(t)
t.Cleanup(func() {
resetDefaultAsciiDocExtConfig()
})
cfg := config.FromTOMLConfigString(`
[markup]
[markup.asciidocext]
@@ -282,7 +253,7 @@ my-attribute-false = false
conv, err := p.New(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
ac := conv.(*internal.AsciidocConverter)
ac := conv.(*internal.AsciiDocConverter)
c.Assert(ac, qt.Not(qt.IsNil))
expectedValues := map[string]bool{
@@ -292,7 +263,8 @@ my-attribute-false = false
"'!my-attribute-false'": true,
}
args := ac.ParseArgs(converter.DocumentContext{})
args, err := ac.ParseArgs(converter.DocumentContext{})
c.Assert(err, qt.IsNil)
c.Assert(len(args), qt.Equals, 9)
c.Assert(args[0], qt.Equals, "-a")
c.Assert(expectedValues[args[1]], qt.Equals, true)
@@ -329,8 +301,8 @@ allow = ['asciidoctor']
}
func TestConvert(t *testing.T) {
if !asciidocext.Supports() {
t.Skip("asciidoctor not installed")
if ok, err := asciidocext.Supports(); !ok {
t.Skip(err)
}
c := qt.New(t)
@@ -345,8 +317,8 @@ func TestConvert(t *testing.T) {
}
func TestTableOfContents(t *testing.T) {
if !asciidocext.Supports() {
t.Skip("asciidoctor not installed")
if ok, err := asciidocext.Supports(); !ok {
t.Skip(err)
}
c := qt.New(t)
p := getProvider(c, "")
@@ -387,8 +359,8 @@ testContent
}
func TestTableOfContentsWithCode(t *testing.T) {
if !asciidocext.Supports() {
t.Skip("asciidoctor not installed")
if ok, err := asciidocext.Supports(); !ok {
t.Skip(err)
}
c := qt.New(t)
p := getProvider(c, "")
@@ -406,8 +378,8 @@ func TestTableOfContentsWithCode(t *testing.T) {
}
func TestTableOfContentsPreserveTOC(t *testing.T) {
if !asciidocext.Supports() {
t.Skip("asciidoctor not installed")
if ok, err := asciidocext.Supports(); !ok {
t.Skip(err)
}
c := qt.New(t)
confStr := `

View File

@@ -2,11 +2,17 @@ package internal
import (
"bytes"
"fmt"
"net/url"
"path"
"path/filepath"
"strings"
"github.com/gohugoio/hugo/common/hexec"
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/config/security"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/langs"
"github.com/gohugoio/hugo/markup/asciidocext/asciidocext_config"
"github.com/gohugoio/hugo/markup/converter"
"github.com/gohugoio/hugo/markup/internal"
@@ -15,27 +21,52 @@ import (
"golang.org/x/net/html"
)
type AsciidocConverter struct {
type AsciiDocConverter struct {
Ctx converter.DocumentContext
Cfg converter.ProviderConfig
}
type AsciidocResult struct {
type asciiDocResult struct {
converter.ResultRender
toc *tableofcontents.Fragments
}
/* ToDo: RelPermalink patch for svg posts not working*/
type pageSubset interface {
IsPage() bool
RelPermalink() string
Section() string
}
func (r AsciidocResult) TableOfContents() *tableofcontents.Fragments {
const (
// asciiDocBinaryName is name of the AsciiDoc converter CLI.
asciiDocBinaryName = "asciidoctor"
// asciiDocDiagramExtension is the name of the AsciiDoc converter diagram
// extension.
asciiDocDiagramExtension = "asciidoctor-diagram"
// asciiDocDiagramCacheDirKey is the AsciiDoc converter attribute key for
// setting the path to the diagram cache directory.
asciiDocDiagramCacheDirKey = "diagram-cachedir"
// asciiDocDiagramCacheImagesOptionKey is the AsciiDoc converter attribute
// key for determining whether to cache image files in addition to
// metadata files.
asciiDocDiagramCacheImagesOptionKey = "diagram-cache-images-option"
// gemBinaryName is the name of the RubyGems CLI.
gemBinaryName = "gem"
// goatBinaryName is the name of the GoAT CLI.
goatBinaryName = "goat"
)
func (r asciiDocResult) TableOfContents() *tableofcontents.Fragments {
return r.toc
}
func (a *AsciidocConverter) Convert(ctx converter.RenderContext) (converter.ResultRender, error) {
b, err := a.GetAsciidocContent(ctx.Src, a.Ctx)
func (a *AsciiDocConverter) Convert(ctx converter.RenderContext) (converter.ResultRender, error) {
b, err := a.GetAsciiDocContent(ctx.Src, a.Ctx)
if err != nil {
return nil, err
}
@@ -43,50 +74,81 @@ func (a *AsciidocConverter) Convert(ctx converter.RenderContext) (converter.Resu
if err != nil {
return nil, err
}
return AsciidocResult{
return asciiDocResult{
ResultRender: converter.Bytes(content),
toc: toc,
}, nil
}
func (a *AsciidocConverter) Supports(_ identity.Identity) bool {
func (a *AsciiDocConverter) Supports(_ identity.Identity) bool {
return false
}
// GetAsciidocContent calls asciidoctor as an external helper
// to convert AsciiDoc content to HTML.
func (a *AsciidocConverter) GetAsciidocContent(src []byte, ctx converter.DocumentContext) ([]byte, error) {
if !HasAsciiDoc() {
a.Cfg.Logger.Errorln("asciidoctor not found in $PATH: Please install.\n",
" Leaving AsciiDoc content unrendered.")
// GetAsciiDocContent calls asciidoctor as an external helper to convert
// AsciiDoc content to HTML.
func (a *AsciiDocConverter) GetAsciiDocContent(src []byte, ctx converter.DocumentContext) ([]byte, error) {
if ok, err := HasAsciiDoc(); !ok {
a.Cfg.Logger.Errorf("leaving AsciiDoc content unrendered: %s", err.Error())
return src, nil
}
args := a.ParseArgs(ctx)
args = append(args, "-")
args, err := a.ParseArgs(ctx)
if err != nil {
return nil, err
}
args = append(args, "-") // read from stdin
a.Cfg.Logger.Infoln("Rendering", ctx.DocumentName, " using asciidoctor args", args, "...")
a.Cfg.Logger.Infof("Rendering %s using Asciidoctor args %s ...", ctx.DocumentName, args)
return internal.ExternallyRenderContent(a.Cfg, ctx, src, asciiDocBinaryName, args)
}
func (a *AsciidocConverter) ParseArgs(ctx converter.DocumentContext) []string {
cfg := a.Cfg.MarkupConfig().AsciidocExt
func (a *AsciiDocConverter) ParseArgs(ctx converter.DocumentContext) ([]string, error) {
cfg := a.Cfg.MarkupConfig().AsciiDocExt
args := []string{}
args = a.AppendArg(args, "-b", cfg.Backend, asciidocext_config.CliDefault.Backend, asciidocext_config.AllowedBackend)
for _, extension := range cfg.Extensions {
if strings.LastIndexAny(extension, `\/.`) > -1 {
a.Cfg.Logger.Errorln("Unsupported asciidoctor extension was passed in. Extension `" + extension + "` ignored. Only installed asciidoctor extensions are allowed.")
a.Cfg.Logger.Errorf(
"The %q Asciidoctor extension is unsupported and ignored. Only installed Asciidoctor extensions are allowed.",
extension,
)
continue
}
args = append(args, "-r", extension)
if extension == asciiDocDiagramExtension {
cacheDir := filepath.Clean(filepath.Join(a.Cfg.Conf.CacheDirMisc(), asciiDocDiagramExtension))
args = append(args, "-a", asciiDocDiagramCacheDirKey+"="+cacheDir)
args = append(args, "-a", asciiDocDiagramCacheImagesOptionKey)
}
}
for attributeKey, attributeValue := range cfg.Attributes {
if asciidocext_config.DisallowedAttributes[attributeKey] {
a.Cfg.Logger.Errorln("Unsupported asciidoctor attribute was passed in. Attribute `" + attributeKey + "` ignored.")
a.Cfg.Logger.Errorf(
"The %q Asciidoctor attribute is unsupported and ignored.",
attributeKey,
)
continue
}
if attributeKey == asciiDocDiagramCacheImagesOptionKey {
a.Cfg.Logger.Warnf(
"The %q Asciidoctor attribute is fixed and cannot be modified. To disable caching of both image and metadata files, set markup.asciidocext.attributes.diagram-nocache-option to true in your site configuration.",
attributeKey,
)
continue
}
if attributeKey == asciiDocDiagramCacheDirKey {
a.Cfg.Logger.Warnf(
"The %q Asciidoctor attribute is fixed and cannot be modified. To change the cache location, modify caches.misc.dir in your site configuration.",
attributeKey,
)
continue
}
@@ -105,42 +167,63 @@ func (a *AsciidocConverter) ParseArgs(ctx converter.DocumentContext) []string {
}
if cfg.WorkingFolderCurrent {
contentDir := filepath.Dir(ctx.Filename)
destinationDir := a.Cfg.Conf.BaseConfig().PublishDir
if destinationDir == "" {
a.Cfg.Logger.Errorln("markup.asciidocext.workingFolderCurrent requires hugo command option --destination to be set")
page, ok := ctx.Document.(pageSubset)
if !ok {
return nil, fmt.Errorf("expected pageSubset, got %T", ctx.Document)
}
var outDir string
var err error
// Derive the outdir document attribute from the relative permalink.
relPath := strings.TrimPrefix(page.RelPermalink(), a.Cfg.Conf.BaseURL().BasePathNoTrailingSlash)
relPath, err := url.PathUnescape(relPath)
if err != nil {
return nil, err
}
file := filepath.Base(ctx.Filename)
if a.Cfg.Conf.IsUglyURLs("") || file == "_index.adoc" || file == "index.adoc" {
outDir, err = filepath.Abs(filepath.Dir(filepath.Join(destinationDir, ctx.DocumentName)))
} else {
postDir := ""
page, ok := ctx.Document.(pageSubset)
if ok {
postDir = filepath.Base(page.RelPermalink())
if a.Cfg.Conf.IsMultihost() {
// In a multi-host configuration, neither absolute nor relative
// permalinks include the language key; prepend it.
language, ok := a.Cfg.Conf.Language().(*langs.Language)
if !ok {
return nil, fmt.Errorf("expected *langs.Language, got %T", a.Cfg.Conf.Language())
}
relPath = path.Join(language.Lang, relPath)
}
if a.Cfg.Conf.IsUglyURLs(page.Section()) {
if page.IsPage() {
// Remove the extension.
relPath = strings.TrimSuffix(relPath, path.Ext(relPath))
} else {
a.Cfg.Logger.Errorln("unable to cast interface to pageSubset")
// Remove the file name.
relPath = path.Dir(relPath)
}
outDir, err = filepath.Abs(filepath.Join(destinationDir, filepath.Dir(ctx.DocumentName), postDir))
}
// Set imagesoutdir and imagesdir attributes.
imagesoutdir, err := filepath.Abs(filepath.Join(a.Cfg.Conf.BaseConfig().PublishDir, relPath))
if err != nil {
return nil, err
}
imagesdir := filepath.Base(imagesoutdir)
if page.IsPage() {
args = append(args, "-a", "imagesoutdir="+imagesoutdir, "-a", "imagesdir="+imagesdir)
} else {
args = append(args, "-a", "imagesoutdir="+imagesoutdir)
}
}
// Prepend the publishDir.
outDir, err := filepath.Abs(filepath.Join(a.Cfg.Conf.BaseConfig().PublishDir, relPath))
if err != nil {
a.Cfg.Logger.Errorln("asciidoctor outDir: ", err)
return nil, err
}
args = append(args, "--base-dir", contentDir, "-a", "outdir="+outDir)
args = append(args, "--base-dir", filepath.Dir(ctx.Filename), "-a", "outdir="+outDir)
}
if cfg.NoHeaderOrFooter {
args = append(args, "--no-header-footer")
} else {
a.Cfg.Logger.Warnln("asciidoctor parameter NoHeaderOrFooter is expected for correct html rendering")
a.Cfg.Logger.Warnln("Asciidoctor parameter NoHeaderOrFooter is required for correct HTML rendering")
}
if cfg.SectionNumbers {
@@ -159,29 +242,71 @@ func (a *AsciidocConverter) ParseArgs(ctx converter.DocumentContext) []string {
args = a.AppendArg(args, "--safe-mode", cfg.SafeMode, asciidocext_config.CliDefault.SafeMode, asciidocext_config.AllowedSafeMode)
return args
return args, nil
}
func (a *AsciidocConverter) AppendArg(args []string, option, value, defaultValue string, allowedValues map[string]bool) []string {
func (a *AsciiDocConverter) AppendArg(args []string, option, value, defaultValue string, allowedValues map[string]bool) []string {
if value != defaultValue {
if allowedValues[value] {
args = append(args, option, value)
} else {
a.Cfg.Logger.Errorln("Unsupported asciidoctor value `" + value + "` for option " + option + " was passed in and will be ignored.")
a.Cfg.Logger.Errorf(
"Unsupported Asciidoctor value %q for option %q was passed in and will be ignored.",
value,
option,
)
}
}
return args
}
const asciiDocBinaryName = "asciidoctor"
// HasAsciiDoc reports whether the AsciiDoc converter is installed.
func HasAsciiDoc() (bool, error) {
if !hexec.InPath(asciiDocBinaryName) {
return false, fmt.Errorf("the AsciiDoc converter (%s) is not installed", asciiDocBinaryName)
}
return true, nil
}
func HasAsciiDoc() bool {
return hexec.InPath(asciiDocBinaryName)
// CanRenderGoATDiagrams reports whether the AsciiDoc converter can render
// GoAT diagrams. Only used in tests.
func CanRenderGoATDiagrams() (bool, error) {
// Verify that the AsciiDoc converter is installed.
if ok, err := HasAsciiDoc(); !ok {
return false, err
}
// Verify that the RubyGems CLI is installed.
if !hexec.InPath(gemBinaryName) {
return false, fmt.Errorf("the RubyGems CLI (%s) is not installed", gemBinaryName)
}
// Verify that the required AsciiDoc converter extension is installed.
sc := security.DefaultConfig
sc.Exec.Allow = security.MustNewWhitelist(gemBinaryName)
ex := hexec.New(sc, "", loggers.NewDefault())
args := []any{"list", asciiDocDiagramExtension, "--installed"}
cmd, err := ex.New(gemBinaryName, args...)
if err != nil {
return false, err
}
err = cmd.Run()
if err != nil {
return false, fmt.Errorf("the %s gem is not installed", asciiDocDiagramExtension)
}
// Verify that the GoAT CLI is installed.
if !hexec.InPath(goatBinaryName) {
return false, fmt.Errorf("the GoAT CLI (%s) is not installed", goatBinaryName)
}
return true, nil
}
// extractTOC extracts the toc from the given src html.
// It returns the html without the TOC, and the TOC data
func (a *AsciidocConverter) extractTOC(src []byte) ([]byte, *tableofcontents.Fragments, error) {
func (a *AsciiDocConverter) extractTOC(src []byte) ([]byte, *tableofcontents.Fragments, error) {
var buf bytes.Buffer
buf.Write(src)
node, err := html.Parse(&buf)
@@ -196,7 +321,7 @@ func (a *AsciidocConverter) extractTOC(src []byte) ([]byte, *tableofcontents.Fra
f = func(n *html.Node) bool {
if n.Type == html.ElementNode && n.Data == "div" && attr(n, "id") == "toc" {
toc = parseTOC(n)
if !a.Cfg.MarkupConfig().AsciidocExt.PreserveTOC {
if !a.Cfg.MarkupConfig().AsciiDocExt.PreserveTOC {
n.Parent.RemoveChild(n)
}
return true

View File

@@ -37,8 +37,8 @@ type Config struct {
// Configuration for the Goldmark markdown engine.
Goldmark goldmark_config.Config
// Configuration for the Asciidoc external markdown engine.
AsciidocExt asciidocext_config.Config
// Configuration for the AsciiDoc external markdown engine.
AsciiDocExt asciidocext_config.Config
}
func (c *Config) Init() error {
@@ -118,5 +118,5 @@ var Default = Config{
Highlight: highlight.DefaultConfig,
Goldmark: goldmark_config.Default,
AsciidocExt: asciidocext_config.Default,
AsciiDocExt: asciidocext_config.Default,
}

View File

@@ -48,8 +48,8 @@ func TestConfig(t *testing.T) {
c.Assert(conf.Goldmark.Parser.Attribute.Title, qt.Equals, true)
c.Assert(conf.Goldmark.Parser.Attribute.Block, qt.Equals, false)
c.Assert(conf.AsciidocExt.WorkingFolderCurrent, qt.Equals, true)
c.Assert(conf.AsciidocExt.Extensions[0], qt.Equals, "asciidoctor-html5s")
c.Assert(conf.AsciiDocExt.WorkingFolderCurrent, qt.Equals, true)
c.Assert(conf.AsciiDocExt.Extensions[0], qt.Equals, "asciidoctor-html5s")
})
// We changed the typographer extension config from a bool to a struct in 0.112.0.

View File

@@ -297,10 +297,10 @@ Summary Truncated: {{ .Truncated }}|
)
}
func TestPageMarkupWithoutSummaryAsciidoc(t *testing.T) {
func TestPageMarkupWithoutSummaryAsciiDoc(t *testing.T) {
t.Parallel()
if !asciidocext.Supports() {
t.Skip("Skip asiidoc test as not supported")
if ok, err := asciidocext.Supports(); !ok {
t.Skip(err)
}
files := `