Skip to content

Commit 5ac8de3

Browse files
committed
fix: preserve section anchors on manual headings
This PR restores missing `id` anchors on manual title pages and split chapter pages. We now emit each registered section ID on its heading, matching the existing behavior for sections rendered when in single-page output mode. For example, render this Verso manual with `htmlDepth := 1`: ```lean #docs (Manual) doc "Example" := ::::::: See {ref "tagged-chapter"}[the chapter]. # Tagged Chapter %%% tag := "tagged-chapter" %%% Chapter content. ::::::: ``` The reference points to `/Tagged-Chapter/#tagged-chapter`. Previously, the split-page renderer constructed its own heading without the ID. The relevant HTML changes as follows (simplified): ```diff <section> - <h1>...</h1> + <h1 id="tagged-chapter">...</h1> ... </section> ``` The link now resolves to the chapter heading correctly. Title pages have the same missing-anchor problem, including in single-page output. We now share the anchor, numbering, and permalink construction through a `partHeading` helper, used by ordinary sections, split chapters, and title pages. The helper represents the result as a `Heading` with explicit level, optional ID, and content, and render it through `Heading.toHtml`. Breaking change: `Html.titlePage` now takes a `Heading` as its first argument. Callers passing plain title HTML must instead supply a heading with its level, ID, and content; manual renderers can use `partHeading` to obtain the registered anchor and existing numbering/permalink behavior. Codex with GPT-6 was used to prepare this PR.
1 parent 27444df commit 5ac8de3

6 files changed

Lines changed: 155 additions & 20 deletions

File tree

‎doc/UsersGuide/Releases/Entries.lean‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ public import UsersGuide.Releases.Entries.MethodInMultiVerso
2424
public import UsersGuide.Releases.Entries.ReleaseNotesChapter
2525
public import UsersGuide.Releases.Entries.RoleDiagnostics
2626
public import UsersGuide.Releases.Entries.SearchPriority
27+
public import UsersGuide.Releases.Entries.SectionAnchors
2728
public import UsersGuide.Releases.Entries.TestFramework
2829
public import UsersGuide.Releases.Entries.VersionedReleaseNotes
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/-
2+
Copyright (c) 2026 Lean FRO LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Author: Emilio J. Gallego Arias
5+
-/
6+
module
7+
8+
public import UsersGuide.Releases.Entry
9+
10+
open Verso.Genre Manual InlineLean UsersGuide.Releases
11+
12+
release_note
13+
version := ⟨4, 34, 0⟩
14+
breaking := true
15+
tag := "manual-heading-anchors"
16+
prs := [983]
17+
18+
#doc (Manual) "Manual Heading Anchors" =>
19+
20+
Manual title pages and chapters rendered on separate pages now emit their registered section IDs on their headings, so references to these sections resolve correctly.
21+
The fix also applies to the document title in single-page output.
22+
23+
Breaking change: {name}`Verso.Genre.Manual.Html.titlePage` now takes a {name}`Verso.Genre.Manual.Heading` as its first argument instead of plain title HTML.
24+
Callers must supply the heading's level, optional ID, and HTML content.
25+
Manual renderers can use {name}`Verso.Genre.Manual.partHeading` to construct a heading with the registered anchor, section number, and permalink.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/-
2+
Copyright (c) 2026 Lean FRO LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Author: Emilio J. Gallego Arias
5+
-/
6+
module
7+
public import Verso
8+
public import VersoManual
9+
public meta import Verso
10+
public meta import VersoManual
11+
import Errata
12+
13+
namespace Verso.Integration.SectionAnchors
14+
15+
open Lean Verso Genre Manual
16+
17+
#docs (Manual) doc "Section Anchors" :=
18+
:::::::
19+
%%%
20+
tag := "manual-root"
21+
%%%
22+
23+
See {ref "tagged-chapter"}[the chapter] and {ref "nested-section"}[its section].
24+
25+
# Tagged Chapter
26+
%%%
27+
tag := "tagged-chapter"
28+
%%%
29+
30+
Return to {ref "manual-root"}[the title page].
31+
32+
## Nested Section
33+
%%%
34+
tag := "nested-section"
35+
%%%
36+
37+
This section stays on its chapter's page.
38+
39+
# Automatic Chapter
40+
41+
This chapter gets an automatically generated tag.
42+
:::::::
43+
44+
/-- Every exported section target must occur exactly once, on a heading in its declared page. -/
45+
private def checkAnchors (mode : Mode) (htmlDepth : Nat := 1) : IO Unit := IO.FS.withTempDir fun destination => do
46+
let cfg : RenderConfig := { destination, htmlDepth, features := {} }
47+
let (traverse, emit, directory) := match mode with
48+
| .single => (traverseHtmlSingle, emitHtmlSingle, "html-single")
49+
| .multi => (traverseHtmlMulti, emitHtmlMulti, "html-multi")
50+
let site := destination / directory
51+
let exitCode ← withLogger fun logger => do
52+
let (part, state) ← (traverse cfg doc.toPart).run extension_impls% |>.run logger
53+
emitXrefsJson site state
54+
(emit cfg part state).run extension_impls% |>.run logger
55+
unless exitCode == 0 do
56+
throw <| IO.userError "Section anchor document generation logged errors"
57+
let xrefs ← IO.ofExcept <| Json.parse (← IO.FS.readFile (site / "xref.json"))
58+
let sections ← IO.ofExcept <| xrefs.getObjVal? "Verso.Genre.Manual.section"
59+
let contents ← IO.ofExcept <| sections.getObjVal? "contents"
60+
let entries ← IO.ofExcept contents.getObj?
61+
unless entries.size == 4 do
62+
throw <| IO.userError s!"Expected four section targets, got {entries.size}"
63+
for tag in ["manual-root", "tagged-chapter", "nested-section"] do
64+
discard <| IO.ofExcept <| contents.getObjVal? tag
65+
for (tag, targets) in entries.toArray do
66+
let targets ← IO.ofExcept targets.getArr?
67+
unless targets.size == 1 do
68+
throw <| IO.userError s!"Expected one destination for {tag}"
69+
let target := targets[0]!
70+
let address ← IO.ofExcept <| target.getObjValAs? String "address"
71+
let id ← IO.ofExcept <| target.getObjValAs? String "id"
72+
let page := (address.splitOn "/").filter (!·.isEmpty) |>.foldl (· / ·) site
73+
let html ← IO.FS.readFile (page / "index.html")
74+
let count := (html.splitOn s!" id=\"{id}\"").length - 1
75+
unless count == 1 do
76+
throw <| IO.userError s!"{tag}: expected exactly one id=\"{id}\" in {address}, got {count}"
77+
unless (List.range 6).any (fun n => (html.splitOn s!"<h{n + 1} id=\"{id}\">").length == 2) do
78+
throw <| IO.userError s!"{tag}: expected id=\"{id}\" on a heading in {address}"
79+
80+
/-- Single-page manual headings contain every exported section target exactly once. -/
81+
@[test]
82+
def singlePageSectionAnchors : Errata.Test := do
83+
checkAnchors .single
84+
85+
/-- Multi-page manual headings retain their anchors at each splitting depth. -/
86+
@[test]
87+
def multiPageSectionAnchors : Errata.Test := do
88+
for depth in [0, 1, 2] do
89+
checkAnchors .multi depth

‎src/verso-manual/VersoManual.lean‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,8 @@ where
763763
let definitionIds := state.definitionIds ctxt
764764
let linkTargets := config.linkTargets state (← readThe AllRemotes)
765765
let titleHtml ← Html.seq <$> text.title.mapM (Manual.toHtml opts ctxt state definitionIds linkTargets {})
766+
let heading :=
767+
partHeading state ctxt (text.metadata.bind (·.id)) 1 titleHtml (showNumber := false) (showPermalink := false)
766768
let introHtml ← Html.seq <$> text.content.mapM (Manual.toHtml opts ctxt state definitionIds linkTargets {})
767769
let bookToc ← text.subParts.mapM (fun p => toc 0 opts (ctxt.inPart p) state definitionIds linkTargets p)
768770
let bookTocHtml := open Verso.Output.Html in
@@ -778,7 +780,7 @@ where
778780
Manual.toHtml { opts with headerLevel := 2 } (ctxt.inPart p) state definitionIds linkTargets {} p
779781
let pageContent := open Verso.Output.Html in
780782
{{<section>
781-
{{Html.titlePage titleHtml authors authorshipNote introHtml}}
783+
{{Html.titlePage heading authors authorshipNote introHtml}}
782784
{{bookTocHtml}}
783785
{{contents}}
784786
</section>}}
@@ -890,13 +892,8 @@ where
890892
(root : Bool) (depth : Nat) (dir : System.FilePath) (part : Part Manual) : StateT (State Html) (ReaderT AllRemotes (ReaderT ExtensionImpls (BuildLogT IO))) Unit := do
891893
let thisFile := part.metadata.bind (·.file) |>.getD (part.titleString.sluggify.toString)
892894
let dir := if root then dir else dir.join thisFile
893-
let sectionNum := sectionHtml ctxt
894-
let pageTitleHtml := sectionNum ++ (← Html.seq <$> part.title.mapM (Manual.toHtml opts ctxt state definitionIds linkTargets codeOptions))
895-
let titleHtml :=
896-
pageTitleHtml ++
897-
if let some id := part.metadata.bind (·.id) then
898-
permalink id state
899-
else .empty
895+
let titleHtml ← Html.seq <$> part.title.mapM (Manual.toHtml opts ctxt state definitionIds linkTargets codeOptions)
896+
let heading := partHeading state ctxt (part.metadata.bind (·.id)) 1 titleHtml
900897
let introHtml ← Html.seq <$> part.content.mapM (Manual.toHtml opts ctxt state definitionIds linkTargets codeOptions)
901898
let contents ←
902899
if depth == 0 || part.htmlSplit == .never then
@@ -925,13 +922,13 @@ where
925922
</section>
926923
}}
927924
else .empty
928-
{{<section>{{Html.titlePage titleHtml authors authorshipNote introHtml ++ contents}} {{subTocHtml}}</section>}}
925+
{{<section>{{Html.titlePage heading authors authorshipNote introHtml ++ contents}} {{subTocHtml}}</section>}}
929926
else
930927
let subTocHtml :=
931928
if (depth > 0 && part.htmlSplit != .never) && subToc.size > 0 && part.htmlToc then
932929
{{<ol class="section-toc">{{subToc.map (·.html config.sectionTocDepth)}}</ol>}}
933930
else .empty
934-
{{<section><h1>{{titleHtml}}</h1> {{introHtml}} {{contents}} {{subTocHtml}}</section>}}
931+
{{<section>{{heading.toHtml}} {{introHtml}} {{contents}} {{subTocHtml}}</section>}}
935932

936933
ensureDir dir
937934
IO.FS.withFile (dir.join "index.html") .write fun h => do

‎src/verso-manual/VersoManual/Basic.lean‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,20 +1606,39 @@ def permalink (id : InternalId) (st : TraverseState) (inline : Bool := true) : H
16061606
</span>
16071607
}}
16081608

1609+
/-- A manual heading whose level, anchor, and contents are rendered together. -/
1610+
structure Heading where
1611+
/-- The HTML heading level. -/
1612+
level : Nat
1613+
/-- The registered HTML anchor, or none for an unanchored heading. -/
1614+
id : Option Slug
1615+
/-- The heading contents, including any section number and permalink widget. -/
1616+
content : Html
1617+
1618+
/-- Renders the heading element and its optional anchor. -/
1619+
def Heading.toHtml (heading : Heading) : Html :=
1620+
let attrs := heading.id.map (fun id => #[("id", id.toString)]) |>.getD #[]
1621+
.tag s!"h{heading.level}" attrs heading.content
1622+
1623+
/--
1624+
Constructs a manual heading with its registered anchor, section number, and permalink.
1625+
The single-page document title omits numbering and the permalink by setting
1626+
{name}`showNumber` and {name}`showPermalink` to false.
1627+
-/
1628+
def partHeading (state : TraverseState) (ctxt : TraverseContext) (id : Option InternalId)
1629+
(level : Nat) (title : Html) (showNumber : Bool := true) (showPermalink : Bool := true) : Heading :=
1630+
let numberHtml := if showNumber then sectionHtml ctxt else .empty
1631+
let permalinkHtml := if showPermalink then id.map (permalink · state) |>.getD .empty else .empty
1632+
{ level,
1633+
id := id.bind (fun id => state.externalTags[id]?) |>.map (·.htmlId),
1634+
content := numberHtml ++ title ++ permalinkHtml }
16091635

16101636
open Verso.Output.Html in
16111637
instance : Html.GenreHtml Manual (ReaderT AllRemotes (ReaderT ExtensionImpls (BuildLogT IO))) where
16121638
part go «meta» txt := do
16131639
let st ← Verso.Doc.Html.HtmlT.state
1614-
let attrs := meta.id.map (st.htmlId) |>.getD #[]
16151640
let ctxt ← Verso.Doc.Html.HtmlT.context
1616-
let sectionNumber : Html := sectionHtml ctxt
1617-
let permalink? m :=
1618-
if let some id := m.id then permalink id st
1619-
else .empty
1620-
let mkHeader lvl content :=
1621-
.tag s!"h{lvl}" attrs (sectionNumber ++ content ++ permalink? «meta»)
1622-
go txt mkHeader
1641+
go txt fun level title => (partHeading st ctxt meta.id level title).toHtml
16231642

16241643
block goI goB b content := do
16251644
let some id := b.id

‎src/verso-manual/VersoManual/Html.lean‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,9 +379,13 @@ where
379379
| none => {{<span class="unnumbered"></span>}}
380380
| some ns => {{<span class="number">{{sectionNumberString ns}}</span>" "}}
381381

382-
public def titlePage (title : Html) (authors : List String) (authorshipNote : Option String) (intro : Html) : Html := {{
382+
/--
383+
Renders a manual's title page using the supplied heading's level, anchor, and contents.
384+
-/
385+
public def titlePage (heading : Heading) (authors : List String) (authorshipNote : Option String)
386+
(intro : Html) : Html := {{
383387
<div class="titlepage">
384-
<h1>{{title}}</h1>
388+
{{heading.toHtml}}
385389
<div class="authors">
386390
{{authors.toArray.map ({{ <span class="author">{{Coe.coe ·}}</span> }})}}
387391
{{if let some note := authorshipNote then {{<p class="note">{{note}}</p>}} else .empty }}

0 commit comments

Comments
 (0)