Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,21 @@ render:
- name: SecretObjectReference
package: sigs.k8s.io/gateway-api/apis/v1beta1
link: https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1beta1.SecretObjectReference
# Rewrite plain URLs in field, type and group/version doc comments to Markdown links.
# Markdown renderer only. Mappings target bare URLs: do not map a URL that already
# appears inside a Markdown link, as it would produce nested (invalid) Markdown.
linkMappings:
- url: https://example.com/old-page
link: docs-content://new/page.md
text: New page
```

> [!NOTE]
> `linkMappings` are declared inline in the config file. This is convenient for a
> small, fixed set of mappings per repository. If you find yourself maintaining a
> large mapping set, consider whether the URLs should instead be linked directly in
> the source doc comments; external mapping-file support may be added later if needed.

### Advanced Features

#### Custom Markers
Expand Down
19 changes: 17 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package config

import (
"fmt"
"os"

"github.com/goccy/go-yaml"
Expand Down Expand Up @@ -52,8 +53,9 @@ const (
)

type RenderConfig struct {
KnownTypes []*KnownType `json:"knownTypes"`
KubernetesVersion string `json:"kubernetesVersion"`
KnownTypes []*KnownType `json:"knownTypes"`
KubernetesVersion string `json:"kubernetesVersion"`
LinkMappings []*LinkMapping `json:"linkMappings"`
}

type KnownType struct {
Expand All @@ -62,6 +64,12 @@ type KnownType struct {
Link string `json:"link"`
}

type LinkMapping struct {
URL string `json:"url"`
Link string `json:"link"`
Text string `json:"text"`
}

const (
OutputModeSingle = "single"
OutputModeGroup = "group"
Expand Down Expand Up @@ -93,5 +101,12 @@ func Load(flags Flags) (*Config, error) {
}

conf.Flags = flags

for i, lm := range conf.Render.LinkMappings {
if lm.URL == "" || lm.Link == "" || lm.Text == "" {
return nil, fmt.Errorf("render.linkMappings[%d]: url, link, and text are all required", i)
}
}

return &conf, nil
}
91 changes: 91 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you 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 config

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestLoad_LinkMappingsValidation(t *testing.T) {
tests := []struct {
name string
yaml string
wantErr bool
}{
{
name: "valid mapping",
yaml: `render:
linkMappings:
- url: https://example.com/old
link: docs-content://new/page.md
text: New page
`,
},
{
name: "empty url is rejected",
yaml: `render:
linkMappings:
- url: ""
link: docs-content://new/page.md
text: New page
`,
wantErr: true,
},
{
name: "missing link is rejected",
yaml: `render:
linkMappings:
- url: https://example.com/old
text: New page
`,
wantErr: true,
},
{
name: "missing text is rejected",
yaml: `render:
linkMappings:
- url: https://example.com/old
link: docs-content://new/page.md
`,
wantErr: true,
},
{
name: "no mappings is valid",
yaml: `render: {}
`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(tt.yaml), 0o600))

_, err := Load(Flags{Config: path})
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
23 changes: 22 additions & 1 deletion renderer/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"fmt"
"io/fs"
"os"
"slices"
"strings"
"text/template"

Expand Down Expand Up @@ -72,6 +73,7 @@ func (m *MarkdownRenderer) ToFuncMap() template.FuncMap {
"RenderLocalLink": m.RenderLocalLink,
"RenderType": m.RenderType,
"RenderTypeLink": m.RenderTypeLink,
"RewriteLinks": m.RewriteLinks,
"SafeID": m.SafeID,
"ShouldRenderType": m.ShouldRenderType,
"TypeID": m.TypeID,
Expand Down Expand Up @@ -148,10 +150,29 @@ func (m *MarkdownRenderer) RenderGVLink(gv types.GroupVersionDetails) string {
return m.RenderLocalLink(gv.GroupVersionString())
}

func (m *MarkdownRenderer) RewriteLinks(text string) string {
if m == nil || m.conf == nil {
return text
}
// Apply longer URLs first so that when one mapped URL is a prefix of another
// (e.g. ".../old" vs ".../old-page"), the more specific match wins regardless
// of the order mappings are declared in the config.
mappings := slices.Clone(m.conf.Render.LinkMappings)
slices.SortStableFunc(mappings, func(a, b *config.LinkMapping) int {
return len(b.URL) - len(a.URL)
})
for _, lm := range mappings {
text = strings.ReplaceAll(text, lm.URL, m.RenderExternalLink(lm.Link, lm.Text))
}
return text
}

func (m *MarkdownRenderer) RenderFieldDoc(text string) string {
out := m.RewriteLinks(text)

// Escape the pipe character, which has special meaning for Markdown as a way to format tables
// so that including | in a comment does not result in wonky tables.
out := strings.ReplaceAll(text, "|", "\\|")
out = strings.ReplaceAll(out, "|", "\\|")

// Escape the curly bracket character.
out = strings.ReplaceAll(out, "{", "\\{")
Expand Down
141 changes: 141 additions & 0 deletions renderer/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,147 @@ func newTestConfig(t *testing.T) *config.Config {
return conf
}

func TestMarkdownRenderer_RewriteLinks(t *testing.T) {
conf := &config.Config{
Render: config.RenderConfig{
LinkMappings: []*config.LinkMapping{
{
URL: "https://example.com/old",
Link: "docs-content://new/page.md",
Text: "New page",
},
{
URL: "https://example.com/other",
Link: "kibana://reference/other.md",
Text: "Other",
},
},
},
}
r := &MarkdownRenderer{conf: conf}

tests := []struct {
name string
renderer *MarkdownRenderer
text string
want string
}{
{
name: "single substitution",
renderer: r,
text: "See https://example.com/old for details.",
want: "See [New page](docs-content://new/page.md) for details.",
},
{
name: "multiple substitutions",
renderer: r,
text: "See https://example.com/old and https://example.com/other.",
want: "See [New page](docs-content://new/page.md) and [Other](kibana://reference/other.md).",
},
{
name: "no match leaves text unchanged",
renderer: r,
text: "See https://example.com/unmapped for details.",
want: "See https://example.com/unmapped for details.",
},
{
name: "no mappings configured",
renderer: &MarkdownRenderer{conf: &config.Config{}},
text: "See https://example.com/old for details.",
want: "See https://example.com/old for details.",
},
{
name: "nil config",
renderer: &MarkdownRenderer{conf: nil},
text: "See https://example.com/old for details.",
want: "See https://example.com/old for details.",
},
{
name: "nil renderer",
renderer: nil,
text: "See https://example.com/old for details.",
want: "See https://example.com/old for details.",
},
{
name: "multiple occurrences of the same URL are all rewritten",
renderer: r,
text: "See https://example.com/old and again https://example.com/old.",
want: "See [New page](docs-content://new/page.md) and again [New page](docs-content://new/page.md).",
},
{
// The shorter, prefix URL is declared first; the longer/more specific
// URL must still win. RewriteLinks sorts by descending URL length, so
// the result is independent of config order.
name: "longer URL wins over shorter prefix regardless of order",
renderer: &MarkdownRenderer{conf: &config.Config{
Render: config.RenderConfig{
LinkMappings: []*config.LinkMapping{
{URL: "https://example.com/old", Link: "docs-content://other.md", Text: "Other"},
{URL: "https://example.com/old-page", Link: "docs-content://new/page.md", Text: "New page"},
},
},
}},
text: "See https://example.com/old-page for details.",
want: "See [New page](docs-content://new/page.md) for details.",
},
{
// KNOWN LIMITATION: mappings target bare URLs. A URL that already
// appears inside a Markdown link gets rewritten too, producing nested
// (invalid) Markdown. This is acceptable because the feature is meant
// for plain URLs in godoc; do not map a URL that is already linked.
name: "KNOWN LIMITATION: URL inside an existing Markdown link produces nested output",
renderer: r,
text: "See [the page](https://example.com/old) for details.",
want: "See [the page]([New page](docs-content://new/page.md)) for details.",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.renderer.RewriteLinks(tt.text))
})
}
}

func TestMarkdownRenderer_RenderFieldDoc_appliesLinkMappings(t *testing.T) {
conf := &config.Config{
Render: config.RenderConfig{
LinkMappings: []*config.LinkMapping{
{
URL: "https://example.com/old",
Link: "docs-content://new/page.md",
Text: "New page",
},
},
},
}
r := &MarkdownRenderer{conf: conf}

got := r.RenderFieldDoc("See https://example.com/old for details.")
assert.Equal(t, "See [New page](docs-content://new/page.md) for details.", got)
}

func TestMarkdownRenderer_RenderFieldDoc_rewriteThenEscape(t *testing.T) {
conf := &config.Config{
Render: config.RenderConfig{
LinkMappings: []*config.LinkMapping{
{
URL: "https://example.com/old",
Link: "docs-content://new/page.md",
Text: "New page",
},
},
},
}
r := &MarkdownRenderer{conf: conf}

// Link rewriting happens before pipe-escaping: the URL becomes a Markdown
// link and the literal pipe in the surrounding text is still escaped so it
// does not break the Markdown table.
got := r.RenderFieldDoc("See https://example.com/old (a | b) for details.")
assert.Equal(t, "See [New page](docs-content://new/page.md) (a \\| b) for details.", got)
}

func TestMarkdownRenderer_TemplateValue(t *testing.T) {
tests := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion templates/markdown/gv_details.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

## {{ $gv.GroupVersionString }}

{{ $gv.Doc }}
{{ markdownRewriteLinks $gv.Doc }}

{{- if $gv.Kinds }}
### Resource Types
Expand Down
2 changes: 1 addition & 1 deletion templates/markdown/type.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

{{ if $type.IsAlias }}_Underlying type:_ _{{ markdownRenderTypeLink $type.UnderlyingType }}_{{ end }}

{{ $type.Doc }}
{{ markdownRewriteLinks $type.Doc }}

{{ if $type.Validation -}}
_Validation:_
Expand Down
2 changes: 1 addition & 1 deletion test/api/v1/groupversion_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.

// Package v1 contains API Schema definitions for the webapp v1 API group
// Package v1 contains API Schema definitions for the webapp v1 API group. See https://example.com/old-page for more.
// +kubebuilder:object:generate=true
// +groupName=webapp.test.k8s.elastic.co
package v1
Expand Down
4 changes: 2 additions & 2 deletions test/api/v1/guestbook_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ const (
// +kubebuilder:validation:Minimum=1
type PositiveInt int

// GuestbookEntry defines an entry in a guest book.
// GuestbookEntry defines an entry in a guest book. See https://example.com/old-page for more.
type GuestbookEntry struct {
// Name of the guest (pipe | should be escaped)
// Name of the guest (pipe | should be escaped). See https://example.com/old-page for naming guidance.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MaxLength=80
// +kubebuilder:validation:Pattern=`0*[a-z0-9]*[a-z]*[0-9]`
Expand Down
Loading
Loading