From 2396a87bee2d853e8665010fd8e1b09b2260a94f Mon Sep 17 00:00:00 2001 From: Alexandre Dutra Date: Fri, 27 Feb 2026 12:16:41 +0100 Subject: [PATCH] Allow multi-line code snippets in Markdown tables This change enhances the `chart.valueDefaultColumnRenderMd` template which now uses multi-line code snippets with `
` tags.

The default values are rendered with a new template: `chart.valueDefaultColumnRenderInline`. Any line breaks are replaced with `
` since Markdown table cells cannot have line breaks. HTML is properly escaped when default values are surrounded by backticks, e.g. `` `foo` `` is rendered as `
<span>foo</span>
`, but `foo` is rendered as `
foo
` without escaping. This behavior matches the existing behavior for Markdown tables. The "tpl" notation type is also supported. The motivation for this change is that Markdown tables are generally better rendered by websites like Hugo than HTML tables, but single-line snippets often make the "Default" column too wide and the overall table layout becomes unbalanced. --- README.md | 14 ++--- pkg/document/template.go | 19 +++++- pkg/document/template_test.go | 113 ++++++++++++++++++++++++++++++++++ pkg/util/funcs.go | 21 +++++++ pkg/util/funcs_test.go | 43 +++++++++++++ 5 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 pkg/util/funcs_test.go diff --git a/README.md b/README.md index dad532ad..91b5de43 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,13 @@ Resulting in a resulting README section like so: | Key | Type | Default | Description | |-----|------|---------|-------------| -| config.databasesToCreate[0] | string | `"postgresql"` | default database for storage of database metadata | -| config.databasesToCreate[1] | string | `"hashbash"` | database for the [hashbash](https://github.com/norwoodj/hashbash-backend-go) project | -| config.usersToCreate[0] | object | `{"admin":true,"name":"root"}` | admin user | -| config.usersToCreate[1] | object | `{"name":"hashbash","readwriteDatabases":["hashbash"]}` | user with access to the database with the same name | -| statefulset.extraVolumes | list | `[{"emptyDir":{},"name":"data"}]` | Additional volumes to be mounted into the database container | -| statefulset.image.repository | string | `"jnorwood/postgresql:11"` | Image to use for deploying, must support an entrypoint which creates users/databases from appropriate config files | -| statefulset.image.tag | string | `"18.0831"` | | +| config.databasesToCreate[0] | string |
"postgres"
| default database for storage of database metadata | +| config.databasesToCreate[1] | string |
"hashbash"
| database for the [hashbash](https://github.com/norwoodj/hashbash-backend-go) project | +| config.usersToCreate[0] | object |
{
"admin": true,
"name": "root"
}
| admin user | +| config.usersToCreate[1] | object |
{
"name": "hashbash",
"readwriteDatabases": [
"hashbash"
]
}
| user with access to the database with the same name | +| statefulset.extraVolumes | list |
[
{
"emptyDir": {},
"name": "data"
}
]
| Additional volumes to be mounted into the database container | +| statefulset.image.repository | string |
"jnorwood/postgresql"
| Image to use for deploying, must support an entrypoint which creates users/databases from appropriate config files | +| statefulset.image.tag | string |
"11"
| | You'll notice that some complex fields (lists and objects) are documented while others aren't, and that some simple fields like `statefulset.image.tag` are documented even without a description comment. The rules for what is and isn't documented in diff --git a/pkg/document/template.go b/pkg/document/template.go index d3551485..625e6fc7 100644 --- a/pkg/document/template.go +++ b/pkg/document/template.go @@ -216,9 +216,22 @@ func getValuesTableTemplates() string { valuesSectionBuilder.WriteString("{{ .Type }}") valuesSectionBuilder.WriteString("{{ end }}") - valuesSectionBuilder.WriteString(`{{ define "chart.valueDefaultColumnRenderMd" }}`) - valuesSectionBuilder.WriteString("{{ if .Default }}{{ .Default }}{{ else }}{{ .AutoDefault }}{{ end }}") - valuesSectionBuilder.WriteString("{{ end }}") + valuesSectionBuilder.WriteString(` +{{ define "chart.valueDefaultColumnRenderMd" }} +{{- $defaultValue := (default .Default .AutoDefault) -}} +{{- $notationType := .NotationType }} +{{- if (and (hasPrefix "` + "`" + `" $defaultValue) (hasSuffix "` + "`" + `" $defaultValue) ) -}} +{{- $defaultValue = htmlEscape (toPrettyJson (fromJson (trimAll "` + "`" + `" $defaultValue ) ) ) -}} +{{- $notationType = "json" }} +{{- end -}} + +{{- if (eq $notationType "tpl" ) }} +{{- .Key }}: |
{{ $defaultValue | replace "\n" "
" }} +{{- else }} +{{- $defaultValue | replace "\n" "
" }} +{{- end -}} +
+{{- end }}`) valuesSectionBuilder.WriteString(`{{ define "chart.valueDescriptionColumnRenderMd" }}`) valuesSectionBuilder.WriteString("{{ if .Description }}{{ .Description }}{{ else }}{{ .AutoDescription }}{{ end }}") diff --git a/pkg/document/template_test.go b/pkg/document/template_test.go index b27b7264..1c17d8d9 100644 --- a/pkg/document/template_test.go +++ b/pkg/document/template_test.go @@ -1,8 +1,11 @@ package document import ( + "bytes" "testing" + "text/template" + "github.com/norwoodj/helm-docs/pkg/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -26,3 +29,113 @@ func TestGetDocumentationTemplate_LoadDefaultOnNotFound(t *testing.T) { require.NoError(t, err) assert.Equal(t, expected, tpl) } + +func renderTemplate(t *testing.T, templateName string, templateBody string, data interface{}) string { + t.Helper() + + tpl, err := template.New("values").Funcs(util.FuncMap()).Parse(templateBody) + require.NoError(t, err) + + var buf bytes.Buffer + require.NoError(t, tpl.ExecuteTemplate(&buf, templateName, data)) + + return buf.String() +} + +func TestValuesTable_DefaultValue(t *testing.T) { + tests := []struct { + name string + def string + notationType string + want string + }{ + { + name: "string", + def: "`\"bar\"`", + want: "
"bar"
", + }, + { + name: "int", + def: "`42`", + want: "
42
", + }, + { + name: "float", + def: "`3.14`", + want: "
3.14
", + }, + { + name: "bool", + def: "`true`", + want: "
true
", + }, + { + name: "object", + def: "`{\"admin\":true,\"name\":\"root\"}`", + want: "
{
"admin": true,
"name": "root"
}
", + }, + { + name: "list", + def: "`[\"a\",\"b\",\"c\"]`", + want: "
[
"a",
"b",
"c"
]
", + }, + { + name: "html escape", + def: "`\"This HTML tag should be escaped\"`", + want: "
"This <span>HTML tag</span> should be escaped"
", + }, + { + name: "unicode", + def: "`\"\\u003chtml\\u003e\\u003c/html\\u003e\"`", + want: "
"<html></html>"
", + }, + { + name: "custom", + def: "This is a custom default value with\nan HTML tag that should not be escaped", + notationType: "custom", + want: "
This is a custom default value with
an HTML tag that should not be escaped
", + }, + { + name: "tpl", + def: "- name: DEBUG\n value: {{ .Values.global.debug | quote }}", + notationType: "tpl", + want: "
some.key: |
- name: DEBUG
value: {{ .Values.global.debug | quote }}
", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rows := []valueRow{{ + Key: "some.key", + Default: tt.def, + NotationType: tt.notationType, + }} + data := chartTemplateData{ + Values: rows, + Sections: sections{}, + } + out := renderTemplate(t, "chart.valuesTable", getValuesTableTemplates(), data) + assert.Contains(t, out, tt.want) + }) + t.Run(tt.name+" with sections", func(t *testing.T) { + rows := []valueRow{{ + Key: "some.key", + Default: tt.def, + NotationType: tt.notationType, + Section: "Some Section", + }} + data := chartTemplateData{ + Sections: sections{ + Sections: []section{ + { + SectionName: "Some Section", + SectionItems: rows, + }, + }, + }, + } + out := renderTemplate(t, "chart.valuesTable", getValuesTableTemplates(), data) + assert.Contains(t, out, tt.want) + }) + } +} diff --git a/pkg/util/funcs.go b/pkg/util/funcs.go index ab2d6285..19353192 100644 --- a/pkg/util/funcs.go +++ b/pkg/util/funcs.go @@ -1,6 +1,7 @@ package util import ( + "html" "strings" "text/template" @@ -12,6 +13,7 @@ func FuncMap() template.FuncMap { f := sprig.TxtFuncMap() f["toYaml"] = toYAML f["fromYaml"] = fromYAML + f["htmlEscape"] = htmlEscape return f } @@ -42,3 +44,22 @@ func fromYAML(str string) map[string]interface{} { } return m } + +// htmlEscape escapes special HTML characters in a string to their HTML entity equivalents. +// It also converts Unicode escape sequences (\u003c, \u003e, \u0026) produced by Go's json.Marshal +// to their HTML entity equivalents (<, >, &). +// +// This is necessary because Sprig's toPrettyJson function uses json.MarshalIndent without +// SetEscapeHTML(false), which means it escapes <, >, and & to Unicode sequences. +// We want proper HTML entities instead for better readability in markdown/HTML output. +// +// This is designed to be called from a template. +func htmlEscape(s string) string { + // First, replace Unicode escape sequences with actual characters + s = strings.ReplaceAll(s, `\u003c`, "<") + s = strings.ReplaceAll(s, `\u003e`, ">") + s = strings.ReplaceAll(s, `\u0026`, "&") + + // Then apply HTML escaping to convert them to HTML entities + return html.EscapeString(s) +} diff --git a/pkg/util/funcs_test.go b/pkg/util/funcs_test.go new file mode 100644 index 00000000..76a3c786 --- /dev/null +++ b/pkg/util/funcs_test.go @@ -0,0 +1,43 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHtmlEscape(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "html", + input: `
Hello & goodbye
`, + expected: `<div>Hello & goodbye</div>`, + }, + { + name: "unicode", + input: `\u003cdiv\u003eHello \u0026 goodbye\u003c/div\u003e`, + expected: `<div>Hello & goodbye</div>`, + }, + { + name: "no escaping needed", + input: `hello world`, + expected: `hello world`, + }, + { + name: "empty string", + input: ``, + expected: ``, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := htmlEscape(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +}