Skip to content

Commit 2b595cb

Browse files
authored
fix: label external declaration sections without h1 (#880)
This PR renders Manual docstring subsection labels as accessible labeled groups instead of nested `h1` elements inside external declaration bodies. The previous HTML used `h1` tags for labels such as `Fields` and `Constructors`, which made them part of the document heading outline even though they only name declaration detail groups. This can confuse heading navigation for screen readers. The new `Html.labeledGroup` helper renders a visible paragraph label, connects it to a `role="group"` container with `aria-labelledby`, and lets downstream renderers reuse the same accessibility pattern without hand-rolling it. Blueprint has already validated this shape downstream with Firefox accessibility tooling. Thanks to @NicolasRouquette for the original bug report: leanprover/verso-blueprint#130
1 parent 4a3ee22 commit 2b595cb

3 files changed

Lines changed: 125 additions & 10 deletions

File tree

src/tests/Tests/VersoManual/Html.lean

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE.
44
Author: David Thrane Christiansen
55
-/
66
import VersoManual.Html
7+
import VersoManual
78

89
namespace Verso.Genre.Manual.Html
910

@@ -112,3 +113,76 @@ Done
112113
break
113114

114115
end
116+
117+
namespace DocstringSectionRegression
118+
119+
open Verso
120+
open Verso.Output
121+
open Verso.Genre.Manual
122+
123+
/-- A fixture for Manual docstring subsection HTML rendering. -/
124+
structure SectionFixture where
125+
/-- Field documentation. -/
126+
field : Nat
127+
128+
/-- A fixture for Manual docstring constructor subsection HTML rendering. -/
129+
inductive ConstructorFixture where
130+
/-- Constructor documentation. -/
131+
| intro : ConstructorFixture
132+
133+
#docs (Manual) doc "Docstring section labels" :=
134+
:::::::
135+
136+
{docstring SectionFixture}
137+
138+
{docstring ConstructorFixture}
139+
140+
:::::::
141+
142+
private def hasSubstring (s sub : String) : Bool :=
143+
s.find? sub |>.isSome
144+
145+
private def compactHtml (s : String) : String :=
146+
s.foldl (init := "") fun out c =>
147+
if c.isWhitespace then out else out.push c
148+
149+
private def renderDoc : IO String := do
150+
let rendered ← IO.mkRef ""
151+
let exitCode ← withLogger fun logger => do
152+
let cfg : RenderConfig := {}
153+
let (part, state) ← (traverseHtmlSingle cfg doc.toPart).run extension_impls% |>.run logger
154+
let ctxt : Manual.TraverseContext := {}
155+
let definitionIds := state.definitionIds ctxt
156+
let remotes : Multi.AllRemotes := {}
157+
let linkTargets := cfg.linkTargets state remotes
158+
let (html, _) ←
159+
(Manual.toHtml (m := ReaderT Multi.AllRemotes (ReaderT ExtensionImpls (BuildLogT IO)))
160+
{} ctxt state definitionIds linkTargets {} part).run {}
161+
|>.run remotes
162+
|>.run extension_impls%
163+
|>.run logger
164+
rendered.set html.asString
165+
unless exitCode == 0 do
166+
throw <| IO.userError "Manual docstring HTML rendering logged errors"
167+
rendered.get
168+
169+
private def assertLabeledSection (compact label : String) : IO Unit := do
170+
let id := s!"docstring-section-{label}"
171+
let group := s!"<divclass=\"docstring-section\"role=\"group\"aria-labelledby=\"{id}\">"
172+
unless hasSubstring compact group do
173+
throw <| IO.userError s!"{label} section should render as a named group"
174+
let labelHtml := s!"<pclass=\"docstring-section-label\"id=\"{id}\">{label}</p>"
175+
unless hasSubstring compact labelHtml do
176+
throw <| IO.userError s!"{label} section should use a paragraph label with the group's ID"
177+
if hasSubstring compact s!"<h1>{label}</h1>" then
178+
throw <| IO.userError s!"{label} section label should not render as h1"
179+
180+
/--
181+
info: docstring section labels render as labeled groups
182+
-/
183+
#guard_msgs in
184+
#eval show IO Unit from do
185+
let compact := compactHtml (← renderDoc)
186+
assertLabeledSection compact "Fields"
187+
assertLabeledSection compact "Constructors"
188+
IO.println "docstring section labels render as labeled groups"

src/verso-manual/VersoManual/Docstring.lean

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -392,13 +392,18 @@ def docstringStyle := r#"
392392
color: #555;
393393
}
394394
395-
.namedocs h1 {
396-
font-size: inherit;
397-
font-weight: bold
395+
.namedocs .docstring-section {
398396
margin-top: 1rem;
399397
margin-bottom: 1rem;
400398
}
401399
400+
.namedocs .docstring-section-label {
401+
font-size: inherit;
402+
font-weight: bold;
403+
margin-top: 0;
404+
margin-bottom: 1rem;
405+
}
406+
402407
.namedocs > .text .constructor {
403408
padding-top: 0;
404409
padding-right: 0;
@@ -521,21 +526,35 @@ where
521526

522527
@[block_extension Block.docstringSection]
523528
def docstringSection.descr : BlockDescr where
524-
traverse _ _ _ := pure none
529+
traverse id info _ := do
530+
let .ok header := FromJson.fromJson? (α := String) info
531+
| reportError "Failed to deserialize docstring section data while traversing"; return none
532+
let path ← (·.path) <$> read
533+
let _ ← externalTag id path s!"docstring-section-{header}"
534+
pure none
525535
toTeX := some fun _goI goB _id info contents =>
526536
open Verso.Output.TeX in do
527537
let .ok header := FromJson.fromJson? (α := String) info
528538
| reportError "Failed to deserialize docstring section data while generating TeX"; return .empty
529539
pure \TeX{\par\noindent\textbf{\Lean{header}}\par " " \Lean{.seq (← contents.mapM goB)}}
530-
toHtml := some fun _goI goB _id info contents =>
540+
toHtml := some fun _goI goB id info contents =>
531541
open Verso.Doc.Html HtmlT in
532542
open Verso.Output Html in do
533543
let .ok header := FromJson.fromJson? (α := String) info
534-
| do reportError "Failed to deserialize docstring section data while generating HTML"; pure .empty
535-
return {{
536-
<h1>{{header}}</h1>
537-
{{← contents.mapM goB}}
538-
}}
544+
| do reportError "Failed to deserialize docstring section data while generating HTML"; pure Html.empty
545+
let xref : TraverseState ← HtmlT.state (genre := Manual)
546+
match xref.externalTags[id]? with
547+
| none =>
548+
reportError s!"No HTML ID registered for docstring section '{header}'"
549+
pure Html.empty
550+
| some dest =>
551+
let renderedContents ← contents.mapM goB
552+
pure (Html.labeledGroup
553+
"docstring-section"
554+
"docstring-section-label"
555+
(toString dest.htmlId)
556+
header
557+
(.seq renderedContents))
539558

540559
@[block_extension Block.internalSignature]
541560
def internalSignature.descr : BlockDescr where

src/verso/Verso/Output/Html.lean

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,28 @@ namespace Html
186186
/-- The default `DOCTYPE` for HTML5. -/
187187
public abbrev doctype := "<!DOCTYPE html>"
188188

189+
/--
190+
Wrap content in a named group whose visible label should not participate in the document heading
191+
outline.
192+
193+
Use this for labels that name a grouped region for assistive technology, but are not section
194+
headings. The label is rendered as a paragraph and connected to the group with `aria-labelledby`.
195+
-/
196+
public def labeledGroup
197+
(groupClass labelClass id label : String)
198+
(contents : Html) : Html :=
199+
let groupAttrs :=
200+
(if groupClass.isEmpty then #[] else #[("class", groupClass)]) ++
201+
#[("role", "group"), ("aria-labelledby", id)]
202+
let labelAttrs :=
203+
(if labelClass.isEmpty then #[] else #[("class", labelClass)]) ++
204+
#[("id", id)]
205+
.tag "div" groupAttrs <|
206+
.seq #[
207+
.tag "p" labelAttrs (.text true label),
208+
contents
209+
]
210+
189211
/--
190212
Visit the entire tree, applying rewrites in some monad. Return `none` to signal that no rewrites are
191213
to be performed.

0 commit comments

Comments
 (0)