markup/highlight: Allow overriding type and code via options

Treat type and code as highlighting options in both transform.Highlight
and transform.HighlightCodeBlock. The type option overrides the language
and code overrides the code, so the two functions now share the same
options handling.

transform.Highlight's LANG argument is now optional:

	transform.Highlight CODE [LANG] [OPTIONS]

Fixes #11872
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bjørn Erik Pedersen
2026-05-21 11:46:47 +02:00
parent 4bc7caea14
commit b6133657e0
5 changed files with 152 additions and 30 deletions
+37 -16
View File
@@ -148,22 +148,6 @@ func (cfg Config) toHTMLOptions() ([]html.Option, error) {
return options, nil
}
func applyOptions(opts any, cfg *Config) error {
if opts == nil {
return nil
}
switch vv := opts.(type) {
case map[string]any:
return applyOptionsFromMap(vv, cfg)
default:
s, err := cast.ToStringE(opts)
if err != nil {
return err
}
return applyOptionsFromString(s, cfg)
}
}
func applyOptionsFromString(opts string, cfg *Config) error {
optsm, err := parseHighlightOptions(opts)
if err != nil {
@@ -177,6 +161,43 @@ func applyOptionsFromMap(optsm map[string]any, cfg *Config) error {
return mapstructure.WeakDecode(optsm, cfg)
}
// applyOptions applies opts (a string or a map) to cfg. The type and code
// options, if set, are not part of Config and instead override lang and code
// respectively. Shared by Highlight and HighlightCodeBlock. See issue 11872.
func applyOptions(opts any, cfg *Config, lang, code *string) error {
if opts == nil {
return nil
}
var optsm map[string]any
switch vv := opts.(type) {
case map[string]any:
optsm = make(map[string]any, len(vv))
for k, v := range vv {
optsm[strings.ToLower(k)] = v
}
default:
s, err := cast.ToStringE(opts)
if err != nil {
return err
}
if optsm, err = parseHighlightOptions(s); err != nil {
return err
}
}
if v, found := optsm["type"]; found {
*lang = cast.ToString(v)
delete(optsm, "type")
}
if v, found := optsm["code"]; found {
*code = cast.ToString(v)
delete(optsm, "code")
}
return applyOptionsFromMap(optsm, cfg)
}
func applyOptionsFromCodeBlockContext(ctx hooks.CodeblockContext, cfg *Config) error {
if cfg.LineAnchors == "" {
const lineAnchorPrefix = "hl-"
+7 -7
View File
@@ -71,7 +71,7 @@ type chromaHighlighter struct {
func (h chromaHighlighter) Highlight(code, lang string, opts any) (string, error) {
cfg := h.cfg
if err := applyOptions(opts, &cfg); err != nil {
if err := applyOptions(opts, &cfg, &lang, &code); err != nil {
return "", err
}
var b strings.Builder
@@ -90,14 +90,14 @@ func (h chromaHighlighter) HighlightCodeBlock(ctx hooks.CodeblockContext, opts a
attributes := ctx.(hooks.AttributesOptionsSliceProvider).AttributesSlice()
options := ctx.Options()
if err := applyOptionsFromMap(options, &cfg); err != nil {
if err := applyOptionsFromMap(ctx.Options(), &cfg); err != nil {
return HighlightResult{}, err
}
// Apply these last so the user can override them.
if err := applyOptions(opts, &cfg); err != nil {
lang, code := ctx.Type(), ctx.Inner()
// Apply these last so the user can override them, including the type and code.
if err := applyOptions(opts, &cfg, &lang, &code); err != nil {
return HighlightResult{}, err
}
@@ -105,7 +105,7 @@ func (h chromaHighlighter) HighlightCodeBlock(ctx hooks.CodeblockContext, opts a
return HighlightResult{}, err
}
low, high, err := highlight(&b, ctx.Inner(), ctx.Type(), attributes, cfg)
low, high, err := highlight(&b, code, lang, attributes, cfg)
if err != nil {
return HighlightResult{}, err
}
@@ -80,6 +80,50 @@ HighlightCodeBlock: Wrapped:{{ $result.Wrapped }}|Inner:{{ $result.Inner }}
)
}
// See issue 11872.
func TestCodeblockWithTypeOverride(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ['home','rss','section','sitemap','taxonomy','term']
[markup.highlight]
noClasses = false # to reduce size of assertion string
-- content/p1.md --
---
title: p1
---
§§§go {style=monokai class=my-class tabWidth=8}
i = 42
§§§
-- content/p2.md --
---
title: p2
---
§§§{style=monokai class=my-class tabWidth=8}
i = 42
§§§
-- layouts/page.html --
{{ .Content }}
-- layouts/_markup/render-codeblock.html --
{{- $opts := dict }}
{{- if not (transform.CanHighlight .Type) }}
{{- $opts = dict "type" "text" }}
{{- end }}
{{- $result := transform.HighlightCodeBlock . $opts }}
{{- $result.Wrapped -}}
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/p1/index.html",
`<div class="highlight my-class"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">i</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="mi">42</span></span></span></code></pre></div>`,
)
b.AssertFileContent("public/p2/index.html",
`<div class="highlight my-class"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">i = 42</span></span></code></pre></div>`,
)
}
// Issue #11311
func TestIssue11311(t *testing.T) {
t.Parallel()
+36 -7
View File
@@ -94,21 +94,50 @@ func (ns *Namespace) Emojify(s any) (template.HTML, error) {
return template.HTML(helpers.Emojify([]byte(ss))), nil
}
// Highlight returns a copy of s as an HTML string with syntax
// Highlight returns a copy of CODE as an HTML string with syntax
// highlighting applied.
func (ns *Namespace) Highlight(s any, lang string, opts ...any) (template.HTML, error) {
ss, err := cast.ToStringE(s)
//
// transform.Highlight CODE [LANG] [OPTIONS]
//
// LANG is optional; it can also be set via the type option in OPTIONS, which
// makes this work the same way as HighlightCodeBlock.
func (ns *Namespace) Highlight(s any, args ...any) (template.HTML, error) {
code, err := cast.ToStringE(s)
if err != nil {
return "", err
}
var optsv any
if len(opts) > 0 {
optsv = opts[0]
var lang string
var opts any
switch len(args) {
case 0:
case 1:
// A single argument is either OPTIONS (a map or option string) or LANG.
if _, ok := args[0].(map[string]any); ok {
opts = args[0]
} else {
var arg string
if arg, err = cast.ToStringE(args[0]); err != nil {
return "", err
}
if strings.Contains(arg, "=") || strings.Contains(arg, ",") {
opts = arg
} else {
lang = arg
}
}
case 2:
if lang, err = cast.ToStringE(args[0]); err != nil {
return "", err
}
opts = args[1]
default:
return "", errors.New("transform.Highlight: expects at most 3 arguments")
}
hl := ns.deps.ContentSpec.Converters.GetHighlighter()
highlighted, err := hl.Highlight(ss, lang, optsv)
highlighted, err := hl.Highlight(code, lang, opts)
if err != nil {
return "", err
}
@@ -103,6 +103,34 @@ disableKinds = ['page','rss','section','sitemap','taxonomy','term']
b.Assert(err.Error(), qt.Contains, "error calling highlight: invalid Highlight option: 0")
}
// transform.Highlight: LANG is optional and may be set via the type option, and
// the code option overrides CODE — consistent with transform.HighlightCodeBlock.
// See issue 11872.
func TestHighlightTypeAndCodeOptions(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ['page','rss','section','sitemap','taxonomy','term']
[markup.highlight]
noClasses = false
-- layouts/home.html --
lang:{{ transform.Highlight "i = 42" "go" }}
type:{{ transform.Highlight "i = 42" (dict "type" "go") }}
code:{{ transform.Highlight "" (dict "type" "go" "code" "i = 42") }}
`
b := hugolib.Test(t, files)
want := `<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">i</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="mi">42</span></span></span></code></pre></div>`
b.AssertFileContent("public/index.html",
"lang:"+want,
"type:"+want,
"code:"+want,
)
}
// Issue #11884
func TestUnmarshalCSVLazyDecoding(t *testing.T) {
t.Parallel()