Skip to content
Draft
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
30 changes: 30 additions & 0 deletions gno.land/adr/prxxxx_heading_anchor_links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# ADR: Heading Anchor Links in gnoweb

## Status

Accepted

## Context

Issue [#5579](https://github.com/gnolang/gno/issues/5579): In rendered realm/readme markdown, headings get auto-generated IDs via `parser.WithAutoHeadingID()` but the heading text itself is not a link. Clicking a `<h1>`/`<h2>`/etc. does nothing — the URL hash doesn't update, and there's no way to copy a stable link to a section without going through the ToC sidebar.

## Decision

Add a custom `headingRenderer` in the GnoExtension that overrides goldmark's default heading renderer. On the closing pass (after child content is rendered), append an empty `<a class="heading-anchor" href="#id" aria-hidden="true"></a>` element. CSS shows a `§` symbol on hover via `::after`, providing a clickable self-link.

This approach avoids nesting `<a>` tags (invalid HTML), which would occur if we wrapped the heading content in an anchor — headings can contain links from the GnoLink extension.

The `aria-hidden="true"` attribute prevents screen readers from announcing the anchor, while the link remains clickable for sighted users.

## Alternatives Considered

1. **Wrap heading content in `<a href="#id">`**: Would nest `<a>` tags when headings contain links — invalid HTML per spec.
2. **goldmark-anchor extension**: External dependency; our custom renderer is minimal (same pattern as existing GnoExtension renderers) and keeps the dependency tree unchanged.
3. **JavaScript-only approach**: Could update `window.location.hash` on click, but doesn't provide a shareable link for copy-paste without additional URL manipulation.
4. **Always-visible anchor icon**: Would add visual noise; hover-to-show is the established pattern (GitHub, MDN, Rust docs).

## Consequences

- Headings with auto-generated IDs now have a clickable anchor link that updates the URL hash.
- The golden test suite needed `parser.WithAutoHeadingID()` added to match production config, updating existing golden outputs to include `id` attributes.
- No new external dependencies.
33 changes: 33 additions & 0 deletions gno.land/pkg/gnoweb/frontend/css/05-composition.css
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,23 @@
line-height: var(--g-line-height-tight);
}

/* Heading anchor links */
.heading-anchor {
color: inherit;
text-decoration: none;
}

:is(h1, h2, h3, h4):hover .heading-anchor::after {
color: var(--s-color-text-tertiary);
font-weight: var(--g-font-regular);
}

:is(h1, h2, h3, h4) .heading-anchor:focus-visible {
outline: var(--g-space-px) solid var(--s-color-border-brand);
outline-offset: var(--g-space-1);
border-radius: var(--g-border-radius-sm);
}

/* H1 - Main heading */
h1 {
font-size: var(--g-font-size-700);
Expand Down Expand Up @@ -633,6 +650,22 @@
text-decoration: underline;
}

/* Heading anchors inherit heading color, no underline */
& .heading-anchor {
color: inherit;
text-decoration: none;
}
& :is(h1, h2, h3, h4):hover .heading-anchor::after {
content: " §";
color: var(--s-color-text-tertiary);
font-weight: var(--g-font-regular);
}
& :is(h1, h2, h3, h4) .heading-anchor:focus-visible {
outline: var(--g-space-px) solid var(--s-color-border-brand);
outline-offset: var(--g-space-1);
border-radius: var(--g-border-radius-sm);
}

/* Inline code */
& :not(pre) > code {
font-family: var(--g-font-family-mono);
Expand Down
3 changes: 3 additions & 0 deletions gno.land/pkg/gnoweb/markdown/ext.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ func (e *GnoExtension) Extend(m goldmark.Markdown) {
// Add mentions extension
ExtMention.Extend(m)

// Add heading anchor extension
extHeading.Extend(m)

// If set, setup images filter
if e.cfg.imgValidatorFunc != nil {
ExtImageValidator.Extend(m, e.cfg.imgValidatorFunc)
Expand Down
67 changes: 67 additions & 0 deletions gno.land/pkg/gnoweb/markdown/ext_heading.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package markdown

import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)

type headingRenderer struct {
html.Config
}

var _ renderer.NodeRenderer = (*headingRenderer)(nil)

func newHeadingRenderer(opts ...html.Option) renderer.NodeRenderer {
r := &headingRenderer{
Config: html.NewConfig(),
}
for _, opt := range opts {
opt.SetHTMLOption(&r.Config)
}
return r
}

func (r *headingRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindHeading, r.renderHeading)
}

func (r *headingRenderer) renderHeading(
w util.BufWriter, source []byte, node ast.Node, entering bool,
) (ast.WalkStatus, error) {
n := node.(*ast.Heading)
if entering {
_, _ = w.WriteString("<h")
_ = w.WriteByte("0123456"[n.Level])
if n.Attributes() != nil {
html.RenderAttributes(w, node, html.HeadingAttributeFilter)
}
_ = w.WriteByte('>')
id, hasID := n.AttributeString("id")
if hasID {
if idBytes, ok := id.([]byte); ok && len(idBytes) > 0 {
_, _ = w.WriteString(`<a class="heading-anchor" href="#`)
_, _ = w.Write(util.EscapeHTML(idBytes))
_, _ = w.WriteString(`" aria-label="Link to this section">`)
}
}
} else {
_, _ = w.WriteString(`</a>`)
_, _ = w.WriteString("</h")
_ = w.WriteByte("0123456"[n.Level])
_, _ = w.WriteString(">\n")
}
return ast.WalkContinue, nil
}

type headingExtension struct{}

var extHeading = &headingExtension{}

func (e *headingExtension) Extend(m goldmark.Markdown) {
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(newHeadingRenderer(), 1),
))
}
2 changes: 1 addition & 1 deletion gno.land/pkg/gnoweb/markdown/ext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func testGoldmarkOutput(t *testing.T, nameIn string, input []byte) (string, []by
}))

// Create markdown processor with extensions and renderer options
m := goldmark.New()
m := goldmark.New(goldmark.WithParserOptions(parser.WithAutoHeadingID()))
ext.Extend(m)

// Parse markdown input with context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
</gno-columns>

-- output.html --
<h3>Invalid lost closing tag</h3>
<h3 id="invalid-lost-closing-tag"><a class="heading-anchor" href="#invalid-lost-closing-tag" aria-label="Link to this section">Invalid lost closing tag</a></h3>
<!-- unexpected/invalid columns tag omitted -->
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<gno-columns-sep />

-- output.html --
<h2>shorthand lost separator</h2>
<h2 id="shorthand-lost-separator"><a class="heading-anchor" href="#shorthand-lost-separator" aria-label="Link to this section">shorthand lost separator</a></h2>
<p>|||</p>
<h2>html tag log separator</h2>
<h2 id="html-tag-log-separator"><a class="heading-anchor" href="#html-tag-log-separator" aria-label="Link to this section">html tag log separator</a></h2>
<!-- unexpected/invalid columns tag omitted -->
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
<div class="gno-columns">
<!-- Column 0 -->
<div class="gno-column">
<h3>Nested open <!-- raw HTML omitted --> should be ignored</h3>
<h3 id="nested-open-gno-columns-should-be-ignored"><a class="heading-anchor" href="#nested-open-gno-columns-should-be-ignored" aria-label="Link to this section">Nested open <!-- raw HTML omitted --> should be ignored</a></h3>
<!-- unexpected/invalid columns tag omitted -->
<h3>The first closing tag should be kept as it is</h3>
<h3 id="the-first-closing-tag-should-be-kept-as-it-is"><a class="heading-anchor" href="#the-first-closing-tag-should-be-kept-as-it-is" aria-label="Link to this section">The first closing tag should be kept as it is</a></h3>
</div>
</div> <!-- </gno-columns> -->
<h3>The next closing tag should be considered as lost</h3>
<h3 id="the-next-closing-tag-should-be-considered-as-lost"><a class="heading-anchor" href="#the-next-closing-tag-should-be-considered-as-lost" aria-label="Link to this section">The next closing tag should be considered as lost</a></h3>
<!-- unexpected/invalid columns tag omitted -->
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ content 3
</gno-columns>

-- output.html --
<h3>3 closings open tags with a single close tag</h3>
<h3 id="3-closings-open-tags-with-a-single-close-tag"><a class="heading-anchor" href="#3-closings-open-tags-with-a-single-close-tag" aria-label="Link to this section">3 closings open tags with a single close tag</a></h3>
<div class="gno-columns">
<!-- Column 0 -->
<div class="gno-column">
<h1>Title 1</h1>
<h1 id="title-1"><a class="heading-anchor" href="#title-1" aria-label="Link to this section">Title 1</a></h1>
<p>content 1</p>
<!-- unexpected/invalid columns tag omitted -->
<h1>Title 2</h1>
<h1 id="title-2"><a class="heading-anchor" href="#title-2" aria-label="Link to this section">Title 2</a></h1>
<p>content 2</p>
<!-- unexpected/invalid columns tag omitted -->
<h1>Title 3</h1>
<h1 id="title-3"><a class="heading-anchor" href="#title-3" aria-label="Link to this section">Title 3</a></h1>
<p>content 3</p>
</div>
</div> <!-- </gno-columns> -->
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ content 3
<div class="gno-columns">
<!-- Column 0 -->
<div class="gno-column">
<h2>Title 1</h2>
<h2 id="title-1"><a class="heading-anchor" href="#title-1" aria-label="Link to this section">Title 1</a></h2>
<p>content 1</p>
</div>
<!-- Column 1 -->
<div class="gno-column">
<h2>Title 2</h2>
<h2 id="title-2"><a class="heading-anchor" href="#title-2" aria-label="Link to this section">Title 2</a></h2>
<p>content 2</p>
</div>
<!-- Column 2 -->
<div class="gno-column">
<h2>Title 3</h2>
<h2 id="title-3"><a class="heading-anchor" href="#title-3" aria-label="Link to this section">Title 3</a></h2>
<p>content 3</p>
</div>
</div> <!-- </gno-columns> -->
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@


-- output.html --
<h3>no columns</h3>
<h3 id="no-columns"><a class="heading-anchor" href="#no-columns" aria-label="Link to this section">no columns</a></h3>
<div class="gno-columns">
</div> <!-- </gno-columns> -->
<h3>no columns with space</h3>
<h3 id="no-columns-with-space"><a class="heading-anchor" href="#no-columns-with-space" aria-label="Link to this section">no columns with space</a></h3>
<div class="gno-columns">
</div> <!-- </gno-columns> -->
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,26 @@ content 4
</gno-columns>

-- output.html --
<h3>Simple example</h3>
<h3 id="simple-example"><a class="heading-anchor" href="#simple-example" aria-label="Link to this section">Simple example</a></h3>
<div class="gno-columns">
<!-- Column 0 -->
<div class="gno-column">
<h2>Title 1</h2>
<h2 id="title-1"><a class="heading-anchor" href="#title-1" aria-label="Link to this section">Title 1</a></h2>
<p>content 1</p>
</div>
<!-- Column 1 -->
<div class="gno-column">
<h2>Title 2</h2>
<h2 id="title-2"><a class="heading-anchor" href="#title-2" aria-label="Link to this section">Title 2</a></h2>
<p>content 2</p>
</div>
<!-- Column 2 -->
<div class="gno-column">
<h2>Title 3</h2>
<h2 id="title-3"><a class="heading-anchor" href="#title-3" aria-label="Link to this section">Title 3</a></h2>
<p>content 3</p>
</div>
<!-- Column 3 -->
<div class="gno-column">
<h2>Title 4</h2>
<h2 id="title-4"><a class="heading-anchor" href="#title-4" aria-label="Link to this section">Title 4</a></h2>
<p>content 4</p>
</div>
</div> <!-- </gno-columns> -->
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ normal content
<div class="gno-columns">
<!-- Column 0 -->
<div class="gno-column">
<h1>Title 1</h1>
<h1 id="title-1"><a class="heading-anchor" href="#title-1" aria-label="Link to this section">Title 1</a></h1>
<p>normal content</p>
</div>
</div> <!-- </gno-columns> -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</gno-form>

-- output.html --
<h1>only the first one should be take into account</h1>
<h1 id="only-the-first-one-should-be-take-into-account"><a class="heading-anchor" href="#only-the-first-one-should-be-take-into-account" aria-label="Link to this section">only the first one should be take into account</a></h1>
<form class="gno-form" method="post" action="/r/test" autocomplete="off" spellcheck="false">
<div class="gno-form_header">
<span><span class="font-bold">/r/test</span> Form</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
</div>
<div class="gno-form_input"><input type="submit" value="Submit to /r/test Realm" /></div>
</form>
<h1>hello 1</h1>
<h1 id="hello-1"><a class="heading-anchor" href="#hello-1" aria-label="Link to this section">hello 1</a></h1>
<form class="gno-form" method="post" action="/r/test" autocomplete="off" spellcheck="false">
<div class="gno-form_header">
<span><span class="font-bold">/r/test</span> Form</span>
Expand Down
23 changes: 23 additions & 0 deletions gno.land/pkg/gnoweb/markdown/golden/ext_heading/basic.md.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- input.md --
# Main Title

## Section One

Some content under section one.

### Subsection 1.1

Content for subsection.

## Section Two

More content.

-- output.html --
<h1 id="main-title"><a class="heading-anchor" href="#main-title" aria-label="Link to this section">Main Title</a></h1>
<h2 id="section-one"><a class="heading-anchor" href="#section-one" aria-label="Link to this section">Section One</a></h2>
<p>Some content under section one.</p>
<h3 id="subsection-11"><a class="heading-anchor" href="#subsection-11" aria-label="Link to this section">Subsection 1.1</a></h3>
<p>Content for subsection.</p>
<h2 id="section-two"><a class="heading-anchor" href="#section-two" aria-label="Link to this section">Section Two</a></h2>
<p>More content.</p>
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
![invalid image](https://invalid.land)

-- output.html --
<h2>Normal image link</h2>
<h2 id="normal-image-link"><a class="heading-anchor" href="#normal-image-link" aria-label="Link to this section">Normal image link</a></h2>
<p><img src="http://gno.land" alt="img1"></p>
<h2>Data iamge uri</h2>
<h2 id="data-iamge-uri"><a class="heading-anchor" href="#data-iamge-uri" aria-label="Link to this section">Data iamge uri</a></h2>
<p><img src="data:image/svg+xml;base64,AAA==" alt="svg image"></p>
<h2>Empty image uri</h2>
<h2 id="empty-image-uri"><a class="heading-anchor" href="#empty-image-uri" aria-label="Link to this section">Empty image uri</a></h2>
<p><img src="" alt="empty img"></p>
<h2>Filter any image starting by <code>https://</code>, see <code>ext_text.go</code></h2>
<h2 id="filter-any-image-starting-by-https-see-ext-textgo"><a class="heading-anchor" href="#filter-any-image-starting-by-https-see-ext-textgo" aria-label="Link to this section">Filter any image starting by <code>https://</code>, see <code>ext_text.go</code></a></h2>
<p><img src="" alt="invalid image"></p>
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
[Bad Link]("/></a><button>Hello</button>)

-- output.html --
<h2>My Realm</h2>
<h2 id="my-realm"><a class="heading-anchor" href="#my-realm" aria-label="Link to this section">My Realm</a></h2>
<p><a href="%22/%3E%3C/a%3E%3Cbutton%3EHello%3C/button%3E">Bad Link</a></p>
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Vivamus nibh dui, finibus <a href="/r/other/realm">internal<span class="link-int
Nam id ipsum faucibus <a href="/r/test/mypackage/realm">package</a> ultrices eros at.</p>
<p>Nam id ipsum <em>faucibus</em> <em><a href="/r/test/mypackage/realm">italic</a></em> ultrices eros at.
Nam id ipsum faucibus <strong><a href="/r/test/mypackage/realm">italic</a></strong> ultrices eros at.</p>
<h2>Inline title <a href="/r/other/realm">internal<span class="link-internal tooltip" data-tooltip-target="info" data-tooltip="Cross package link" title="Cross package link"><svg class="c-icon"><use href="#ico-internal-link"></use></svg></span></a></h2>
<h2 id="inline-title-internalrotherrealm"><a class="heading-anchor" href="#inline-title-internalrotherrealm" aria-label="Link to this section">Inline title <a href="/r/other/realm">internal<span class="link-internal tooltip" data-tooltip-target="info" data-tooltip="Cross package link" title="Cross package link"><svg class="c-icon"><use href="#ico-internal-link"></use></svg></span></a></a></h2>
<ul>
<li>level 1 <a href="/r/other1/realm">internal1<span class="link-internal tooltip" data-tooltip-target="info" data-tooltip="Cross package link" title="Cross package link"><svg class="c-icon"><use href="#ico-internal-link"></use></svg></span></a>
<ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Vivamus nibh dui, finibus <a href="/r/other/realm">internal<span class="link-int
Nam id ipsum faucibus <a href="/r/test/mypackage/realm">package</a> ultrices eros at.</p>
<p>Nam id ipsum faucibus <em><a href="/r/test/mypackage/realm">italic</a></em> ultrices eros at.
Nam id ipsum faucibus <strong><a href="/r/test/mypackage/realm">bold</a></strong> ultrices eros at.</p>
<h2>Inline title <a href="/r/other/realm">internal<span class="link-internal tooltip" data-tooltip-target="info" data-tooltip="Cross package link" title="Cross package link"><svg class="c-icon"><use href="#ico-internal-link"></use></svg></span></a></h2>
<h2 id="inline-title-internalrotherrealm"><a class="heading-anchor" href="#inline-title-internalrotherrealm" aria-label="Link to this section">Inline title <a href="/r/other/realm">internal<span class="link-internal tooltip" data-tooltip-target="info" data-tooltip="Cross package link" title="Cross package link"><svg class="c-icon"><use href="#ico-internal-link"></use></svg></span></a></a></h2>
<ul>
<li>level 1 <a href="/r/other1/realm">internal1<span class="link-internal tooltip" data-tooltip-target="info" data-tooltip="Cross package link" title="Cross package link"><svg class="c-icon"><use href="#ico-internal-link"></use></svg></span></a>
<ul>
Expand Down
2 changes: 1 addition & 1 deletion gno.land/pkg/gnoweb/public/main.css

Large diffs are not rendered by default.

Loading