Skip to content

Commit 294cad5

Browse files
bepclaude
andcommitted
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>
1 parent 67aede4 commit 294cad5

7 files changed

Lines changed: 167 additions & 32 deletions

File tree

docs/content/en/functions/transform/Highlight.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,22 @@ params:
77
functions_and_methods:
88
aliases: [highlight]
99
returnType: template.HTML
10-
signatures: ['transform.Highlight CODE LANG [OPTIONS]']
10+
signatures: ['transform.Highlight CODE [LANG] [OPTIONS]']
1111
aliases: [/functions/highlight]
1212
---
1313

1414
The `transform.Highlight` function uses the [`alecthomas/chroma`][] package to generate syntax-highlighted HTML from the provided code, [language][], and [options][].
1515

1616
## Arguments
1717

18-
The `transform.Highlight` function takes three arguments.
19-
2018
CODE
2119
: (`string`) The code to highlight.
2220

2321
LANG
24-
: (`string`) The [language][] of the code to highlight. This value is case-insensitive.
22+
: (`string`) The [language][] of the code to highlight. This value is case-insensitive. Optional; you can also set the language with the `type` key in OPTIONS. {{< new-in "0.162.0" />}}
2523

2624
OPTIONS
27-
: (`map or string`) A map or comma-separated key-value pairs wrapped in quotation marks. You can set default values for each option in your [project configuration][]. The key names are case-insensitive.
25+
: (`map or string`) A map or comma-separated key-value pairs wrapped in quotation marks. You can set default values for each option in your [project configuration][]. The key names are case-insensitive. In addition to the [highlighting options](#options-1), the `type` and `code` keys override the `LANG` and `CODE` arguments respectively. {{< new-in "0.162.0" />}}
2826

2927
## Examples
3028

@@ -40,6 +38,10 @@ OPTIONS
4038
{{ $lang := "bash" }}
4139
{{ $opts := dict "lineNos" "table" "style" "dracula" }}
4240
{{ transform.Highlight $input $lang $opts }}
41+
42+
{{ $input := `print("Hello World!")` }}
43+
{{ $opts := dict "type" "python" "style" "dracula" }}
44+
{{ transform.Highlight $input $opts }}
4345
```
4446

4547
## Options

docs/content/en/functions/transform/HighlightCodeBlock.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,15 @@ To override the default [highlighting options]:
3333
{{ $result.Wrapped }}
3434
```
3535

36+
The `type` and `code` keys are special: they override the language and the code received from the code block respectively. {{< new-in "0.162.0" />}} For example, to fall back to plain text when the language is not supported by the highlighter:
37+
38+
```go-html-template
39+
{{ $opts := dict }}
40+
{{ if not (transform.CanHighlight .Type) }}
41+
{{ $opts = dict "type" "text" }}
42+
{{ end }}
43+
{{ $result := transform.HighlightCodeBlock . $opts }}
44+
{{ $result.Wrapped }}
45+
```
46+
3647
[highlighting options]: /functions/transform/highlight/#options

markup/highlight/config.go

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -148,33 +148,54 @@ func (cfg Config) toHTMLOptions() ([]html.Option, error) {
148148
return options, nil
149149
}
150150

151-
func applyOptions(opts any, cfg *Config) error {
151+
func applyOptionsFromString(opts string, cfg *Config) error {
152+
optsm, err := parseHighlightOptions(opts)
153+
if err != nil {
154+
return err
155+
}
156+
return mapstructure.WeakDecode(optsm, cfg)
157+
}
158+
159+
func applyOptionsFromMap(optsm map[string]any, cfg *Config) error {
160+
normalizeHighlightOptions(optsm)
161+
return mapstructure.WeakDecode(optsm, cfg)
162+
}
163+
164+
// applyOptions applies opts (a string or a map) to cfg. The type and code
165+
// options, if set, are not part of Config and instead override lang and code
166+
// respectively. Shared by Highlight and HighlightCodeBlock. See issue 11872.
167+
func applyOptions(opts any, cfg *Config, lang, code *string) error {
152168
if opts == nil {
153169
return nil
154170
}
171+
172+
var optsm map[string]any
155173
switch vv := opts.(type) {
156174
case map[string]any:
157-
return applyOptionsFromMap(vv, cfg)
175+
optsm = make(map[string]any, len(vv))
176+
for k, v := range vv {
177+
optsm[strings.ToLower(k)] = v
178+
}
158179
default:
159180
s, err := cast.ToStringE(opts)
160181
if err != nil {
161182
return err
162183
}
163-
return applyOptionsFromString(s, cfg)
184+
if optsm, err = parseHighlightOptions(s); err != nil {
185+
return err
186+
}
164187
}
165-
}
166188

167-
func applyOptionsFromString(opts string, cfg *Config) error {
168-
optsm, err := parseHighlightOptions(opts)
169-
if err != nil {
170-
return err
189+
if v, found := optsm["type"]; found {
190+
*lang = cast.ToString(v)
191+
delete(optsm, "type")
192+
}
193+
if v, found := optsm["code"]; found {
194+
*code = cast.ToString(v)
195+
delete(optsm, "code")
171196
}
172-
return mapstructure.WeakDecode(optsm, cfg)
173-
}
174197

175-
func applyOptionsFromMap(optsm map[string]any, cfg *Config) error {
176-
normalizeHighlightOptions(optsm)
177-
return mapstructure.WeakDecode(optsm, cfg)
198+
return applyOptionsFromMap(optsm, cfg)
178199
}
179200

180201
func applyOptionsFromCodeBlockContext(ctx hooks.CodeblockContext, cfg *Config) error {

markup/highlight/highlight.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ type chromaHighlighter struct {
7171

7272
func (h chromaHighlighter) Highlight(code, lang string, opts any) (string, error) {
7373
cfg := h.cfg
74-
if err := applyOptions(opts, &cfg); err != nil {
74+
if err := applyOptions(opts, &cfg, &lang, &code); err != nil {
7575
return "", err
7676
}
7777
var b strings.Builder
@@ -90,22 +90,22 @@ func (h chromaHighlighter) HighlightCodeBlock(ctx hooks.CodeblockContext, opts a
9090

9191
attributes := ctx.(hooks.AttributesOptionsSliceProvider).AttributesSlice()
9292

93-
options := ctx.Options()
94-
95-
if err := applyOptionsFromMap(options, &cfg); err != nil {
93+
if err := applyOptionsFromMap(ctx.Options(), &cfg); err != nil {
9694
return HighlightResult{}, err
9795
}
9896

99-
// Apply these last so the user can override them.
100-
if err := applyOptions(opts, &cfg); err != nil {
97+
lang, code := ctx.Type(), ctx.Inner()
98+
99+
// Apply these last so the user can override them, including the type and code.
100+
if err := applyOptions(opts, &cfg, &lang, &code); err != nil {
101101
return HighlightResult{}, err
102102
}
103103

104104
if err := applyOptionsFromCodeBlockContext(ctx, &cfg); err != nil {
105105
return HighlightResult{}, err
106106
}
107107

108-
low, high, err := highlight(&b, ctx.Inner(), ctx.Type(), attributes, cfg)
108+
low, high, err := highlight(&b, code, lang, attributes, cfg)
109109
if err != nil {
110110
return HighlightResult{}, err
111111
}

markup/highlight/highlight_integration_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,50 @@ HighlightCodeBlock: Wrapped:{{ $result.Wrapped }}|Inner:{{ $result.Inner }}
8080
)
8181
}
8282

83+
// See issue 11872.
84+
func TestCodeblockWithTypeOverride(t *testing.T) {
85+
t.Parallel()
86+
87+
files := `
88+
-- hugo.toml --
89+
disableKinds = ['home','rss','section','sitemap','taxonomy','term']
90+
[markup.highlight]
91+
noClasses = false # to reduce size of assertion string
92+
-- content/p1.md --
93+
---
94+
title: p1
95+
---
96+
§§§go {style=monokai class=my-class tabWidth=8}
97+
i = 42
98+
§§§
99+
-- content/p2.md --
100+
---
101+
title: p2
102+
---
103+
§§§{style=monokai class=my-class tabWidth=8}
104+
i = 42
105+
§§§
106+
-- layouts/page.html --
107+
{{ .Content }}
108+
-- layouts/_markup/render-codeblock.html --
109+
{{- $opts := dict }}
110+
{{- if not (transform.CanHighlight .Type) }}
111+
{{- $opts = dict "type" "text" }}
112+
{{- end }}
113+
{{- $result := transform.HighlightCodeBlock . $opts }}
114+
{{- $result.Wrapped -}}
115+
`
116+
117+
b := hugolib.Test(t, files)
118+
119+
b.AssertFileContent("public/p1/index.html",
120+
`<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>`,
121+
)
122+
b.AssertFileContent("public/p2/index.html",
123+
`<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>`,
124+
)
125+
}
126+
83127
// Issue #11311
84128
func TestIssue11311(t *testing.T) {
85129
t.Parallel()

tpl/transform/transform.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,50 @@ func (ns *Namespace) Emojify(s any) (template.HTML, error) {
9494
return template.HTML(helpers.Emojify([]byte(ss))), nil
9595
}
9696

97-
// Highlight returns a copy of s as an HTML string with syntax
97+
// Highlight returns a copy of CODE as an HTML string with syntax
9898
// highlighting applied.
99-
func (ns *Namespace) Highlight(s any, lang string, opts ...any) (template.HTML, error) {
100-
ss, err := cast.ToStringE(s)
99+
//
100+
// transform.Highlight CODE [LANG] [OPTIONS]
101+
//
102+
// LANG is optional; it can also be set via the type option in OPTIONS, which
103+
// makes this work the same way as HighlightCodeBlock.
104+
func (ns *Namespace) Highlight(s any, args ...any) (template.HTML, error) {
105+
code, err := cast.ToStringE(s)
101106
if err != nil {
102107
return "", err
103108
}
104109

105-
var optsv any
106-
if len(opts) > 0 {
107-
optsv = opts[0]
110+
var lang string
111+
var opts any
112+
113+
switch len(args) {
114+
case 0:
115+
case 1:
116+
// A single argument is either OPTIONS (a map or option string) or LANG.
117+
if _, ok := args[0].(map[string]any); ok {
118+
opts = args[0]
119+
} else {
120+
var arg string
121+
if arg, err = cast.ToStringE(args[0]); err != nil {
122+
return "", err
123+
}
124+
if strings.Contains(arg, "=") || strings.Contains(arg, ",") {
125+
opts = arg
126+
} else {
127+
lang = arg
128+
}
129+
}
130+
case 2:
131+
if lang, err = cast.ToStringE(args[0]); err != nil {
132+
return "", err
133+
}
134+
opts = args[1]
135+
default:
136+
return "", errors.New("transform.Highlight: expects at most 3 arguments")
108137
}
109138

110139
hl := ns.deps.ContentSpec.Converters.GetHighlighter()
111-
highlighted, err := hl.Highlight(ss, lang, optsv)
140+
highlighted, err := hl.Highlight(code, lang, opts)
112141
if err != nil {
113142
return "", err
114143
}

tpl/transform/transform_integration_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,34 @@ disableKinds = ['page','rss','section','sitemap','taxonomy','term']
103103
b.Assert(err.Error(), qt.Contains, "error calling highlight: invalid Highlight option: 0")
104104
}
105105

106+
// transform.Highlight: LANG is optional and may be set via the type option, and
107+
// the code option overrides CODE — consistent with transform.HighlightCodeBlock.
108+
// See issue 11872.
109+
func TestHighlightTypeAndCodeOptions(t *testing.T) {
110+
t.Parallel()
111+
112+
files := `
113+
-- hugo.toml --
114+
disableKinds = ['page','rss','section','sitemap','taxonomy','term']
115+
[markup.highlight]
116+
noClasses = false
117+
-- layouts/home.html --
118+
lang:{{ transform.Highlight "i = 42" "go" }}
119+
type:{{ transform.Highlight "i = 42" (dict "type" "go") }}
120+
code:{{ transform.Highlight "" (dict "type" "go" "code" "i = 42") }}
121+
`
122+
123+
b := hugolib.Test(t, files)
124+
125+
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>`
126+
127+
b.AssertFileContent("public/index.html",
128+
"lang:"+want,
129+
"type:"+want,
130+
"code:"+want,
131+
)
132+
}
133+
106134
// Issue #11884
107135
func TestUnmarshalCSVLazyDecoding(t *testing.T) {
108136
t.Parallel()

0 commit comments

Comments
 (0)