Skip to content

Better sidebar/navbar rendering from the Documenter end #278

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
48 changes: 43 additions & 5 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,45 @@ using DocumenterCitations
using DocumenterInterLinks
using LaTeXStrings

struct DecomposeInSidebar
path::String
pages::Any
end

# So you can only really pull this trick once in a Julia session
# But because doc.user.pages is a Vector{Any}, we can't really do anything about it.
function DocumenterVitepress.pagelist2str(doc, ds::Vector{<: Any}, ::Val{:sidebar})
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain what this is supposed to do and why you need the invoke? I'm not sure yet if this DecomposeInSidebar thing shouldn't work differently, but I'm also not quite getting yet how you intended it

println("Hello World!!!")
if !all(x -> x isa DecomposeInSidebar, ds)
# if this is false, invoke the default method.
return invoke(pagelist2str, Tuple{Any, Any, Val{:sidebar}}, doc, ds, Val(:sidebar))
end
contents = DocumenterVitepress.pagelist2str.((doc,), ds, (Val(:sidebar),))
ret = "{\n" * join(contents, ",\n") * "\n}"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest just taking a dep on some JSON package rather than having this brittle manual construction code, it's easy to mess up escaping etc.

println(ret)
return ret
end

function DocumenterVitepress.pagelist2str(doc, ds::DecomposeInSidebar, ::Val{:sidebar})
raw_contents = DocumenterVitepress.pagelist2str(doc, ds.pages, Val(:sidebar))
contents = if raw_contents isa String
raw_contents
else
join(raw_contents, ",\n")
end

return "\"/$(ds.path)/\": {\n$(contents)\n}"
end

function DocumenterVitepress.pagelist2str(doc, ds::DecomposeInSidebar, ::Val{:navbar})
return DocumenterVitepress.pagelist2str(doc, ds.pages, Val(:sidebar))
end

function Documenter.walk_navpages(ds::DecomposeInSidebar, parent, doc)
return Documenter.walk_navpages(ds.pages, parent, doc)
end


# Handle DocumenterCitations integration - if you're running this, then you don't need anything here!!
documenter_citations_dir = dirname(dirname(pathof(DocumenterCitations)))
documenter_citations_docs_dir = joinpath(documenter_citations_dir, "docs")
Expand Down Expand Up @@ -53,7 +92,7 @@ makedocs(;
source = "src",
build = "build",
pages = [
"Manual" => [
DecomposeInSidebar("manual", "Manual" => [
"Get Started" => "manual/get_started.md",
"Updating to DocumenterVitepress" => "manual/documenter_to_vitepress_docs_example.md",
"Code" => "manual/code_example.md",
Expand All @@ -63,12 +102,11 @@ makedocs(;
"CSS Styling" => "manual/style_css.md",
"Authors' badge" => "manual/author_badge.md",
"GitHub Icon with Stars" => "manual/repo_stars.md",
],
"Developers' documentation" => [
]),
DecomposeInSidebar("devs", "Developers' documentation" => [
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a nice thing to have, but I don't love the name. I don't feel that it communicates his purpose fully.

Copy link
Collaborator Author

@asinghvi17 asinghvi17 Jun 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's more a test at this point. We can move this in and refactoring before merge.

"The rendering process" => "devs/render_pipeline.md",
"Internal API" => "devs/internal_api.md",
],
"api.md",
]),
],
plugins = [bib, links],
)
Expand Down
1 change: 1 addition & 0 deletions src/DocumenterVitepress.jl
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const ASSETS = normpath(joinpath(@__DIR__, "..", "assets"))

include("vitepress_interface.jl")
include("vitepress_config.jl")
include("frontmatter.jl")
include("writer.jl")
include("ANSIBlocks.jl")

Expand Down
26 changes: 26 additions & 0 deletions src/frontmatter.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# In Vitepress you can only have one frontmatter block.
# But users / other Documenter stages may inject multiple.
# So, we have a stage that will merge and render all frontmatter blocks
# before doing anything else.

function merge_and_render_frontmatter(io::IO, mime::MIME"text/yaml", page, doc; kwargs...)
frontmatter = String[]
for block in page.mdast.children
element = block.element
if element isa MarkdownAST.CodeBlock && element.info == "@frontmatter"
push!(frontmatter, element.code)
elseif element isa Documenter.RawNode && startswith(element.text, "---")
push!(frontmatter, join(split(element.text, "\n")[2:end-1], "\n"))

Check warning on line 13 in src/frontmatter.jl

View check run for this annotation

Codecov / codecov/patch

src/frontmatter.jl#L6-L13

Added lines #L6 - L13 were not covered by tests
end
end

Check warning on line 15 in src/frontmatter.jl

View check run for this annotation

Codecov / codecov/patch

src/frontmatter.jl#L15

Added line #L15 was not covered by tests

if haskey(page.globals.meta, :Title)
pushfirst!(frontmatter, "title: \"$(page.globals.meta[:Title])\"")

Check warning on line 18 in src/frontmatter.jl

View check run for this annotation

Codecov / codecov/patch

src/frontmatter.jl#L17-L18

Added lines #L17 - L18 were not covered by tests
end
if haskey(page.globals.meta, :Description)
pushfirst!(frontmatter, "description: \"$(page.globals.meta[:Description])\"")

Check warning on line 21 in src/frontmatter.jl

View check run for this annotation

Codecov / codecov/patch

src/frontmatter.jl#L20-L21

Added lines #L20 - L21 were not covered by tests
end
println(io, "---")
println(io, join(frontmatter, "\n"))
println(io, "---")

Check warning on line 25 in src/frontmatter.jl

View check run for this annotation

Codecov / codecov/patch

src/frontmatter.jl#L23-L25

Added lines #L23 - L25 were not covered by tests
end
79 changes: 49 additions & 30 deletions src/vitepress_config.jl
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,19 @@
@info "Base is \"\" and ENV[\"CI\"] is not set so this is a local build. Not adding any additional base prefix based on the repository or deploy url and instead using absolute path \"/\" to facilitate serving docs locally."
"/"
elseif isnothing(settings.deploy_url)
"/" * splitpath(settings.repo)[end] # Get the last identifier of the repo path, i.e., `user/$repo`.
"/" * split(settings.repo, '/')[end] # Get the last identifier of the repo path, i.e., `user/$repo`.

Check warning on line 92 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L92

Added line #L92 was not covered by tests
else
s_path = startswith(settings.deploy_url, r"http[s?]:\/\/") ? splitpath(settings.deploy_url)[2:end] : splitpath(settings.deploy_url)
s = length(s_path) > 1 ? joinpath(s_path) : "" # ignore custom URL here
s_path = if startswith(settings.deploy_url, r"http[s?]:\/\/")
frags = split(settings.deploy_url, '/') # "https", "", "my.custom.domain", "sub", "dir"
length(frags) >= 4 ? frags[4:end] : [""] # |-> "sub", "dir"

Check warning on line 96 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L94-L96

Added lines #L94 - L96 were not covered by tests
else
split(settings.deploy_url, '/') # "sub", "dir"

Check warning on line 98 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L98

Added line #L98 was not covered by tests
end
s = join(s_path, '/')

Check warning on line 100 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L100

Added line #L100 was not covered by tests
isempty(s) ? "/" : "/$(s)"
end

base_str = deploy_abspath == "/" ? "base: '$(deploy_abspath)$(deploy_relpath)'" : "base: '$(deploy_abspath)/$(deploy_relpath)'"
base_str = endswith(deploy_abspath, "/") ? "base: '$(deploy_abspath)$(deploy_relpath)'" : "base: '$(deploy_abspath)/$(deploy_relpath)'"

Check warning on line 104 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L104

Added line #L104 was not covered by tests

push!(replacers, "REPLACE_ME_DOCUMENTER_VITEPRESS_DEPLOY_ABSPATH" => deploy_abspath)
push!(replacers, "base: 'REPLACE_ME_DOCUMENTER_VITEPRESS'" => base_str)
Expand All @@ -106,10 +111,10 @@
# # Vitepress navbar and sidebar

provided_page_list = doc.user.pages
sidebar_navbar_info = pagelist2str.((doc,), provided_page_list)
sidebar_navbar_string = join(sidebar_navbar_info, ",\n")
push!(replacers, "sidebar: 'REPLACE_ME_DOCUMENTER_VITEPRESS'" => "sidebar: [\n$sidebar_navbar_string\n]\n")
push!(replacers, "nav: 'REPLACE_ME_DOCUMENTER_VITEPRESS'" => "nav: [\n$sidebar_navbar_string\n]\n")
sidebar_info = sprint(print, pagelist2str(doc, provided_page_list, Val(:sidebar)))
navbar_info = sprint(print, pagelist2str(doc, provided_page_list, Val(:navbar)))
push!(replacers, "sidebar: 'REPLACE_ME_DOCUMENTER_VITEPRESS'" => "sidebar: $(sidebar_info)\n")
push!(replacers, "nav: 'REPLACE_ME_DOCUMENTER_VITEPRESS'" => "nav: $(navbar_info)\n")

Check warning on line 117 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L114-L117

Added lines #L114 - L117 were not covered by tests

# # Title
push!(replacers, "title: 'REPLACE_ME_DOCUMENTER_VITEPRESS'" => "title: '$(doc.user.sitename)'")
Expand Down Expand Up @@ -158,10 +163,9 @@
return
end

function _get_raw_text(element)
end
# Utility methods to get data about pages

function pagelist2str(doc, page::String)
function get_title(doc, page::AbstractString)

Check warning on line 168 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L168

Added line #L168 was not covered by tests
# If no name is given, find the first header in the page,
# and use that as the name.
elements = collect(doc.blueprint.pages[page].mdast.children)
Expand All @@ -174,35 +178,50 @@
else
Documenter.MDFlatten.mdflatten(elements[idx])
end
return "{ text: '$(replace(name, "'" => "\\'"))', link: '/$(splitext(page)[1])' }" # , $(sidebar_items(doc, page)) }"
return name

Check warning on line 181 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L181

Added line #L181 was not covered by tests
end
get_title(doc, page::Pair{<: AbstractString, <: Any}) = first(page)

Check warning on line 183 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L183

Added line #L183 was not covered by tests

pagelist2str(doc, name_any::Pair{String, <: Any}) = pagelist2str(doc, first(name_any) => last(name_any))
# Catch all method: just broadcast over any iterable assuming it is a collection
function pagelist2str(doc, pages, sidenav::Val)
contents = map(pages) do page
"{ " * pagelist2str(doc, page, sidenav) * " }"

Check warning on line 188 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L186-L188

Added lines #L186 - L188 were not covered by tests
end
return "[" * join(contents, ",\n") * "]"

Check warning on line 190 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L190

Added line #L190 was not covered by tests
end
function pagelist2str(doc, page::AbstractString, sidenav::Val)
name = get_title(doc, page)
return "text: '$(replace(name, "'" => "\\'"))', link: '/$(splitext(page)[1])'" # , $(sidebar_items(doc, page)) }"

Check warning on line 194 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L192-L194

Added lines #L192 - L194 were not covered by tests
end

function pagelist2str(doc, name_page::Pair{String, String})
function pagelist2str(doc, name_page::Pair{<: Any, <: Any}, sidenav::Val)

Check warning on line 197 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L197

Added line #L197 was not covered by tests
name, page = name_page
# This is the simplest and easiest case.
return "{ text: '$(replace(name, "'" => "\\'"))', link: '/$(splitext(page)[1])' }" # , $(sidebar_items(doc, page)) }"
return pagelist2str(doc, name => page, sidenav) # , $(sidebar_items(doc, page)) }"

Check warning on line 200 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L200

Added line #L200 was not covered by tests
end

function pagelist2str(doc, name_contents::Pair{String, <: AbstractVector})
name, contents = name_contents
# This is for nested stuff. Should work automatically but you never know...
rendered_contents = pagelist2str.((doc,), contents)
return "{ text: '$(replace(name, "'" => "\\'"))', collapsed: false, items: [\n$(join(rendered_contents, ",\n"))]\n }" # TODO: add a link here if the name is the same name as a file?
function pagelist2str(doc, name_page::Pair{<: Any, <: Nothing}, sidenav::Val)
return ""

Check warning on line 204 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L203-L204

Added lines #L203 - L204 were not covered by tests
end

function sidebar_items(doc, page::String)
# We look at the page elements, and obtain all level 1 and 2 headers.
elements = doc.blueprint.pages[page].elements
headers = filter(x -> x isa Union{MarkdownAST.Heading{1}, MarkdownAST.Heading{2}}, elements)
# If nothing is found, move on in life
if length(headers) ≤ 1
return ""
function pagelist2str(doc, name_page::Pair{<: AbstractString, <: AbstractString}, sidenav::Val)
name, page = name_page

Check warning on line 208 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L207-L208

Added lines #L207 - L208 were not covered by tests
# This is the simplest and easiest case.
return "text: '$(replace(name, "'" => "\\'"))', link: '/$(splitext(page)[1])'" # , $(sidebar_items(doc, page)) }"

Check warning on line 210 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L210

Added line #L210 was not covered by tests
end
function pagelist2str(doc, name_contents::Pair{<: AbstractString, <: AbstractArray}, sidenav::Val)
name, contents = name_contents

Check warning on line 213 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L212-L213

Added lines #L212 - L213 were not covered by tests
# This is for nested stuff. Should work automatically but you never know...
rendered_contents = map(contents) do content
"{" * pagelist2str(doc, content, sidenav) * "}"

Check warning on line 216 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L215-L216

Added lines #L215 - L216 were not covered by tests
end
final_contents = join(rendered_contents, ",\n")
collapse = if sidenav === Val(:sidebar)
"collapsed: false,"

Check warning on line 220 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L218-L220

Added lines #L218 - L220 were not covered by tests
else
""

Check warning on line 222 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L222

Added line #L222 was not covered by tests
end
# Otherwise, we return a collapsible tree of headers for each level 1 and 2 header.
items = headers
return "collapsed: true, items: [\n $(join(_item_link.((page,), items), ",\n"))\n]"
return "text: '$(replace(name, "'" => "\\'"))', $collapse items: [\n$(final_contents)\n]" # TODO: add a link here if the name is the same name as a file?

Check warning on line 224 in src/vitepress_config.jl

View check run for this annotation

Codecov / codecov/patch

src/vitepress_config.jl#L224

Added line #L224 was not covered by tests
end

function _item_link(page, item)
Expand Down
54 changes: 36 additions & 18 deletions src/writer.jl
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
assets = nothing
"A version string to write to the header of the objects.inv inventory file. This should be a valid version number without a v prefix. Defaults to the version defined in the Project.toml file in the parent folder of the documentation root"
inventory_version::Union{String,Nothing} = nothing
"Whether to write inventory or not"
write_inventory = true
"""
Sets the granularity of versions which should be kept. Options are :patch, :minor or :breaking (the default).
You can use this to reduce the number of docs versions that coexist on your dev branch. With :patch, every patch
Expand Down Expand Up @@ -217,34 +219,47 @@
end
end

version = settings.inventory_version
if isnothing(version)
project_toml = joinpath(dirname(doc.user.root), "Project.toml")
version = _get_inventory_version(project_toml)
inventory = if settings.write_inventory
version = settings.inventory_version
if isnothing(version)
project_toml = joinpath(dirname(doc.user.root), "Project.toml")
version = _get_inventory_version(project_toml)

Check warning on line 226 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L222-L226

Added lines #L222 - L226 were not covered by tests
end
Inventory(; project=doc.user.sitename, version)

Check warning on line 228 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L228

Added line #L228 was not covered by tests
else
nothing

Check warning on line 230 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L230

Added line #L230 was not covered by tests
end
inventory = Inventory(; project=doc.user.sitename, version)

# Iterate over the pages, render each page separately
for (src, page) in doc.blueprint.pages
# This is where you can operate on a per-page level.
open(docpath(page.build, builddir, settings.md_output_path), "w") do io
merge_and_render_frontmatter(io, MIME("text/yaml"), page, doc)

Check warning on line 237 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L237

Added line #L237 was not covered by tests
for node in page.mdast.children
render(io, mime, node, page, doc; inventory)
kwargs = if settings.write_inventory
(; inventory = inventory)

Check warning on line 240 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L239-L240

Added lines #L239 - L240 were not covered by tests
else
(;)

Check warning on line 242 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L242

Added line #L242 was not covered by tests
end
render(io, mime, node, page, doc; kwargs...)

Check warning on line 244 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L244

Added line #L244 was not covered by tests
end
end
item = InventoryItem(
name = replace(splitext(src)[1], "\\" => "/"),
domain = "std",
role = "doc",
dispname = _pagetitle(page),
priority = -1,
uri = _get_inventory_uri(doc, page, nothing)
)
push!(inventory, item)
if settings.write_inventory
item = InventoryItem(

Check warning on line 248 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L247-L248

Added lines #L247 - L248 were not covered by tests
name = replace(splitext(src)[1], "\\" => "/"),
domain = "std",
role = "doc",
dispname = _pagetitle(page),
priority = -1,
uri = _get_inventory_uri(doc, page, nothing)
)
push!(inventory, item)

Check warning on line 256 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L256

Added line #L256 was not covered by tests
end
end
if settings.write_inventory
objects_inv = joinpath(builddir, settings.md_output_path, "public", "objects.inv")
DocInventories.save(objects_inv, inventory)

Check warning on line 261 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L258-L261

Added lines #L258 - L261 were not covered by tests
end

objects_inv = joinpath(builddir, settings.md_output_path, "public", "objects.inv")
DocInventories.save(objects_inv, inventory)

bases = determine_bases(deploy_decision.subfolder; settings.keep)

Expand Down Expand Up @@ -948,6 +963,9 @@
# Raw nodes are used to insert raw HTML into the output. We just print it as is.
# TODO: what if the `raw` is not HTML? That is not addressed here but we ought to address it...
function render(io::IO, ::MIME"text/plain", node::Documenter.MarkdownAST.Node, raw::Documenter.RawNode, page, doc; kwargs...)
if startswith(raw.text, "---")
return # this was already handled by frontmatter.

Check warning on line 967 in src/writer.jl

View check run for this annotation

Codecov / codecov/patch

src/writer.jl#L966-L967

Added lines #L966 - L967 were not covered by tests
end
return raw.name === :html ? println(io, raw.text, "\n") : nothing
end

Expand Down Expand Up @@ -1000,7 +1018,7 @@
# Code blocks
function render(io::IO, mime::MIME"text/plain", node::Documenter.MarkdownAST.Node, code::MarkdownAST.CodeBlock, page, doc; kwargs...)
if startswith(code.info, "@")
@warn """

Check warning on line 1021 in src/writer.jl

View workflow job for this annotation

GitHub Actions / build

DocumenterVitepress: un-expanded `@doctest` block encountered on page src/manual/code_example.md. The first few lines of code in this node are: ``` julia> 1 + 1 2 ```
DocumenterVitepress: un-expanded `$(code.info)` block encountered on page $(page.source).
The first few lines of code in this node are:
```
Expand Down
Loading