mirror of
https://github.com/gohugoio/hugo.git
synced 2024-11-07 20:30:36 -05:00
media, hugolib: Support extension-less media types
This change is motivated by Netlify's `_redirects` files, which is currently not possible to generate with Hugo. This commit adds a `Delimiter` field to media type, which defaults to ".", but can be blanked out. Fixes #3614
This commit is contained in:
parent
516e6c6dc5
commit
0f40e1fadf
7 changed files with 183 additions and 34 deletions
|
@ -164,7 +164,7 @@ func createTargetPath(d targetPathDescriptor) string {
|
||||||
if d.URL != "" {
|
if d.URL != "" {
|
||||||
pagePath = filepath.Join(pagePath, d.URL)
|
pagePath = filepath.Join(pagePath, d.URL)
|
||||||
if strings.HasSuffix(d.URL, "/") || !strings.Contains(d.URL, ".") {
|
if strings.HasSuffix(d.URL, "/") || !strings.Contains(d.URL, ".") {
|
||||||
pagePath = filepath.Join(pagePath, d.Type.BaseName+"."+d.Type.MediaType.Suffix)
|
pagePath = filepath.Join(pagePath, d.Type.BaseName+d.Type.MediaType.FullSuffix())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if d.ExpandedPermalink != "" {
|
if d.ExpandedPermalink != "" {
|
||||||
|
@ -184,9 +184,9 @@ func createTargetPath(d targetPathDescriptor) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
if isUgly {
|
if isUgly {
|
||||||
pagePath += "." + d.Type.MediaType.Suffix
|
pagePath += d.Type.MediaType.Delimiter + d.Type.MediaType.Suffix
|
||||||
} else {
|
} else {
|
||||||
pagePath = filepath.Join(pagePath, d.Type.BaseName+"."+d.Type.MediaType.Suffix)
|
pagePath = filepath.Join(pagePath, d.Type.BaseName+d.Type.MediaType.FullSuffix())
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.LangPrefix != "" {
|
if d.LangPrefix != "" {
|
||||||
|
@ -207,7 +207,7 @@ func createTargetPath(d targetPathDescriptor) string {
|
||||||
base = helpers.FilePathSeparator + d.Type.BaseName
|
base = helpers.FilePathSeparator + d.Type.BaseName
|
||||||
}
|
}
|
||||||
|
|
||||||
pagePath += base + "." + d.Type.MediaType.Suffix
|
pagePath += base + d.Type.MediaType.FullSuffix()
|
||||||
|
|
||||||
if d.LangPrefix != "" {
|
if d.LangPrefix != "" {
|
||||||
pagePath = filepath.Join(d.LangPrefix, pagePath)
|
pagePath = filepath.Join(d.LangPrefix, pagePath)
|
||||||
|
|
|
@ -18,6 +18,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gohugoio/hugo/media"
|
||||||
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/gohugoio/hugo/output"
|
"github.com/gohugoio/hugo/output"
|
||||||
|
@ -27,6 +29,17 @@ func TestPageTargetPath(t *testing.T) {
|
||||||
|
|
||||||
pathSpec := newTestDefaultPathSpec()
|
pathSpec := newTestDefaultPathSpec()
|
||||||
|
|
||||||
|
noExtNoDelimMediaType := media.TextType
|
||||||
|
noExtNoDelimMediaType.Suffix = ""
|
||||||
|
noExtNoDelimMediaType.Delimiter = ""
|
||||||
|
|
||||||
|
// Netlify style _redirects
|
||||||
|
noExtDelimFormat := output.Format{
|
||||||
|
Name: "NER",
|
||||||
|
MediaType: noExtNoDelimMediaType,
|
||||||
|
BaseName: "_redirects",
|
||||||
|
}
|
||||||
|
|
||||||
for _, langPrefix := range []string{"", "no"} {
|
for _, langPrefix := range []string{"", "no"} {
|
||||||
for _, uglyURLs := range []bool{false, true} {
|
for _, uglyURLs := range []bool{false, true} {
|
||||||
t.Run(fmt.Sprintf("langPrefix=%q,uglyURLs=%t", langPrefix, uglyURLs),
|
t.Run(fmt.Sprintf("langPrefix=%q,uglyURLs=%t", langPrefix, uglyURLs),
|
||||||
|
@ -40,6 +53,7 @@ func TestPageTargetPath(t *testing.T) {
|
||||||
{"JSON home", targetPathDescriptor{Kind: KindHome, Type: output.JSONFormat}, "/index.json"},
|
{"JSON home", targetPathDescriptor{Kind: KindHome, Type: output.JSONFormat}, "/index.json"},
|
||||||
{"AMP home", targetPathDescriptor{Kind: KindHome, Type: output.AMPFormat}, "/amp/index.html"},
|
{"AMP home", targetPathDescriptor{Kind: KindHome, Type: output.AMPFormat}, "/amp/index.html"},
|
||||||
{"HTML home", targetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: output.HTMLFormat}, "/index.html"},
|
{"HTML home", targetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: output.HTMLFormat}, "/index.html"},
|
||||||
|
{"Netlify redirects", targetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: noExtDelimFormat}, "/_redirects"},
|
||||||
{"HTML section list", targetPathDescriptor{
|
{"HTML section list", targetPathDescriptor{
|
||||||
Kind: KindSection,
|
Kind: KindSection,
|
||||||
Sections: []string{"sect1"},
|
Sections: []string{"sect1"},
|
||||||
|
|
|
@ -290,3 +290,76 @@ baseName = "feed"
|
||||||
require.Equal(t, "http://example.com/blog/feed.xml", s.Info.RSSLink)
|
require.Equal(t, "http://example.com/blog/feed.xml", s.Info.RSSLink)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #3614
|
||||||
|
func TestDotLessOutputFormat(t *testing.T) {
|
||||||
|
siteConfig := `
|
||||||
|
baseURL = "http://example.com/blog"
|
||||||
|
|
||||||
|
paginate = 1
|
||||||
|
defaultContentLanguage = "en"
|
||||||
|
|
||||||
|
disableKinds = ["page", "section", "taxonomy", "taxonomyTerm", "sitemap", "robotsTXT", "404"]
|
||||||
|
|
||||||
|
[mediaTypes]
|
||||||
|
[mediaTypes."text/nodot"]
|
||||||
|
suffix = ""
|
||||||
|
delimiter = ""
|
||||||
|
[mediaTypes."text/defaultdelim"]
|
||||||
|
suffix = "defd"
|
||||||
|
[mediaTypes."text/nosuffix"]
|
||||||
|
suffix = ""
|
||||||
|
[mediaTypes."text/customdelim"]
|
||||||
|
suffix = "del"
|
||||||
|
delimiter = "_"
|
||||||
|
|
||||||
|
[outputs]
|
||||||
|
home = [ "DOTLESS", "DEF", "NOS", "CUS" ]
|
||||||
|
|
||||||
|
[outputFormats]
|
||||||
|
[outputFormats.DOTLESS]
|
||||||
|
mediatype = "text/nodot"
|
||||||
|
baseName = "_redirects" # This is how Netlify names their redirect files.
|
||||||
|
[outputFormats.DEF]
|
||||||
|
mediatype = "text/defaultdelim"
|
||||||
|
baseName = "defaultdelimbase"
|
||||||
|
[outputFormats.NOS]
|
||||||
|
mediatype = "text/nosuffix"
|
||||||
|
baseName = "nosuffixbase"
|
||||||
|
[outputFormats.CUS]
|
||||||
|
mediatype = "text/customdelim"
|
||||||
|
baseName = "customdelimbase"
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
mf := afero.NewMemMapFs()
|
||||||
|
writeToFs(t, mf, "content/foo.html", `foo`)
|
||||||
|
writeToFs(t, mf, "layouts/_default/list.dotless", `a dotless`)
|
||||||
|
writeToFs(t, mf, "layouts/_default/list.def.defd", `default delimim`)
|
||||||
|
writeToFs(t, mf, "layouts/_default/list.nos", `no suffix`)
|
||||||
|
writeToFs(t, mf, "layouts/_default/list.cus.del", `custom delim`)
|
||||||
|
|
||||||
|
th, h := newTestSitesFromConfig(t, mf, siteConfig)
|
||||||
|
|
||||||
|
err := h.Build(BuildCfg{})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
th.assertFileContent("public/_redirects", "a dotless")
|
||||||
|
th.assertFileContent("public/defaultdelimbase.defd", "default delimim")
|
||||||
|
// This looks weird, but the user has chosen this definition.
|
||||||
|
th.assertFileContent("public/nosuffixbase.", "no suffix")
|
||||||
|
th.assertFileContent("public/customdelimbase_del", "custom delim")
|
||||||
|
|
||||||
|
s := h.Sites[0]
|
||||||
|
home := s.getPage(KindHome)
|
||||||
|
require.NotNil(t, home)
|
||||||
|
|
||||||
|
outputs := home.OutputFormats()
|
||||||
|
|
||||||
|
require.Equal(t, "/blog/_redirects", outputs.Get("DOTLESS").RelPermalink())
|
||||||
|
require.Equal(t, "/blog/defaultdelimbase.defd", outputs.Get("DEF").RelPermalink())
|
||||||
|
require.Equal(t, "/blog/nosuffixbase.", outputs.Get("NOS").RelPermalink())
|
||||||
|
require.Equal(t, "/blog/customdelimbase_del", outputs.Get("CUS").RelPermalink())
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -22,6 +22,10 @@ import (
|
||||||
"github.com/mitchellh/mapstructure"
|
"github.com/mitchellh/mapstructure"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDelimiter = "."
|
||||||
|
)
|
||||||
|
|
||||||
// A media type (also known as MIME type and content type) is a two-part identifier for
|
// A media type (also known as MIME type and content type) is a two-part identifier for
|
||||||
// file formats and format contents transmitted on the Internet.
|
// file formats and format contents transmitted on the Internet.
|
||||||
// For Hugo's use case, we use the top-level type name / subtype name + suffix.
|
// For Hugo's use case, we use the top-level type name / subtype name + suffix.
|
||||||
|
@ -29,9 +33,10 @@ import (
|
||||||
// If suffix is not provided, the sub type will be used.
|
// If suffix is not provided, the sub type will be used.
|
||||||
// See // https://en.wikipedia.org/wiki/Media_type
|
// See // https://en.wikipedia.org/wiki/Media_type
|
||||||
type Type struct {
|
type Type struct {
|
||||||
MainType string // i.e. text
|
MainType string // i.e. text
|
||||||
SubType string // i.e. html
|
SubType string // i.e. html
|
||||||
Suffix string // i.e html
|
Suffix string // i.e html
|
||||||
|
Delimiter string // defaults to "."
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromTypeString creates a new Type given a type sring on the form MainType/SubType and
|
// FromTypeString creates a new Type given a type sring on the form MainType/SubType and
|
||||||
|
@ -54,7 +59,7 @@ func FromString(t string) (Type, error) {
|
||||||
suffix = subParts[1]
|
suffix = subParts[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
return Type{MainType: mainType, SubType: subType, Suffix: suffix}, nil
|
return Type{MainType: mainType, SubType: subType, Suffix: suffix, Delimiter: defaultDelimiter}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type returns a string representing the main- and sub-type of a media type, i.e. "text/css".
|
// Type returns a string representing the main- and sub-type of a media type, i.e. "text/css".
|
||||||
|
@ -72,16 +77,21 @@ func (m Type) String() string {
|
||||||
return fmt.Sprintf("%s/%s", m.MainType, m.SubType)
|
return fmt.Sprintf("%s/%s", m.MainType, m.SubType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FullSuffix returns the file suffix with any delimiter prepended.
|
||||||
|
func (m Type) FullSuffix() string {
|
||||||
|
return m.Delimiter + m.Suffix
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
CalendarType = Type{"text", "calendar", "ics"}
|
CalendarType = Type{"text", "calendar", "ics", defaultDelimiter}
|
||||||
CSSType = Type{"text", "css", "css"}
|
CSSType = Type{"text", "css", "css", defaultDelimiter}
|
||||||
CSVType = Type{"text", "csv", "csv"}
|
CSVType = Type{"text", "csv", "csv", defaultDelimiter}
|
||||||
HTMLType = Type{"text", "html", "html"}
|
HTMLType = Type{"text", "html", "html", defaultDelimiter}
|
||||||
JavascriptType = Type{"application", "javascript", "js"}
|
JavascriptType = Type{"application", "javascript", "js", defaultDelimiter}
|
||||||
JSONType = Type{"application", "json", "json"}
|
JSONType = Type{"application", "json", "json", defaultDelimiter}
|
||||||
RSSType = Type{"application", "rss", "xml"}
|
RSSType = Type{"application", "rss", "xml", defaultDelimiter}
|
||||||
XMLType = Type{"application", "xml", "xml"}
|
XMLType = Type{"application", "xml", "xml", defaultDelimiter}
|
||||||
TextType = Type{"text", "plain", "txt"}
|
TextType = Type{"text", "plain", "txt", defaultDelimiter}
|
||||||
)
|
)
|
||||||
|
|
||||||
var DefaultTypes = Types{
|
var DefaultTypes = Types{
|
||||||
|
|
|
@ -40,6 +40,7 @@ func TestDefaultTypes(t *testing.T) {
|
||||||
require.Equal(t, test.expectedMainType, test.tp.MainType)
|
require.Equal(t, test.expectedMainType, test.tp.MainType)
|
||||||
require.Equal(t, test.expectedSubType, test.tp.SubType)
|
require.Equal(t, test.expectedSubType, test.tp.SubType)
|
||||||
require.Equal(t, test.expectedSuffix, test.tp.Suffix)
|
require.Equal(t, test.expectedSuffix, test.tp.Suffix)
|
||||||
|
require.Equal(t, defaultDelimiter, test.tp.Delimiter)
|
||||||
|
|
||||||
require.Equal(t, test.expectedType, test.tp.Type())
|
require.Equal(t, test.expectedType, test.tp.Type())
|
||||||
require.Equal(t, test.expectedString, test.tp.String())
|
require.Equal(t, test.expectedString, test.tp.String())
|
||||||
|
@ -66,11 +67,11 @@ func TestFromTypeString(t *testing.T) {
|
||||||
|
|
||||||
f, err = FromString("application/custom")
|
f, err = FromString("application/custom")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "custom"}, f)
|
require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "custom", Delimiter: defaultDelimiter}, f)
|
||||||
|
|
||||||
f, err = FromString("application/custom+pdf")
|
f, err = FromString("application/custom+pdf")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "pdf"}, f)
|
require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "pdf", Delimiter: defaultDelimiter}, f)
|
||||||
|
|
||||||
f, err = FromString("noslash")
|
f, err = FromString("noslash")
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|
|
@ -181,17 +181,37 @@ func resolveListTemplate(d LayoutDescriptor, f Format,
|
||||||
case "taxonomyTerm":
|
case "taxonomyTerm":
|
||||||
layouts = resolveTemplate(taxonomyTermLayouts, d, f)
|
layouts = resolveTemplate(taxonomyTermLayouts, d, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
return layouts
|
return layouts
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveTemplate(templ string, d LayoutDescriptor, f Format) []string {
|
func resolveTemplate(templ string, d LayoutDescriptor, f Format) []string {
|
||||||
|
delim := "."
|
||||||
|
if f.MediaType.Delimiter == "" {
|
||||||
|
delim = ""
|
||||||
|
}
|
||||||
layouts := strings.Fields(replaceKeyValues(templ,
|
layouts := strings.Fields(replaceKeyValues(templ,
|
||||||
"SUFFIX", f.MediaType.Suffix,
|
".SUFFIX", delim+f.MediaType.Suffix,
|
||||||
"NAME", strings.ToLower(f.Name),
|
"NAME", strings.ToLower(f.Name),
|
||||||
"SECTION", d.Section))
|
"SECTION", d.Section))
|
||||||
|
|
||||||
return layouts
|
return filterDotLess(layouts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterDotLess(layouts []string) []string {
|
||||||
|
var filteredLayouts []string
|
||||||
|
|
||||||
|
for _, l := range layouts {
|
||||||
|
// This may be constructed, but media types can be suffix-less, but can contain
|
||||||
|
// a delimiter.
|
||||||
|
l = strings.TrimSuffix(l, ".")
|
||||||
|
// If media type has no suffix, we have "index" type of layouts in this list, which
|
||||||
|
// doesn't make much sense.
|
||||||
|
if strings.Contains(l, ".") {
|
||||||
|
filteredLayouts = append(filteredLayouts, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredLayouts
|
||||||
}
|
}
|
||||||
|
|
||||||
func prependTextPrefixIfNeeded(f Format, layouts ...string) []string {
|
func prependTextPrefixIfNeeded(f Format, layouts ...string) []string {
|
||||||
|
@ -220,7 +240,12 @@ func regularPageLayouts(types string, layout string, f Format) []string {
|
||||||
layout = "single"
|
layout = "single"
|
||||||
}
|
}
|
||||||
|
|
||||||
suffix := f.MediaType.Suffix
|
delimiter := "."
|
||||||
|
if f.MediaType.Delimiter == "" {
|
||||||
|
delimiter = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
suffix := delimiter + f.MediaType.Suffix
|
||||||
name := strings.ToLower(f.Name)
|
name := strings.ToLower(f.Name)
|
||||||
|
|
||||||
if types != "" {
|
if types != "" {
|
||||||
|
@ -229,15 +254,15 @@ func regularPageLayouts(types string, layout string, f Format) []string {
|
||||||
// Add type/layout.html
|
// Add type/layout.html
|
||||||
for i := range t {
|
for i := range t {
|
||||||
search := t[:len(t)-i]
|
search := t[:len(t)-i]
|
||||||
layouts = append(layouts, fmt.Sprintf("%s/%s.%s.%s", strings.ToLower(path.Join(search...)), layout, name, suffix))
|
layouts = append(layouts, fmt.Sprintf("%s/%s.%s%s", strings.ToLower(path.Join(search...)), layout, name, suffix))
|
||||||
layouts = append(layouts, fmt.Sprintf("%s/%s.%s", strings.ToLower(path.Join(search...)), layout, suffix))
|
layouts = append(layouts, fmt.Sprintf("%s/%s%s", strings.ToLower(path.Join(search...)), layout, suffix))
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add _default/layout.html
|
// Add _default/layout.html
|
||||||
layouts = append(layouts, fmt.Sprintf("_default/%s.%s.%s", layout, name, suffix))
|
layouts = append(layouts, fmt.Sprintf("_default/%s.%s%s", layout, name, suffix))
|
||||||
layouts = append(layouts, fmt.Sprintf("_default/%s.%s", layout, suffix))
|
layouts = append(layouts, fmt.Sprintf("_default/%s%s", layout, suffix))
|
||||||
|
|
||||||
return layouts
|
return filterDotLess(layouts)
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,14 +21,34 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ampType = Format{
|
|
||||||
Name: "AMP",
|
|
||||||
MediaType: media.HTMLType,
|
|
||||||
BaseName: "index",
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLayout(t *testing.T) {
|
func TestLayout(t *testing.T) {
|
||||||
|
|
||||||
|
noExtNoDelimMediaType := media.TextType
|
||||||
|
noExtNoDelimMediaType.Suffix = ""
|
||||||
|
noExtNoDelimMediaType.Delimiter = ""
|
||||||
|
|
||||||
|
noExtMediaType := media.TextType
|
||||||
|
noExtMediaType.Suffix = ""
|
||||||
|
|
||||||
|
var (
|
||||||
|
ampType = Format{
|
||||||
|
Name: "AMP",
|
||||||
|
MediaType: media.HTMLType,
|
||||||
|
BaseName: "index",
|
||||||
|
}
|
||||||
|
|
||||||
|
noExtDelimFormat = Format{
|
||||||
|
Name: "NEM",
|
||||||
|
MediaType: noExtNoDelimMediaType,
|
||||||
|
BaseName: "_redirects",
|
||||||
|
}
|
||||||
|
noExt = Format{
|
||||||
|
Name: "NEX",
|
||||||
|
MediaType: noExtMediaType,
|
||||||
|
BaseName: "next",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
for _, this := range []struct {
|
for _, this := range []struct {
|
||||||
name string
|
name string
|
||||||
d LayoutDescriptor
|
d LayoutDescriptor
|
||||||
|
@ -39,6 +59,12 @@ func TestLayout(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{"Home", LayoutDescriptor{Kind: "home"}, true, "", ampType,
|
{"Home", LayoutDescriptor{Kind: "home"}, true, "", ampType,
|
||||||
[]string{"index.amp.html", "index.html", "_default/list.amp.html", "_default/list.html", "theme/index.amp.html", "theme/index.html"}},
|
[]string{"index.amp.html", "index.html", "_default/list.amp.html", "_default/list.html", "theme/index.amp.html", "theme/index.html"}},
|
||||||
|
{"Home, no ext or delim", LayoutDescriptor{Kind: "home"}, true, "", noExtDelimFormat,
|
||||||
|
[]string{"index.nem", "_default/list.nem"}},
|
||||||
|
{"Home, no ext", LayoutDescriptor{Kind: "home"}, true, "", noExt,
|
||||||
|
[]string{"index.nex", "_default/list.nex"}},
|
||||||
|
{"Page, no ext or delim", LayoutDescriptor{Kind: "page"}, true, "", noExtDelimFormat,
|
||||||
|
[]string{"_default/single.nem", "theme/_default/single.nem"}},
|
||||||
{"Section", LayoutDescriptor{Kind: "section", Section: "sect1"}, false, "", ampType,
|
{"Section", LayoutDescriptor{Kind: "section", Section: "sect1"}, false, "", ampType,
|
||||||
[]string{"section/sect1.amp.html", "section/sect1.html"}},
|
[]string{"section/sect1.amp.html", "section/sect1.html"}},
|
||||||
{"Taxonomy", LayoutDescriptor{Kind: "taxonomy", Section: "tag"}, false, "", ampType,
|
{"Taxonomy", LayoutDescriptor{Kind: "taxonomy", Section: "tag"}, false, "", ampType,
|
||||||
|
|
Loading…
Reference in a new issue