summaryrefslogtreecommitdiffstats
path: root/helpers
diff options
context:
space:
mode:
Diffstat (limited to 'helpers')
-rw-r--r--helpers/content.go301
-rw-r--r--helpers/content_test.go244
-rw-r--r--helpers/docshelper.go37
-rw-r--r--helpers/emoji.go96
-rw-r--r--helpers/emoji_test.go143
-rw-r--r--helpers/general.go522
-rw-r--r--helpers/general_test.go460
-rw-r--r--helpers/path.go488
-rw-r--r--helpers/path_test.go561
-rw-r--r--helpers/pathspec.go87
-rw-r--r--helpers/pathspec_test.go62
-rw-r--r--helpers/processing_stats.go120
-rw-r--r--helpers/testhelpers_test.go49
-rw-r--r--helpers/url.go241
-rw-r--r--helpers/url_test.go260
15 files changed, 3671 insertions, 0 deletions
diff --git a/helpers/content.go b/helpers/content.go
new file mode 100644
index 000000000..d04e34a07
--- /dev/null
+++ b/helpers/content.go
@@ -0,0 +1,301 @@
+// Copyright 2019 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package helpers implements general utility functions that work with
+// and on content. The helper functions defined here lay down the
+// foundation of how Hugo works with files and filepaths, and perform
+// string operations on content.
+package helpers
+
+import (
+ "bytes"
+ "html/template"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/gohugoio/hugo/common/hexec"
+ "github.com/gohugoio/hugo/common/loggers"
+
+ "github.com/spf13/afero"
+
+ "github.com/gohugoio/hugo/markup/converter"
+ "github.com/gohugoio/hugo/markup/converter/hooks"
+
+ "github.com/gohugoio/hugo/markup"
+
+ "github.com/gohugoio/hugo/config"
+)
+
+var (
+ openingPTag = []byte("<p>")
+ closingPTag = []byte("</p>")
+ paragraphIndicator = []byte("<p")
+ closingIndicator = []byte("</")
+)
+
+// ContentSpec provides functionality to render markdown content.
+type ContentSpec struct {
+ Converters markup.ConverterProvider
+ anchorNameSanitizer converter.AnchorNameSanitizer
+ getRenderer func(t hooks.RendererType, id any) any
+
+ // SummaryLength is the length of the summary that Hugo extracts from a content.
+ summaryLength int
+
+ BuildFuture bool
+ BuildExpired bool
+ BuildDrafts bool
+
+ Cfg config.Provider
+}
+
+// NewContentSpec returns a ContentSpec initialized
+// with the appropriate fields from the given config.Provider.
+func NewContentSpec(cfg config.Provider, logger loggers.Logger, contentFs afero.Fs, ex *hexec.Exec) (*ContentSpec, error) {
+ spec := &ContentSpec{
+ summaryLength: cfg.GetInt("summaryLength"),
+ BuildFuture: cfg.GetBool("buildFuture"),
+ BuildExpired: cfg.GetBool("buildExpired"),
+ BuildDrafts: cfg.GetBool("buildDrafts"),
+
+ Cfg: cfg,
+ }
+
+ converterProvider, err := markup.NewConverterProvider(converter.ProviderConfig{
+ Cfg: cfg,
+ ContentFs: contentFs,
+ Logger: logger,
+ Exec: ex,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ spec.Converters = converterProvider
+ p := converterProvider.Get("markdown")
+ conv, err := p.New(converter.DocumentContext{})
+ if err != nil {
+ return nil, err
+ }
+ if as, ok := conv.(converter.AnchorNameSanitizer); ok {
+ spec.anchorNameSanitizer = as
+ } else {
+ // Use Goldmark's sanitizer
+ p := converterProvider.Get("goldmark")
+ conv, err := p.New(converter.DocumentContext{})
+ if err != nil {
+ return nil, err
+ }
+ spec.anchorNameSanitizer = conv.(converter.AnchorNameSanitizer)
+ }
+
+ return spec, nil
+}
+
+// stripEmptyNav strips out empty <nav> tags from content.
+func stripEmptyNav(in []byte) []byte {
+ return bytes.Replace(in, []byte("<nav>\n</nav>\n\n"), []byte(``), -1)
+}
+
+// BytesToHTML converts bytes to type template.HTML.
+func BytesToHTML(b []byte) template.HTML {
+ return template.HTML(string(b))
+}
+
+// ExtractTOC extracts Table of Contents from content.
+func ExtractTOC(content []byte) (newcontent []byte, toc []byte) {
+ if !bytes.Contains(content, []byte("<nav>")) {
+ return content, nil
+ }
+ origContent := make([]byte, len(content))
+ copy(origContent, content)
+ first := []byte(`<nav>
+<ul>`)
+
+ last := []byte(`</ul>
+</nav>`)
+
+ replacement := []byte(`<nav id="TableOfContents">
+<ul>`)
+
+ startOfTOC := bytes.Index(content, first)
+
+ peekEnd := len(content)
+ if peekEnd > 70+startOfTOC {
+ peekEnd = 70 + startOfTOC
+ }
+
+ if startOfTOC < 0 {
+ return stripEmptyNav(content), toc
+ }
+ // Need to peek ahead to see if this nav element is actually the right one.
+ correctNav := bytes.Index(content[startOfTOC:peekEnd], []byte(`<li><a href="#`))
+ if correctNav < 0 { // no match found
+ return content, toc
+ }
+ lengthOfTOC := bytes.Index(content[startOfTOC:], last) + len(last)
+ endOfTOC := startOfTOC + lengthOfTOC
+
+ newcontent = append(content[:startOfTOC], content[endOfTOC:]...)
+ toc = append(replacement, origContent[startOfTOC+len(first):endOfTOC]...)
+ return
+}
+
+func (c *ContentSpec) SanitizeAnchorName(s string) string {
+ return c.anchorNameSanitizer.SanitizeAnchorName(s)
+}
+
+func (c *ContentSpec) ResolveMarkup(in string) string {
+ in = strings.ToLower(in)
+ switch in {
+ case "md", "markdown", "mdown":
+ return "markdown"
+ case "html", "htm":
+ return "html"
+ default:
+ if conv := c.Converters.Get(in); conv != nil {
+ return conv.Name()
+ }
+ }
+ return ""
+}
+
+// TotalWords counts instance of one or more consecutive white space
+// characters, as defined by unicode.IsSpace, in s.
+// This is a cheaper way of word counting than the obvious len(strings.Fields(s)).
+func TotalWords(s string) int {
+ n := 0
+ inWord := false
+ for _, r := range s {
+ wasInWord := inWord
+ inWord = !unicode.IsSpace(r)
+ if inWord && !wasInWord {
+ n++
+ }
+ }
+ return n
+}
+
+// TruncateWordsByRune truncates words by runes.
+func (c *ContentSpec) TruncateWordsByRune(in []string) (string, bool) {
+ words := make([]string, len(in))
+ copy(words, in)
+
+ count := 0
+ for index, word := range words {
+ if count >= c.summaryLength {
+ return strings.Join(words[:index], " "), true
+ }
+ runeCount := utf8.RuneCountInString(word)
+ if len(word) == runeCount {
+ count++
+ } else if count+runeCount < c.summaryLength {
+ count += runeCount
+ } else {
+ for ri := range word {
+ if count >= c.summaryLength {
+ truncatedWords := append(words[:index], word[:ri])
+ return strings.Join(truncatedWords, " "), true
+ }
+ count++
+ }
+ }
+ }
+
+ return strings.Join(words, " "), false
+}
+
+// TruncateWordsToWholeSentence takes content and truncates to whole sentence
+// limited by max number of words. It also returns whether it is truncated.
+func (c *ContentSpec) TruncateWordsToWholeSentence(s string) (string, bool) {
+ var (
+ wordCount = 0
+ lastWordIndex = -1
+ )
+
+ for i, r := range s {
+ if unicode.IsSpace(r) {
+ wordCount++
+ lastWordIndex = i
+
+ if wordCount >= c.summaryLength {
+ break
+ }
+
+ }
+ }
+
+ if lastWordIndex == -1 {
+ return s, false
+ }
+
+ endIndex := -1
+
+ for j, r := range s[lastWordIndex:] {
+ if isEndOfSentence(r) {
+ endIndex = j + lastWordIndex + utf8.RuneLen(r)
+ break
+ }
+ }
+
+ if endIndex == -1 {
+ return s, false
+ }
+
+ return strings.TrimSpace(s[:endIndex]), endIndex < len(s)
+}
+
+// TrimShortHTML removes the <p>/</p> tags from HTML input in the situation
+// where said tags are the only <p> tags in the input and enclose the content
+// of the input (whitespace excluded).
+func (c *ContentSpec) TrimShortHTML(input []byte) []byte {
+ firstOpeningP := bytes.Index(input, paragraphIndicator)
+ lastOpeningP := bytes.LastIndex(input, paragraphIndicator)
+
+ lastClosingP := bytes.LastIndex(input, closingPTag)
+ lastClosing := bytes.LastIndex(input, closingIndicator)
+
+ if firstOpeningP == lastOpeningP && lastClosingP == lastClosing {
+ input = bytes.TrimSpace(input)
+ input = bytes.TrimPrefix(input, openingPTag)
+ input = bytes.TrimSuffix(input, closingPTag)
+ input = bytes.TrimSpace(input)
+ }
+ return input
+}
+
+func isEndOfSentence(r rune) bool {
+ return r == '.' || r == '?' || r == '!' || r == '"' || r == '\n'
+}
+
+// Kept only for benchmark.
+func (c *ContentSpec) truncateWordsToWholeSentenceOld(content string) (string, bool) {
+ words := strings.Fields(content)
+
+ if c.summaryLength >= len(words) {
+ return strings.Join(words, " "), false
+ }
+
+ for counter, word := range words[c.summaryLength:] {
+ if strings.HasSuffix(word, ".") ||
+ strings.HasSuffix(word, "?") ||
+ strings.HasSuffix(word, ".\"") ||
+ strings.HasSuffix(word, "!") {
+ upper := c.summaryLength + counter + 1
+ return strings.Join(words[:upper], " "), (upper < len(words))
+ }
+ }
+
+ return strings.Join(words[:c.summaryLength], " "), true
+}
diff --git a/helpers/content_test.go b/helpers/content_test.go
new file mode 100644
index 000000000..54b7ef3f9
--- /dev/null
+++ b/helpers/content_test.go
@@ -0,0 +1,244 @@
+// Copyright 2019 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import (
+ "bytes"
+ "html/template"
+ "strings"
+ "testing"
+
+ "github.com/spf13/afero"
+
+ "github.com/gohugoio/hugo/common/loggers"
+ "github.com/gohugoio/hugo/config"
+
+ qt "github.com/frankban/quicktest"
+)
+
+const tstHTMLContent = "<!DOCTYPE html><html><head><script src=\"http://two/foobar.js\"></script></head><body><nav><ul><li hugo-nav=\"section_0\"></li><li hugo-nav=\"section_1\"></li></ul></nav><article>content <a href=\"http://two/foobar\">foobar</a>. Follow up</article><p>This is some text.<br>And some more.</p></body></html>"
+
+func TestTrimShortHTML(t *testing.T) {
+ tests := []struct {
+ input, output []byte
+ }{
+ {[]byte(""), []byte("")},
+ {[]byte("Plain text"), []byte("Plain text")},
+ {[]byte(" \t\n Whitespace text\n\n"), []byte("Whitespace text")},
+ {[]byte("<p>Simple paragraph</p>"), []byte("Simple paragraph")},
+ {[]byte("\n \n \t <p> \t Whitespace\nHTML \n\t </p>\n\t"), []byte("Whitespace\nHTML")},
+ {[]byte("<p>Multiple</p><p>paragraphs</p>"), []byte("<p>Multiple</p><p>paragraphs</p>")},
+ {[]byte("<p>Nested<p>paragraphs</p></p>"), []byte("<p>Nested<p>paragraphs</p></p>")},
+ {[]byte("<p>Hello</p>\n<ul>\n<li>list1</li>\n<li>list2</li>\n</ul>"), []byte("<p>Hello</p>\n<ul>\n<li>list1</li>\n<li>list2</li>\n</ul>")},
+ }
+
+ c := newTestContentSpec()
+ for i, test := range tests {
+ output := c.TrimShortHTML(test.input)
+ if !bytes.Equal(test.output, output) {
+ t.Errorf("Test %d failed. Expected %q got %q", i, test.output, output)
+ }
+ }
+}
+
+func TestStripEmptyNav(t *testing.T) {
+ c := qt.New(t)
+ cleaned := stripEmptyNav([]byte("do<nav>\n</nav>\n\nbedobedo"))
+ c.Assert(cleaned, qt.DeepEquals, []byte("dobedobedo"))
+}
+
+func TestBytesToHTML(t *testing.T) {
+ c := qt.New(t)
+ c.Assert(BytesToHTML([]byte("dobedobedo")), qt.Equals, template.HTML("dobedobedo"))
+}
+
+func TestNewContentSpec(t *testing.T) {
+ cfg := config.NewWithTestDefaults()
+ c := qt.New(t)
+
+ cfg.Set("summaryLength", 32)
+ cfg.Set("buildFuture", true)
+ cfg.Set("buildExpired", true)
+ cfg.Set("buildDrafts", true)
+
+ spec, err := NewContentSpec(cfg, loggers.NewErrorLogger(), afero.NewMemMapFs(), nil)
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(spec.summaryLength, qt.Equals, 32)
+ c.Assert(spec.BuildFuture, qt.Equals, true)
+ c.Assert(spec.BuildExpired, qt.Equals, true)
+ c.Assert(spec.BuildDrafts, qt.Equals, true)
+}
+
+var benchmarkTruncateString = strings.Repeat("This is a sentence about nothing.", 20)
+
+func BenchmarkTestTruncateWordsToWholeSentence(b *testing.B) {
+ c := newTestContentSpec()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ c.TruncateWordsToWholeSentence(benchmarkTruncateString)
+ }
+}
+
+func BenchmarkTestTruncateWordsToWholeSentenceOld(b *testing.B) {
+ c := newTestContentSpec()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ c.truncateWordsToWholeSentenceOld(benchmarkTruncateString)
+ }
+}
+
+func TestTruncateWordsToWholeSentence(t *testing.T) {
+ c := newTestContentSpec()
+ type test struct {
+ input, expected string
+ max int
+ truncated bool
+ }
+ data := []test{
+ {"a b c", "a b c", 12, false},
+ {"a b c", "a b c", 3, false},
+ {"a", "a", 1, false},
+ {"This is a sentence.", "This is a sentence.", 5, false},
+ {"This is also a sentence!", "This is also a sentence!", 1, false},
+ {"To be. Or not to be. That's the question.", "To be.", 1, true},
+ {" \nThis is not a sentence\nAnd this is another", "This is not a sentence", 4, true},
+ {"", "", 10, false},
+ {"This... is a more difficult test?", "This... is a more difficult test?", 1, false},
+ }
+ for i, d := range data {
+ c.summaryLength = d.max
+ output, truncated := c.TruncateWordsToWholeSentence(d.input)
+ if d.expected != output {
+ t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output)
+ }
+
+ if d.truncated != truncated {
+ t.Errorf("Test %d failed. Expected truncated=%t got %t", i, d.truncated, truncated)
+ }
+ }
+}
+
+func TestTruncateWordsByRune(t *testing.T) {
+ c := newTestContentSpec()
+ type test struct {
+ input, expected string
+ max int
+ truncated bool
+ }
+ data := []test{
+ {"", "", 1, false},
+ {"a b c", "a b c", 12, false},
+ {"a b c", "a b c", 3, false},
+ {"a", "a", 1, false},
+ {"Hello 中国", "", 0, true},
+ {"这是中文,全中文。", "这是中文,", 5, true},
+ {"Hello 中国", "Hello 中", 2, true},
+ {"Hello 中国", "Hello 中国", 3, false},
+ {"Hello中国 Good 好的", "Hello中国 Good 好", 9, true},
+ {"This is a sentence.", "This is", 2, true},
+ {"This is also a sentence!", "This", 1, true},
+ {"To be. Or not to be. That's the question.", "To be. Or not", 4, true},
+ {" \nThis is not a sentence\n ", "This is not", 3, true},
+ }
+ for i, d := range data {
+ c.summaryLength = d.max
+ output, truncated := c.TruncateWordsByRune(strings.Fields(d.input))
+ if d.expected != output {
+ t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output)
+ }
+
+ if d.truncated != truncated {
+ t.Errorf("Test %d failed. Expected truncated=%t got %t", i, d.truncated, truncated)
+ }
+ }
+}
+
+func TestExtractTOCNormalContent(t *testing.T) {
+ content := []byte("<nav>\n<ul>\nTOC<li><a href=\"#")
+
+ actualTocLessContent, actualToc := ExtractTOC(content)
+ expectedTocLess := []byte("TOC<li><a href=\"#")
+ expectedToc := []byte("<nav id=\"TableOfContents\">\n<ul>\n")
+
+ if !bytes.Equal(actualTocLessContent, expectedTocLess) {
+ t.Errorf("Actual tocless (%s) did not equal expected (%s) tocless content", actualTocLessContent, expectedTocLess)
+ }
+
+ if !bytes.Equal(actualToc, expectedToc) {
+ t.Errorf("Actual toc (%s) did not equal expected (%s) toc content", actualToc, expectedToc)
+ }
+}
+
+func TestExtractTOCGreaterThanSeventy(t *testing.T) {
+ content := []byte("<nav>\n<ul>\nTOC This is a very long content which will definitely be greater than seventy, I promise you that.<li><a href=\"#")
+
+ actualTocLessContent, actualToc := ExtractTOC(content)
+ // Because the start of Toc is greater than 70+startpoint of <li> content and empty TOC will be returned
+ expectedToc := []byte("")
+
+ if !bytes.Equal(actualTocLessContent, content) {
+ t.Errorf("Actual tocless (%s) did not equal expected (%s) tocless content", actualTocLessContent, content)
+ }
+
+ if !bytes.Equal(actualToc, expectedToc) {
+ t.Errorf("Actual toc (%s) did not equal expected (%s) toc content", actualToc, expectedToc)
+ }
+}
+
+func TestExtractNoTOC(t *testing.T) {
+ content := []byte("TOC")
+
+ actualTocLessContent, actualToc := ExtractTOC(content)
+ expectedToc := []byte("")
+
+ if !bytes.Equal(actualTocLessContent, content) {
+ t.Errorf("Actual tocless (%s) did not equal expected (%s) tocless content", actualTocLessContent, content)
+ }
+
+ if !bytes.Equal(actualToc, expectedToc) {
+ t.Errorf("Actual toc (%s) did not equal expected (%s) toc content", actualToc, expectedToc)
+ }
+}
+
+var totalWordsBenchmarkString = strings.Repeat("Hugo Rocks ", 200)
+
+func TestTotalWords(t *testing.T) {
+ for i, this := range []struct {
+ s string
+ words int
+ }{
+ {"Two, Words!", 2},
+ {"Word", 1},
+ {"", 0},
+ {"One, Two, Three", 3},
+ {totalWordsBenchmarkString, 400},
+ } {
+ actualWordCount := TotalWords(this.s)
+
+ if actualWordCount != this.words {
+ t.Errorf("[%d] Actual word count (%d) for test string (%s) did not match %d", i, actualWordCount, this.s, this.words)
+ }
+ }
+}
+
+func BenchmarkTotalWords(b *testing.B) {
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ wordCount := TotalWords(totalWordsBenchmarkString)
+ if wordCount != 400 {
+ b.Fatal("Wordcount error")
+ }
+ }
+}
diff --git a/helpers/docshelper.go b/helpers/docshelper.go
new file mode 100644
index 000000000..35d07d366
--- /dev/null
+++ b/helpers/docshelper.go
@@ -0,0 +1,37 @@
+package helpers
+
+import (
+ "sort"
+
+ "github.com/alecthomas/chroma/v2/lexers"
+ "github.com/gohugoio/hugo/docshelper"
+)
+
+// This is is just some helpers used to create some JSON used in the Hugo docs.
+func init() {
+ docsProvider := func() docshelper.DocProvider {
+ var chromaLexers []any
+
+ sort.Sort(lexers.GlobalLexerRegistry.Lexers)
+
+ for _, l := range lexers.GlobalLexerRegistry.Lexers {
+
+ config := l.Config()
+
+ lexerEntry := struct {
+ Name string
+ Aliases []string
+ }{
+ config.Name,
+ config.Aliases,
+ }
+
+ chromaLexers = append(chromaLexers, lexerEntry)
+
+ }
+
+ return docshelper.DocProvider{"chroma": map[string]any{"lexers": chromaLexers}}
+ }
+
+ docshelper.AddDocProviderFunc(docsProvider)
+}
diff --git a/helpers/emoji.go b/helpers/emoji.go
new file mode 100644
index 000000000..eb47ff448
--- /dev/null
+++ b/helpers/emoji.go
@@ -0,0 +1,96 @@
+// Copyright 2016 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import (
+ "bytes"
+ "sync"
+
+ "github.com/kyokomi/emoji/v2"
+)
+
+var (
+ emojiInit sync.Once
+
+ emojis = make(map[string][]byte)
+
+ emojiDelim = []byte(":")
+ emojiWordDelim = []byte(" ")
+ emojiMaxSize int
+)
+
+// Emoji returns the emojy given a key, e.g. ":smile:", nil if not found.
+func Emoji(key string) []byte {
+ emojiInit.Do(initEmoji)
+ return emojis[key]
+}
+
+// Emojify "emojifies" the input source.
+// Note that the input byte slice will be modified if needed.
+// See http://www.emoji-cheat-sheet.com/
+func Emojify(source []byte) []byte {
+ emojiInit.Do(initEmoji)
+
+ start := 0
+ k := bytes.Index(source[start:], emojiDelim)
+
+ for k != -1 {
+
+ j := start + k
+
+ upper := j + emojiMaxSize
+
+ if upper > len(source) {
+ upper = len(source)
+ }
+
+ endEmoji := bytes.Index(source[j+1:upper], emojiDelim)
+ nextWordDelim := bytes.Index(source[j:upper], emojiWordDelim)
+
+ if endEmoji < 0 {
+ start++
+ } else if endEmoji == 0 || (nextWordDelim != -1 && nextWordDelim < endEmoji) {
+ start += endEmoji + 1
+ } else {
+ endKey := endEmoji + j + 2
+ emojiKey := source[j:endKey]
+
+ if emoji, ok := emojis[string(emojiKey)]; ok {
+ source = append(source[:j], append(emoji, source[endKey:]...)...)
+ }
+
+ start += endEmoji
+ }
+
+ if start >= len(source) {
+ break
+ }
+
+ k = bytes.Index(source[start:], emojiDelim)
+ }
+
+ return source
+}
+
+func initEmoji() {
+ emojiMap := emoji.CodeMap()
+
+ for k, v := range emojiMap {
+ emojis[k] = []byte(v)
+
+ if len(k) > emojiMaxSize {
+ emojiMaxSize = len(k)
+ }
+ }
+}
diff --git a/helpers/emoji_test.go b/helpers/emoji_test.go
new file mode 100644
index 000000000..6485bb5fe
--- /dev/null
+++ b/helpers/emoji_test.go
@@ -0,0 +1,143 @@
+// Copyright 2016 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package helpers
+
+import (
+ "math"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/gohugoio/hugo/bufferpool"
+ "github.com/kyokomi/emoji/v2"
+)
+
+func TestEmojiCustom(t *testing.T) {
+ for i, this := range []struct {
+ input string
+ expect []byte
+ }{
+ {"A :smile: a day", []byte("A 😄 a day")},
+ {"A few :smile:s a day", []byte("A few 😄s a day")},
+ {"A :smile: and a :beer: makes the day for sure.", []byte("A 😄 and a 🍺 makes the day for sure.")},
+ {"A :smile: and: a :beer:", []byte("A 😄 and: a 🍺")},
+ {"A :diamond_shape_with_a_dot_inside: and then some.", []byte("A 💠 and then some.")},
+ {":smile:", []byte("😄")},
+ {":smi", []byte(":smi")},
+ {"A :smile:", []byte("A 😄")},
+ {":beer:!", []byte("🍺!")},
+ {"::smile:", []byte(":😄")},
+ {":beer::", []byte("🍺:")},
+ {" :beer: :", []byte(" 🍺 :")},
+ {":beer: and :smile: and another :beer:!", []byte("🍺 and 😄 and another 🍺!")},
+ {" :beer: : ", []byte(" 🍺 : ")},
+ {"No smilies for you!", []byte("No smilies for you!")},
+ {" The motto: no smiles! ", []byte(" The motto: no smiles! ")},
+ {":hugo_is_the_best_static_gen:", []byte(":hugo_is_the_best_static_gen:")},
+ {"은행 :smile: 은행", []byte("은행 😄 은행")},
+ // #2198
+ {"See: A :beer:!", []byte("See: A 🍺!")},
+ {`Aaaaaaaaaa: aaaaaaaaaa aaaaaaaaaa aaaaaaaaaa.
+
+:beer:`, []byte(`Aaaaaaaaaa: aaaaaaaaaa aaaaaaaaaa aaaaaaaaaa.
+
+🍺`)},
+ {"test :\n```bash\nthis is a test\n```\n\ntest\n\n:cool::blush:::pizza:\\:blush : : blush: :pizza:", []byte("test :\n```bash\nthis is a test\n```\n\ntest\n\n🆒😊:🍕\\:blush : : blush: 🍕")},
+ {
+ // 2391
+ "[a](http://gohugo.io) :smile: [r](http://gohugo.io/introduction/overview/) :beer:",
+ []byte(`[a](http://gohugo.io) 😄 [r](http://gohugo.io/introduction/overview/) 🍺`),
+ },
+ } {
+
+ result := Emojify([]byte(this.input))
+
+ if !reflect.DeepEqual(result, this.expect) {
+ t.Errorf("[%d] got %q but expected %q", i, result, this.expect)
+ }
+
+ }
+}
+
+// The Emoji benchmarks below are heavily skewed in Hugo's direction:
+//
+// Hugo have a byte slice, wants a byte slice and doesn't mind if the original is modified.
+
+func BenchmarkEmojiKyokomiFprint(b *testing.B) {
+ f := func(in []byte) []byte {
+ buff := bufferpool.GetBuffer()
+ defer bufferpool.PutBuffer(buff)
+ emoji.Fprint(buff, string(in))
+
+ bc := make([]byte, buff.Len())
+ copy(bc, buff.Bytes())
+ return bc
+ }
+
+ doBenchmarkEmoji(b, f)
+}
+
+func BenchmarkEmojiKyokomiSprint(b *testing.B) {
+ f := func(in []byte) []byte {
+ return []byte(emoji.Sprint(string(in)))
+ }
+
+ doBenchmarkEmoji(b, f)
+}
+
+func BenchmarkHugoEmoji(b *testing.B) {
+ doBenchmarkEmoji(b, Emojify)
+}
+
+func doBenchmarkEmoji(b *testing.B, f func(in []byte) []byte) {
+ type input struct {
+ in []byte
+ expect []byte
+ }
+
+ data := []struct {
+ input string
+ expect string
+ }{
+ {"A :smile: a day", emoji.Sprint("A :smile: a day")},
+ {"A :smile: and a :beer: day keeps the doctor away", emoji.Sprint("A :smile: and a :beer: day keeps the doctor away")},
+ {"A :smile: a day and 10 " + strings.Repeat(":beer: ", 10), emoji.Sprint("A :smile: a day and 10 " + strings.Repeat(":beer: ", 10))},
+ {"No smiles today.", "No smiles today."},
+ {"No smiles for you or " + strings.Repeat("you ", 1000), "No smiles for you or " + strings.Repeat("you ", 1000)},
+ }
+
+ in := make([]input, b.N