From e52ef3abef43703942e7026c99e8197a9093a202 Mon Sep 17 00:00:00 2001 From: shainaraskas Date: Fri, 22 May 2026 13:01:39 -0400 Subject: [PATCH 1/7] 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:_ From 4ba89705ea0bbe844caed5f0837e239645414479 Mon Sep 17 00:00:00 2001 From: Thibault Richard Date: Thu, 11 Jun 2026 07:54:33 +0200 Subject: [PATCH 2/7] config: validate linkMappings fields at load time Reject any render.linkMappings entry with an empty url, link, or text. An empty url is especially dangerous: strings.ReplaceAll(text, "", repl) inserts the replacement between every character, silently corrupting all rendered output. Failing fast at config load surfaces the misconfiguration clearly instead of producing garbage docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/config.go | 8 ++++ config/config_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 config/config_test.go diff --git a/config/config.go b/config/config.go index 9c40f3d..cdbe559 100644 --- a/config/config.go +++ b/config/config.go @@ -18,6 +18,7 @@ package config import ( + "fmt" "os" "github.com/goccy/go-yaml" @@ -100,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 } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..b68f3f9 --- /dev/null +++ b/config/config_test.go @@ -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) + } + }) + } +} From 4cdc3c3378086931313af0a9881eb4f40fe5e220 Mon Sep 17 00:00:00 2001 From: Thibault Richard Date: Thu, 11 Jun 2026 07:55:16 +0200 Subject: [PATCH 3/7] renderer: make link rewriting order-independent RewriteLinks used strings.ReplaceAll, so when one mapped URL was a prefix of another (".../old" vs ".../old-page"), correctness depended on the order mappings happened to be declared in the config. Sort a copy of the mappings by descending URL length before applying them, so the more specific URL always wins regardless of config order. Update the prefix test to declare the shorter URL first, proving order-independence. Co-Authored-By: Claude Opus 4.8 (1M context) --- renderer/markdown.go | 10 +++++++++- renderer/markdown_test.go | 7 +++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/renderer/markdown.go b/renderer/markdown.go index 9ded306..aa24f11 100644 --- a/renderer/markdown.go +++ b/renderer/markdown.go @@ -20,6 +20,7 @@ import ( "fmt" "io/fs" "os" + "slices" "strings" "text/template" @@ -153,7 +154,14 @@ func (m *MarkdownRenderer) RewriteLinks(text string) string { if m == nil || m.conf == nil { return text } - for _, lm := range m.conf.Render.LinkMappings { + // 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 diff --git a/renderer/markdown_test.go b/renderer/markdown_test.go index 2760e1c..a6b1ad5 100644 --- a/renderer/markdown_test.go +++ b/renderer/markdown_test.go @@ -103,12 +103,15 @@ func TestMarkdownRenderer_RewriteLinks(t *testing.T) { 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", + // 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-page", Link: "docs-content://new/page.md", Text: "New page"}, {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"}, }, }, }}, From 89d4f36b75a9abf866e2b05ba8fdc26426ae648b Mon Sep 17 00:00:00 2001 From: Thibault Richard Date: Thu, 11 Jun 2026 07:58:22 +0200 Subject: [PATCH 4/7] templates: apply link rewriting to group/version docs Field docs and $type.Doc were rewritten, but group/version package doc comments rendered raw via {{ $gv.Doc }}, so a mapped URL in a GV doc was silently missed. Wrap $gv.Doc with markdownRewriteLinks in the bundled and test markdown templates. Add a mapped URL to the test package comment to exercise this end-to-end and regenerate the golden files (markdown rewrites it, asciidoc leaves it raw as expected). Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/markdown/gv_details.tpl | 2 +- test/api/v1/groupversion_info.go | 2 +- test/expected.asciidoc | 2 +- test/expected.md | 2 +- test/hide.md | 2 +- test/templates/markdown/gv_details.tpl | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/templates/markdown/gv_details.tpl b/templates/markdown/gv_details.tpl index 30ad0d7..caddcbb 100644 --- a/templates/markdown/gv_details.tpl +++ b/templates/markdown/gv_details.tpl @@ -3,7 +3,7 @@ ## {{ $gv.GroupVersionString }} -{{ $gv.Doc }} +{{ markdownRewriteLinks $gv.Doc }} {{- if $gv.Kinds }} ### Resource Types diff --git a/test/api/v1/groupversion_info.go b/test/api/v1/groupversion_info.go index 26c7436..919d835 100644 --- a/test/api/v1/groupversion_info.go +++ b/test/api/v1/groupversion_info.go @@ -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 diff --git a/test/expected.asciidoc b/test/expected.asciidoc index c979eaf..c409249 100644 --- a/test/expected.asciidoc +++ b/test/expected.asciidoc @@ -37,7 +37,7 @@ _Underlying type:_ _string_ [id="{anchor_prefix}-webapp-test-k8s-elastic-co-v1"] === webapp.test.k8s.elastic.co/v1 -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. .Resource Types - xref:{anchor_prefix}-github-com-elastic-crd-ref-docs-api-v1-embedded[$$Embedded$$] diff --git a/test/expected.md b/test/expected.md index 0b7de7f..a1a1042 100644 --- a/test/expected.md +++ b/test/expected.md @@ -28,7 +28,7 @@ _Appears in:_ ## webapp.test.k8s.elastic.co/v1 -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 [New page](docs-content://new/page.md) for more. ### Resource Types - [Embedded](#embedded) diff --git a/test/hide.md b/test/hide.md index 191fbdf..0fe4130 100644 --- a/test/hide.md +++ b/test/hide.md @@ -31,7 +31,7 @@ _Appears in:_ ## webapp.test.k8s.elastic.co/v1 -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 [New page](docs-content://new/page.md) for more. ### Resource Types - [Embedded](#embedded) diff --git a/test/templates/markdown/gv_details.tpl b/test/templates/markdown/gv_details.tpl index 103ae17..c39794c 100644 --- a/test/templates/markdown/gv_details.tpl +++ b/test/templates/markdown/gv_details.tpl @@ -3,7 +3,7 @@ ## {{ $gv.GroupVersionString }} -{{ $gv.Doc }} +{{ markdownRewriteLinks $gv.Doc }} {{- if index $gv.Markers "special" }} *Important: This package is special and should be treated differently.* From 583384e13caed0d38d83d2e867c79c6afd5e6f9c Mon Sep 17 00:00:00 2001 From: Thibault Richard Date: Thu, 11 Jun 2026 07:58:46 +0200 Subject: [PATCH 5/7] docs: flag nested-link rewriting as a known limitation A mapped URL that already appears inside a Markdown link gets rewritten too, producing nested invalid Markdown. This is acceptable for the plain-URL godoc use case. Relabel the test to mark it a known limitation rather than desired behavior, and add a README caveat telling users not to map already-linked URLs. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +++- renderer/markdown_test.go | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 88ec91d..9e2b0ba 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,9 @@ 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. + # 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 diff --git a/renderer/markdown_test.go b/renderer/markdown_test.go index a6b1ad5..52466cf 100644 --- a/renderer/markdown_test.go +++ b/renderer/markdown_test.go @@ -119,7 +119,11 @@ func TestMarkdownRenderer_RewriteLinks(t *testing.T) { want: "See [New page](docs-content://new/page.md) for details.", }, { - name: "URL inside an existing Markdown link produces nested output", + // 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.", From 4c33803c43080cff5673ad58c21a6046822026af Mon Sep 17 00:00:00 2001 From: Thibault Richard Date: Thu, 11 Jun 2026 07:59:04 +0200 Subject: [PATCH 6/7] test: cover pipe escaping after link rewriting in RenderFieldDoc RenderFieldDoc rewrites links before escaping the pipe character. Add a test exercising a field doc that both contains a mappable URL and a literal pipe, asserting the URL becomes a link and the pipe is still escaped. Co-Authored-By: Claude Opus 4.8 (1M context) --- renderer/markdown_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/renderer/markdown_test.go b/renderer/markdown_test.go index 52466cf..8a6a83f 100644 --- a/renderer/markdown_test.go +++ b/renderer/markdown_test.go @@ -155,6 +155,27 @@ func TestMarkdownRenderer_RenderFieldDoc_appliesLinkMappings(t *testing.T) { 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 From 9765669218f28f51408403bce84e674b6c2505b6 Mon Sep 17 00:00:00 2001 From: Thibault Richard Date: Thu, 11 Jun 2026 07:59:20 +0200 Subject: [PATCH 7/7] docs: note inline vs external linkMappings trade-off linkMappings are inline in the config file, which is fine for small per-repo sets but can become unwieldy at scale. Document the trade-off rather than building external mapping-file support now. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 9e2b0ba..40110a1 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ render: 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