From e52ef3abef43703942e7026c99e8197a9093a202 Mon Sep 17 00:00:00 2001 From: shainaraskas Date: Fri, 22 May 2026 13:01:39 -0400 Subject: [PATCH] Add URL rewrite for markdown docs --- README.md | 5 ++ config/config.go | 11 ++- renderer/markdown.go | 15 +++- renderer/markdown_test.go | 113 +++++++++++++++++++++++++++++++ templates/markdown/type.tpl | 2 +- test/api/v1/guestbook_types.go | 4 +- test/config.yaml | 4 ++ test/expected.asciidoc | 4 +- test/expected.md | 4 +- test/hide.md | 4 +- test/templates/markdown/type.tpl | 2 +- 11 files changed, 155 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 789f795..88ec91d 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,11 @@ 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 and type doc comments to Markdown links. Markdown renderer only. + linkMappings: + - url: https://example.com/old-page + link: docs-content://new/page.md + text: New page ``` ### Advanced Features diff --git a/config/config.go b/config/config.go index b1c792e..9c40f3d 100644 --- a/config/config.go +++ b/config/config.go @@ -52,8 +52,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 { @@ -62,6 +63,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" diff --git a/renderer/markdown.go b/renderer/markdown.go index a270c10..9ded306 100644 --- a/renderer/markdown.go +++ b/renderer/markdown.go @@ -72,6 +72,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, @@ -148,10 +149,22 @@ 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 + } + for _, lm := range m.conf.Render.LinkMappings { + 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, "{", "\\{") diff --git a/renderer/markdown_test.go b/renderer/markdown_test.go index ddd1063..2760e1c 100644 --- a/renderer/markdown_test.go +++ b/renderer/markdown_test.go @@ -35,6 +35,119 @@ 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).", + }, + { + name: "longer URL listed first wins over shorter prefix", + renderer: &MarkdownRenderer{conf: &config.Config{ + Render: config.RenderConfig{ + LinkMappings: []*config.LinkMapping{ + {URL: "https://example.com/old-page", Link: "docs-content://new/page.md", Text: "New page"}, + {URL: "https://example.com/old", Link: "docs-content://other.md", Text: "Other"}, + }, + }, + }}, + text: "See https://example.com/old-page for details.", + want: "See [New page](docs-content://new/page.md) for details.", + }, + { + name: "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_TemplateValue(t *testing.T) { tests := []struct { name string diff --git a/templates/markdown/type.tpl b/templates/markdown/type.tpl index 7d89d04..f8b91a6 100644 --- a/templates/markdown/type.tpl +++ b/templates/markdown/type.tpl @@ -6,7 +6,7 @@ {{ if $type.IsAlias }}_Underlying type:_ _{{ markdownRenderTypeLink $type.UnderlyingType }}_{{ end }} -{{ $type.Doc }} +{{ markdownRewriteLinks $type.Doc }} {{ if $type.Validation -}} _Validation:_ diff --git a/test/api/v1/guestbook_types.go b/test/api/v1/guestbook_types.go index 8eb71bc..d89557c 100644 --- a/test/api/v1/guestbook_types.go +++ b/test/api/v1/guestbook_types.go @@ -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]` diff --git a/test/config.yaml b/test/config.yaml index 2ebede7..4f655e2 100644 --- a/test/config.yaml +++ b/test/config.yaml @@ -18,3 +18,7 @@ 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 + linkMappings: + - url: https://example.com/old-page + link: docs-content://new/page.md + text: New page diff --git a/test/expected.asciidoc b/test/expected.asciidoc index dff065f..c979eaf 100644 --- a/test/expected.asciidoc +++ b/test/expected.asciidoc @@ -148,7 +148,7 @@ Guestbook is the Schema for the guestbooks API. -GuestbookEntry defines an entry in a guest book. +GuestbookEntry defines an entry in a guest book. See https://example.com/old-page for more. @@ -160,7 +160,7 @@ GuestbookEntry defines an entry in a guest book. [cols="20a,50a,15a,15a", options="header"] |=== | Field | Description | Default | Validation -| *`name`* __string__ | Name of the guest (pipe \| should be escaped) + | | MaxLength: 80 + +| *`name`* __string__ | Name of the guest (pipe \| should be escaped). See https://example.com/old-page for naming guidance. + | | MaxLength: 80 + Pattern: `0\*[a-z0-9]*[a-z]*[0-9]` + Required: \{} + diff --git a/test/expected.md b/test/expected.md index 538adf6..0b7de7f 100644 --- a/test/expected.md +++ b/test/expected.md @@ -118,7 +118,7 @@ _Appears in:_ -GuestbookEntry defines an entry in a guest book. +GuestbookEntry defines an entry in a guest book. See [New page](docs-content://new/page.md) for more. @@ -127,7 +127,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `name` _string_ | Name of the guest (pipe \| should be escaped) | | MaxLength: 80
Pattern: `0*[a-z0-9]*[a-z]*[0-9]`
Required: \{\}
| +| `name` _string_ | Name of the guest (pipe \| should be escaped). See [New page](docs-content://new/page.md) for naming guidance. | | MaxLength: 80
Pattern: `0*[a-z0-9]*[a-z]*[0-9]`
Required: \{\}
| | `tags` _string array_ | Tags of the entry. | | items:Pattern: `[a-z]*`
| | `time` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.25/#time-v1-meta)_ | Time of entry | | | | `comment` _string_ | Comment by guest. This can be a multi-line comment.
Like this one.
Now let's test a list:
* a
* b
Another isolated comment.
Looks good? | | Pattern: `0*[a-z0-9]*[a-z]*[0-9]*\|\s`
| diff --git a/test/hide.md b/test/hide.md index d274f96..191fbdf 100644 --- a/test/hide.md +++ b/test/hide.md @@ -119,7 +119,7 @@ _Appears in:_ -GuestbookEntry defines an entry in a guest book. +GuestbookEntry defines an entry in a guest book. See [New page](docs-content://new/page.md) for more. @@ -128,7 +128,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `name` _string_ | Name of the guest (pipe \| should be escaped) | | MaxLength: 80
Pattern: `0*[a-z0-9]*[a-z]*[0-9]`
Required: \{\}
| +| `name` _string_ | Name of the guest (pipe \| should be escaped). See [New page](docs-content://new/page.md) for naming guidance. | | MaxLength: 80
Pattern: `0*[a-z0-9]*[a-z]*[0-9]`
Required: \{\}
| | `tags` _string array_ | Tags of the entry. | | items:Pattern: `[a-z]*`
| | `time` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.25/#time-v1-meta)_ | Time of entry | | | | `comment` _string_ | Comment by guest. This can be a multi-line comment.
Like this one.
Now let's test a list:
* a
* b
Another isolated comment.
Looks good? | | Pattern: `0*[a-z0-9]*[a-z]*[0-9]*\|\s`
| diff --git a/test/templates/markdown/type.tpl b/test/templates/markdown/type.tpl index 1ef5d62..61f39e0 100644 --- a/test/templates/markdown/type.tpl +++ b/test/templates/markdown/type.tpl @@ -6,7 +6,7 @@ {{ if $type.IsAlias }}_Underlying type:_ _{{ markdownRenderTypeLink $type.UnderlyingType }}_{{ end }} -{{ $type.Doc }} +{{ markdownRewriteLinks $type.Doc }} {{ if $type.Validation -}} _Validation:_