From 4ea94c451df9aaf85c03fb302662310e71f98677 Mon Sep 17 00:00:00 2001 From: Joe Mooring Date: Tue, 31 Dec 2024 17:43:35 -0500 Subject: [PATCH] tpl/images: Add images.QR function Closes #13205 --- README.md | 31 ++--- .../en/content-management/shortcodes.md | 102 +++++++++++++++- docs/content/en/functions/images/QR.md | 115 ++++++++++++++++++ docs/data/embedded_template_urls.toml | 9 +- go.mod | 1 + go.sum | 2 + tpl/images/images.go | 100 +++++++++++++-- tpl/images/images_integration_test.go | 51 ++++++++ .../embedded/templates/shortcodes/qr.html | 76 ++++++++++++ tpl/tplimpl/tplimpl_integration_test.go | 36 ++++++ 10 files changed, 495 insertions(+), 28 deletions(-) create mode 100644 docs/content/en/functions/images/QR.md create mode 100644 tpl/tplimpl/embedded/templates/shortcodes/qr.html diff --git a/README.md b/README.md index 10ce6e2009f..913130b5d94 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ Build the extended edition: ```text CGO_ENABLED=1 go install -tags extended github.com/gohugoio/hugo@latest ``` + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=gohugoio/hugo&type=Timeline)](https://star-history.com/#gohugoio/hugo&Timeline) @@ -154,7 +155,7 @@ github.com/bep/clocks="v0.5.0" github.com/bep/debounce="v1.2.0" github.com/bep/gitmap="v1.6.0" github.com/bep/goat="v0.5.0" -github.com/bep/godartsass/v2="v2.3.0" +github.com/bep/godartsass/v2="v2.3.2" github.com/bep/golibsass="v1.2.0" github.com/bep/gowebp="v0.3.0" github.com/bep/imagemeta="v0.8.3" @@ -189,7 +190,7 @@ github.com/gohugoio/locales="v0.14.0" github.com/gohugoio/localescompressed="v1.0.1" github.com/google/go-cmp="v0.6.0" github.com/gorilla/websocket="v1.5.3" -github.com/hairyhenderson/go-codeowners="v0.6.1" +github.com/hairyhenderson/go-codeowners="v0.7.0" github.com/hashicorp/golang-lru/v2="v2.0.7" github.com/invopop/yaml="v0.2.0" github.com/jdkato/prose="v1.2.1" @@ -218,30 +219,32 @@ github.com/russross/blackfriday/v2="v2.1.0" github.com/sass/dart-sass/compiler="1.81.0" github.com/sass/dart-sass/implementation="1.81.0" github.com/sass/dart-sass/protocol="3.1.0" +github.com/sass/libsass="3.6.6" github.com/spf13/afero="v1.11.0" -github.com/spf13/cast="v1.7.0" +github.com/spf13/cast="v1.7.1" github.com/spf13/cobra="v1.8.1" github.com/spf13/fsync="v0.10.1" github.com/spf13/pflag="v1.0.5" -github.com/tdewolff/minify/v2="v2.21.1" -github.com/tdewolff/parse/v2="v2.7.18" -github.com/tetratelabs/wazero="v1.8.1" +github.com/tdewolff/minify/v2="v2.20.37" +github.com/tdewolff/parse/v2="v2.7.15" +github.com/tetratelabs/wazero="v1.8.2" +github.com/webmproject/libwebp="v1.3.2" github.com/yuin/goldmark-emoji="v1.0.4" github.com/yuin/goldmark="v1.7.8" go.uber.org/automaxprocs="v1.5.3" -golang.org/x/crypto="v0.29.0" +golang.org/x/crypto="v0.31.0" golang.org/x/exp="v0.0.0-20221031165847-c99f073a8326" golang.org/x/image="v0.22.0" golang.org/x/mod="v0.22.0" -golang.org/x/net="v0.31.0" -golang.org/x/sync="v0.9.0" -golang.org/x/sys="v0.27.0" -golang.org/x/text="v0.20.0" -golang.org/x/tools="v0.27.0" -google.golang.org/protobuf="v1.35.1" +golang.org/x/net="v0.33.0" +golang.org/x/sync="v0.10.0" +golang.org/x/sys="v0.28.0" +golang.org/x/text="v0.21.0" +golang.org/x/tools="v0.28.0" +google.golang.org/protobuf="v1.35.2" gopkg.in/yaml.v2="v2.4.0" gopkg.in/yaml.v3="v3.0.1" -howett.net/plist="v1.0.0" +rsc.io/qr="v0.2.0" software.sslmate.com/src/go-pkcs12="v0.2.0" ``` diff --git a/docs/content/en/content-management/shortcodes.md b/docs/content/en/content-management/shortcodes.md index 47e4f94ed06..afe84e79bb9 100644 --- a/docs/content/en/content-management/shortcodes.md +++ b/docs/content/en/content-management/shortcodes.md @@ -121,7 +121,7 @@ Hugo renders this to: ``` -The details shortcode accepts these named arguments: +The `details` shortcode accepts these named arguments: summary : (`string`) The content of the child `summary` element rendered from Markdown to HTML. Default is `Details`. @@ -333,6 +333,106 @@ Access nested values by [chaining] the [identifiers]: {{}} ``` +### qr + +{{% note %}} +To override Hugo's embedded `qr` shortcode, copy the [source code] to a file with the same name in the layouts/shortcodes directory. + +[source code]: {{% eturl qr %}} +{{% /note %}} + +The `qr` shortcode encodes the given text into a [QR code] using the specified options and renders the resulting image. + +[QR code]: https://en.wikipedia.org/wiki/QR_code + +Use the self-closing syntax to pass the text as an argument: + +```text +{{}} +``` + +Or insert the text between the opening and closing tags: + +```text +{{}} +https://gohugo.io +{{}} +``` + +Both of the above produce this image: + +{{< qr text="https://gohugo.io" class="qrcode" />}} + +To create a QR code for a phone number: + +```text +{{}} +``` + +{{< qr text="tel:+12065550101" class="qrcode" />}} + +To create a QR code containing contact information in the [vCard] format: + +[vCard]: https://en.wikipedia.org/wiki/VCard + +```text +{{}} +BEGIN:VCARD +VERSION:2.1 +N;CHARSET=UTF-8:Smith;John;R.;Dr.;PhD +FN;CHARSET=UTF-8:Dr. John R. Smith, PhD. +ORG;CHARSET=UTF-8:ABC Widgets +TITLE;CHARSET=UTF-8:Vice President Engineering +TEL;TYPE=WORK:+12065550101 +EMAIL;TYPE=WORK:jsmith@example.org +END:VCARD +{{}} +``` + +{{< qr level="low" scale=2 alt="QR code of vCard for John Smith" class="qrcode" >}} +BEGIN:VCARD +VERSION:2.1 +N;CHARSET=UTF-8:Smith;John;R.;Dr.;PhD +FN;CHARSET=UTF-8:Dr. John R. Smith, PhD. +ORG;CHARSET=UTF-8:ABC Widgets +TITLE;CHARSET=UTF-8:Vice President Engineering +TEL;TYPE=WORK:+12065550101 +EMAIL;TYPE=WORK:jsmith@example.org +END:VCARD +{{< /qr >}} + +Internally this shortcode calls the `images.QR` function. Please read the [related documentation] for implementation details and guidance. + +[related documentation]: /functions/images/qr/ + +The `qr` shortcode accepts these named arguments: + +text +: (`string`) The text to encode, falling back to the text between the opening and closing shortcode tags. + +level +: (`string`) The error correction level to use when encoding the text, one of `low`, `medium`, `quartile`, or `high`. Default is `medium`. + +scale +: (`int`) The number of image pixels per QR code module. Must be greater than or equal to 2. Default is `4`. + +targetDir +: (`string`) The subdirectory within the [`publishDir`] where Hugo will place the generated image. + +[`publishDir`]: /getting-started/configuration/#publishdir + +alt +: (`string`) The `alt` attribute of the `img` element. + +class +: (`string`) The `class` attribute of the `img` element. + +id +: (`string`) The `id` attribute of the `img` element. + +title +: (`string`) The `title` attribute of the `img` element. + ### ref {{% note %}} diff --git a/docs/content/en/functions/images/QR.md b/docs/content/en/functions/images/QR.md new file mode 100644 index 00000000000..c1b8fb4657f --- /dev/null +++ b/docs/content/en/functions/images/QR.md @@ -0,0 +1,115 @@ +--- +title: images.QR +description: Encodes the given text into a QR code using the specified options, returning an image resource. +keywords: [] +action: + aliases: [] + related: [] + returnType: images.ImageResource + signatures: ['images.QR OPTIONS'] +toc: true +math: true +--- + +{{< new-in 0.141.0 >}} + +The `images.QR` function encodes the given text into a [QR code] using the specified options, returning an image resource. The size of the generated image depends on three factors: + +- Data length: Longer text necessitates a larger image to accommodate the increased information density. +- Error correction level: Higher error correction levels enhance the QR code's resistance to damage, but this typically results in a slightly larger image size to maintain readability. +- Pixels per module: The number of image pixels assigned to each individual module (the smallest unit of the QR code) directly impacts the overall image size. A higher pixel count per module leads to a larger, higher-resolution image. + +Although the default option values are sufficient for most applications, you should test the rendered QR code both on-screen and in print. + +[QR code]: https://en.wikipedia.org/wiki/QR_code + +## Options + +text +: (`string`) The text to encode. + +level +: (`string`) The error correction level to use when encoding the text, one of `low`, `medium`, `quartile`, or `high`. Default is `medium`. + +Error correction level|Redundancy +:--|:--|:-- +low|20% +medium|38% +quartile|55% +high|65% + +scale +: (`int`) The number of image pixels per QR code module. Must be greater than or equal to `2`. Default is `4`. + +targetDir +: (`string`) The subdirectory within the [`publishDir`] where Hugo will place the generated image. Use Unix-style slashes (`/`) to separarate path segments. If empty or not provided, the image is placed directly in the `publishDir` root. Hugo automatically creates the necessary subdirectories if they don't exist. + +[`publishDir`]: /getting-started/configuration/#publishdir + +## Examples + +To create a QR code using the default values for `level` and `scale`: + +```go-html-template +{{ $opts := dict "text" "https://gohugo.io" }} +{{ with images.QR $opts }} + +{{ end }} +``` + +{{< qr text="https://gohugo.io" class="qrcode" />}} + +Specify `level`, `scale`, and `targetDir` as needed to achieve the desired result: + +```go-html-template +{{ $opts := dict + "text" "https://gohugo.io" + "level" "high" + "scale" 3 + "targetDir" "codes" +}} +{{ with images.QR $opts }} + +{{ end }} +``` + +{{< qr text="https://gohugo.io" level="high" scale=3 targetDir="codes" class="qrcode" />}} + +## Scale + +As you decrease the size of a QR code, the maximum distance at which it can be reliably scanned by a device also decreases. + +In the example above, we set the `scale` to `2`, resulting in a QR code where each module consists of 2x2 pixels. While this might be sufficient for on-screen display, it's likely to be problematic when printed at 600 dpi. + +\[ \frac{2\:px}{module} \times \frac{1\:inch}{600\:px} \times \frac{25.4\:mm}{1\:inch} = \frac{0.085\:mm}{module} \] + +This module size is half of the commonly recommended minimum of 0.170 mm.\ +If the QR code will be printed, use the default `scale` value of `4` pixels per module. + +Avoid using Hugo's image processing methods to resize QR codes. Resizing can introduce blurring due to anti-aliasing when a QR code module occupies a fractional number of pixels. + +{{% note %}} +Always test the rendered QR code both on-screen and in print. +{{% /note %}} + +## Shortcode + +Call the `qr` shortcode to insert a QR code into your content. + +Use the self-closing syntax to pass the text as an argument: + +```text +{{}} +``` + +Or insert the text between the opening and closing tags: + +```text +{{}} +https://gohugo.io +{{}} +``` + +The `qr` shortcode accepts several arguments including `level` and `scale`. See the [related documentation] for details. + +[related documentation]: /content-management/shortcodes/#qr diff --git a/docs/data/embedded_template_urls.toml b/docs/data/embedded_template_urls.toml index b7247f2727f..359105e6045 100644 --- a/docs/data/embedded_template_urls.toml +++ b/docs/data/embedded_template_urls.toml @@ -10,18 +10,18 @@ 'google_analytics' = 'google_analytics.html' 'opengraph' = 'opengraph.html' 'pagination' = 'pagination.html' -'schema' = 'schema.html' -'twitter_cards' = 'twitter_cards.html' - 'robots' = '_default/robots.txt' 'rss' = '_default/rss.xml' +'schema' = 'schema.html' 'sitemap' = '_default/sitemap.xml' 'sitemapindex' = '_default/sitemapindex.xml' +'twitter_cards' = 'twitter_cards.html' # Render hooks +'render-codeblock-goat' = '_default/_markup/render-codeblock-goat.html' 'render-image' = '_default/_markup/render-image.html' 'render-link' = '_default/_markup/render-link.html' -'render-codeblock-goat' = '_default/_markup/render-codeblock-goat.html' +'render-table' = '_default/_markup/render-table.html' # Shortcodes 'comment' = 'shortcodes/comment.html' @@ -31,6 +31,7 @@ 'highlight' = 'shortcodes/highlight.html' 'instagram' = 'shortcodes/instagram.html' 'param' = 'shortcodes/param.html' +'qr' = 'shortcodes/qr.html' 'ref' = 'shortcodes/ref.html' 'relref' = 'shortcodes/relref.html' 'twitter' = 'shortcodes/twitter.html' diff --git a/go.mod b/go.mod index 1f420e2fa51..4ee08313e3d 100644 --- a/go.mod +++ b/go.mod @@ -165,6 +165,7 @@ require ( google.golang.org/protobuf v1.35.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect howett.net/plist v1.0.0 // indirect + rsc.io/qr v0.2.0 // indirect software.sslmate.com/src/go-pkcs12 v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 58d002d5a59..0db43c53586 100644 --- a/go.sum +++ b/go.sum @@ -878,6 +878,8 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= software.sslmate.com/src/go-pkcs12 v0.2.0 h1:nlFkj7bTysH6VkC4fGphtjXRbezREPgrHuJG20hBGPE= diff --git a/tpl/images/images.go b/tpl/images/images.go index 02ffb333fa9..08ab3cc958c 100644 --- a/tpl/images/images.go +++ b/tpl/images/images.go @@ -16,11 +16,18 @@ package images import ( "errors" + "fmt" "image" + "path" "sync" "github.com/bep/overlayfs" + "github.com/gohugoio/hugo/common/hashing" + "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/resources/images" + "github.com/gohugoio/hugo/resources/resource_factories/create" + "github.com/mitchellh/mapstructure" + "rsc.io/qr" // Importing image codecs for image.DecodeConfig _ "image/gif" @@ -50,21 +57,22 @@ func New(d *deps.Deps) *Namespace { } return &Namespace{ - readFileFs: readFileFs, - Filters: &images.Filters{}, - cache: map[string]image.Config{}, - deps: d, + readFileFs: readFileFs, + Filters: &images.Filters{}, + cache: map[string]image.Config{}, + deps: d, + createClient: create.New(d.ResourceSpec), } } // Namespace provides template functions for the "images" namespace. type Namespace struct { *images.Filters - readFileFs afero.Fs - cacheMu sync.RWMutex - cache map[string]image.Config - - deps *deps.Deps + readFileFs afero.Fs + cacheMu sync.RWMutex + cache map[string]image.Config + deps *deps.Deps + createClient *create.Client } // Config returns the image.Config for the specified path relative to the @@ -117,3 +125,77 @@ func (ns *Namespace) Filter(args ...any) (images.ImageResource, error) { return img.Filter(filtersv...) } + +var qrErrorCorrectionLevels = map[string]qr.Level{ + "low": qr.L, + "medium": qr.M, + "quartile": qr.Q, + "high": qr.H, +} + +// QR encodes the given text into a QR code using the specified options, +// returning an image resource. +func (ns *Namespace) QR(options any) (images.ImageResource, error) { + const ( + qrDefaultErrorCorrectionLevel = "medium" + qrDefaultScale = 4 + ) + + opts := struct { + Text string // text to encode + Level string // error correction level; one of low, medium, quartile, or high + Scale int // number of image pixels per QR code module + TargetDir string // target directory relative to publishDir + }{ + Level: qrDefaultErrorCorrectionLevel, + Scale: qrDefaultScale, + } + + err := mapstructure.WeakDecode(options, &opts) + if err != nil { + return nil, err + } + + if opts.Text == "" { + return nil, errors.New("cannot encode an empty string") + } + + level, ok := qrErrorCorrectionLevels[opts.Level] + if !ok { + return nil, errors.New("error correction level must be one of low, medium, quartile, or high") + } + + if opts.Scale < 2 { + return nil, errors.New("scale must be an integer greater than or equal to 2") + } + + targetPath := path.Join(opts.TargetDir, fmt.Sprintf("qr_%s.png", hashing.HashString(opts))) + + r, err := ns.createClient.FromOpts( + create.Options{ + TargetPath: targetPath, + TargetPathHasHash: true, + CreateContent: func() (func() (hugio.ReadSeekCloser, error), error) { + code, err := qr.Encode(opts.Text, level) + if err != nil { + return nil, err + } + code.Scale = opts.Scale + png := code.PNG() + return func() (hugio.ReadSeekCloser, error) { + return hugio.NewReadSeekerNoOpCloserFromBytes(png), nil + }, nil + }, + }, + ) + if err != nil { + return nil, err + } + + ir, ok := r.(images.ImageResource) + if !ok { + panic("bug: resource is not an image resource") + } + + return ir, nil +} diff --git a/tpl/images/images_integration_test.go b/tpl/images/images_integration_test.go index 003422aedaf..e9a31c9afac 100644 --- a/tpl/images/images_integration_test.go +++ b/tpl/images/images_integration_test.go @@ -14,8 +14,10 @@ package images_test import ( + "strings" "testing" + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/hugolib" ) @@ -49,3 +51,52 @@ fileExists2 OK: true| imageConfig2 OK: 1| `) } + +func TestQR(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- layouts/index.html -- +{{- $text := "https://gohugo.io" }} +{{- $optionMaps := slice + (dict "text" $text) + (dict "text" $text "level" "medium") + (dict "text" $text "level" "medium" "scale" 4) + (dict "text" $text "level" "low" "scale" 2) + (dict "text" $text "level" "medium" "scale" 3) + (dict "text" $text "level" "quartile" "scale" 5) + (dict "text" $text "level" "high" "scale" 6) + (dict "text" $text "level" "high" "scale" 6 "targetDir" "foo/bar") +}} +{{- range $k, $opts := $optionMaps }} + {{- with images.QR $opts }} + + {{- end }} +{{- end }} +` + + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.html", + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ) + + files = strings.ReplaceAll(files, "low", "foo") + + b, err := hugolib.TestE(t, files) + b.Assert(err.Error(), qt.Contains, "error correction level must be one of low, medium, quartile, or high") + + files = strings.ReplaceAll(files, "foo", "low") + files = strings.ReplaceAll(files, "https://gohugo.io", "") + + b, err = hugolib.TestE(t, files) + b.Assert(err.Error(), qt.Contains, "cannot encode an empty string") +} diff --git a/tpl/tplimpl/embedded/templates/shortcodes/qr.html b/tpl/tplimpl/embedded/templates/shortcodes/qr.html new file mode 100644 index 00000000000..cae7e56705b --- /dev/null +++ b/tpl/tplimpl/embedded/templates/shortcodes/qr.html @@ -0,0 +1,76 @@ +{{- /* +Encodes the given text into a QR code using the specified options and renders the resulting image. + +@param {string} text The text to encode, falling back to the text between the opening and closing shortcode tags. +@param {string} [level=medium] The error correction level to use when encoding the text, one of low, medium, quartile, or high. +@param {int} [scale=4] The number of image pixels per QR code module. Must be greater than or equal to 2. +@param {string} [targetDir] The subdirectory within the publishDir where Hugo will place the generated image. +@param {string} [alt] The alt attribute of the img element. +@param {string} [class] The class attribute of the img element. +@param {string} [id] The id attribute of the img element. +@param {string} [title] The title attribute of the img element. + +@returns {template.HTML} + +@examples + + {{< qr text="https://gohugo.io" />}} + + {{< qr >}} + https://gohugo.io" + {{< /qr >}} + + {{< qr + text="https://gohugo.io" + level="high" + scale=4 + targetDir="codes" + alt="QR code linking to https://gohugo.io" + class="my-class" + id="my-id" + title="My Title" + />}} + +*/}} + +{{- /* Constants. */}} +{{- $validLevels := slice "low" "medium" "quartile" "high" }} +{{- $minimumScale := 2 }} + +{{- /* Get arguments. */}} +{{- $text := or (.Get "text") (strings.TrimSpace .Inner) "" }} +{{- $level := or (.Get "level") "medium" }} +{{- $scale := or (.Get "scale") 4 }} +{{- $targetDir := or (.Get "targetDir") "" }} +{{- $alt := or (.Get "alt") "" }} +{{- $class := or (.Get "class") "" }} +{{- $id := or (.Get "id") "" }} +{{- $title := or (.Get "title") "" }} + +{{- /* Validate arguments. */}} +{{- $errors := false}} +{{- if not $text }} + {{- errorf "The %q shortcode requires a %q argument. See %s" .Name "text" .Position }} + {{- $errors = true }} +{{- end }} +{{- if not (in $validLevels $level) }} + {{- errorf "The %q argument passed to the %q shortcode must be one of %s. See %s" "level" .Name (delimit $validLevels ", " ", or ") .Position }} + {{- $errors = true }} +{{- end }} +{{- if or (lt $scale $minimumScale) (ne $scale (int $scale)) }} + {{- errorf "The %q argument passed to the %q shortcode must be an integer greater than or equal to %d. See %s" "scale" .Name $minimumScale .Position }} + {{- $errors = true }} +{{- end }} + +{{- /* Render image. */}} +{{- if not $errors }} + {{- $opts := dict "text" $text "level" $level "scale" $scale "targetDir" $targetDir }} + {{- with images.QR $opts -}} + {{ $alt }} + {{- end }} +{{- end -}} diff --git a/tpl/tplimpl/tplimpl_integration_test.go b/tpl/tplimpl/tplimpl_integration_test.go index dbadece4e52..636e6aa688e 100644 --- a/tpl/tplimpl/tplimpl_integration_test.go +++ b/tpl/tplimpl/tplimpl_integration_test.go @@ -698,3 +698,39 @@ Home! b.BuildPartial("/mybundle1/") b.AssertFileContent("public/mybundle1/index.html", "Baseof!!") } + +func TestQRShortcode(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- layouts/index.html -- +{{ .Content }} +-- content/_index.md -- +--- +title: home +--- +{{< qr + text="https://gohugo.io" + level="high" + scale=4 + targetDir="codes" + alt="QR code linking to https://gohugo.io" + class="my-class" + id="my-id" + title="My Title" +/>}} + +{{< qr >}} +https://gohugo.io" +{{< /qr >}} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", + `QR code linking to https://gohugo.io`, + ``, + ) +}