diff --git a/.JuliaFormatter.toml b/.JuliaFormatter.toml deleted file mode 100644 index 7f659436..00000000 --- a/.JuliaFormatter.toml +++ /dev/null @@ -1 +0,0 @@ -whitespace_in_kwargs = true \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 70900061..88c9cf4a 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -39,24 +39,15 @@ jobs: ${{ runner.os }}- - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 - julia-format: - name: Formatter + runic: + name: Runic formatting runs-on: ubuntu-latest steps: - - uses: julia-actions/setup-julia@latest - uses: actions/checkout@v4 - - name: Install JuliaFormatter and format - run: | - julia --color=yes -e 'using Pkg; Pkg.add(PackageSpec(name="JuliaFormatter"))' - julia --color=yes -e 'using JuliaFormatter; format(".", verbose=true)' - - name: Format check - run: | - julia --color=yes -e ' - out = Cmd(`git diff --name-only`) |> read |> String - if out == "" - exit(0) - else - @error "Some files have not been formatted !!!" - write(stdout, out) - exit(1) - end' + - uses: julia-actions/setup-julia@v2 + with: + version: '1.11' + - uses: julia-actions/cache@v2 + - uses: fredrikekre/runic-action@v1 + with: + version: '1' diff --git a/Project.toml b/Project.toml index f8a06d0d..61f78cb2 100644 --- a/Project.toml +++ b/Project.toml @@ -9,7 +9,7 @@ Git = "d7ba0133-e1db-5d97-8f8c-041e4b3a1eb2" Gumbo = "708ec375-b3d6-5a57-a7ce-8257bf98657a" HypertextLiteral = "ac1192a8-f4b3-4bfe-ba22-af5b92cd3ab2" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -NodeJS = "2bd173c7-0d6d-553b-b6af-13a54713934c" +NodeJS_22_jll = "8fca9ca2-e7a1-5ccf-8c05-43be5a78664f" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" [compat] @@ -18,7 +18,7 @@ Git = "1" Gumbo = "0.8.2" HypertextLiteral = "0.9" JSON = "0.20,0.21" -NodeJS = "1, 2" +NodeJS_22_jll = "22.15.0" julia = "1" [extras] diff --git a/assets/default/pagefind.css b/assets/default/pagefind.css new file mode 100644 index 00000000..1969e7f9 --- /dev/null +++ b/assets/default/pagefind.css @@ -0,0 +1,152 @@ +#multi-page-nav .search { + margin-left: auto; + font-size: 16px; +} + +#multi-page-nav .search .search-keybinding { + float: right; + width: 0; + transform: translateX(-1em); + color: #999; +} + +#multi-page-nav .search:focus-within .search-keybinding { + display: none; +} + +#multi-page-nav .search #search-input { + border: 1px solid #666; + border-radius: 3.2px; + color: #999; + background-color: unset; + height: 28px; + font-family: inherit; + width: 20em; + font-size: 14px; + padding: 0 8px; +} + +#multi-page-nav .search #search-input::placeholder { + color: #999; + opacity: 1; +} + +#multi-page-nav .search:focus-within #search-input { + background-color: #fff; + outline: none; + box-shadow: none; + color: #000; +} + +.theme--documenter-dark #multi-page-nav .search:focus-within #search-input { + background-color: #202227; + color: #eee; +} + +#multi-page-nav .search:focus-within .suggestions { + display: block; +} + +#multi-page-nav .hidden { + display: none !important; +} + +#multi-page-nav .suggestions { + margin: -5px 1.5rem 0 0; + display: none; + background: #fff; + min-width: 20em; + max-width: 50vw; + position: absolute; + border: 1px solid #cfd4db; + border-radius: 6px; + padding: .4rem; + list-style-type: none; + z-index: 10; + right: 0; + max-height: max(50vh, 250px); + overflow-y: auto; +} + +.theme--documenter-dark #multi-page-nav .suggestions { + background: #2e3138; + border: 1px solid #5e6d6f; +} + +#multi-page-nav .suggestions mark { + background-color: inherit; + color: #000; +} +.theme--documenter-dark #multi-page-nav .suggestions mark { + color: #fff; +} + +#multi-page-nav .suggestions .suggestion { + line-height: 1.3; + border-radius: 4px; + + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; +} +#multi-page-nav .suggestions .sub-suggestions .suggestion { + margin-left: 1rem; +} + +#multi-page-nav .suggestions .suggestion-header { + padding: .4rem .6rem; +} +#multi-page-nav .suggestions .suggestion-header:focus-visible { + outline: none; +} +.theme--documenter-dark #multi-page-nav .suggestions .suggestion-header:hover, +.theme--documenter-dark #multi-page-nav .suggestions .suggestion-header:focus { + background-color: #202227; +} +#multi-page-nav .suggestions .suggestion-header:hover, +#multi-page-nav .suggestions .suggestion-header:focus { + background-color: #eee; +} + +#multi-page-nav .suggestions .suggestion .suggestion-excerpt { + font-size: small; + color: #666; +} +.theme--documenter-dark #multi-page-nav .suggestions .suggestion .suggestion-excerpt { + color: #aaa; +} + +#multi-page-nav .suggestion .suggestion-title { + font-size: 1.2rem; + font-weight: bold; +} + +#multi-page-nav .suggestion .sub-suggestions .suggestion-title { + font-size: 1rem; +} + +#multi-page-nav .suggestion a { + display: block; + overflow: hidden; + text-overflow: ellipsis; + color: black; +} + +.theme--documenter-dark #multi-page-nav .suggestion a { + color: white; +} + +#multi-page-nav .suggestions .suggestion .page-title { + font-weight: bold; +} + +@media screen and (max-width: 1055px) { + #multi-page-nav .search #search-input { + width: 100%; + } + #multi-page-nav .suggestions { + max-width: 100vw; + width: calc(100% - 3.5rem); + margin: 10px 2.5em 4.5em 0; + } +} diff --git a/assets/default/pagefind_integration.js b/assets/default/pagefind_integration.js new file mode 100644 index 00000000..746ace91 --- /dev/null +++ b/assets/default/pagefind_integration.js @@ -0,0 +1,150 @@ +// custom search widget +(async function() { + const MAX_RESULTS = 20 + let FOCUSABLE_ELEMENTS = [] + let FOCUSED_ELEMENT_INDEX = 0 + + const pagefind = await import(window.MULTIDOCUMENTER_ROOT_PATH + "pagefind/pagefind.js") + + function initialize() { + pagefind.init() + registerSearchListener() + + document.body.addEventListener('keydown', ev => { + if (document.activeElement === document.body && (ev.key === '/' || ev.key === 's')) { + document.getElementById('search-input').focus() + ev.preventDefault() + } + }) + } + + function registerSearchListener() { + const input = document.getElementById('search-input') + const suggestions = document.getElementById('search-result-container') + + async function runSearch() { + const query = input.value + + const search = await pagefind.debouncedSearch(query, {}, 300); + + if (search) { + buildResults(search.results) + } + } + + input.addEventListener('keyup', ev => { + runSearch() + }) + + input.addEventListener('keydown', ev => { + if (ev.key === 'ArrowDown') { + FOCUSED_ELEMENT_INDEX = 0 + FOCUSABLE_ELEMENTS[FOCUSED_ELEMENT_INDEX].focus() + ev.preventDefault() + return + } else if (ev.key === 'ArrowUp') { + FOCUSED_ELEMENT_INDEX = FOCUSABLE_ELEMENTS.length - 1 + FOCUSABLE_ELEMENTS[FOCUSED_ELEMENT_INDEX].focus() + ev.preventDefault() + return + } + }) + + suggestions.addEventListener('keydown', ev => { + if (ev.key === 'ArrowDown') { + FOCUSED_ELEMENT_INDEX += 1 + if (FOCUSED_ELEMENT_INDEX < FOCUSABLE_ELEMENTS.length) { + FOCUSABLE_ELEMENTS[FOCUSED_ELEMENT_INDEX].focus() + } else { + FOCUSED_ELEMENT_INDEX = -1 + input.focus() + } + ev.preventDefault() + } else if (ev.key === 'ArrowUp') { + FOCUSED_ELEMENT_INDEX -= 1 + if (FOCUSED_ELEMENT_INDEX >= 0) { + FOCUSABLE_ELEMENTS[FOCUSED_ELEMENT_INDEX].focus() + } else { + FOCUSED_ELEMENT_INDEX = -1 + input.focus() + } + ev.preventDefault() + } + }) + + input.addEventListener('focus', ev => { + runSearch() + }) + } + + function renderResult(result) { + const entry = document.createElement('li') + entry.classList.add('suggestion') + + const linkContainer = document.createElement('a') + linkContainer.classList.add('suggestion-header') + linkContainer.setAttribute('href', result.url) + + const page = document.createElement('p') + page.classList.add('suggestion-title') + + const pageTitle = document.createElement('span') + pageTitle.innerText = result.title ?? result.meta.title + + page.appendChild(pageTitle) + + const excerpt = document.createElement('p') + excerpt.classList.add('suggestion-excerpt') + excerpt.innerHTML = result.excerpt + + linkContainer.appendChild(page) + linkContainer.appendChild(excerpt) + + entry.appendChild(linkContainer) + + return entry + } + + async function buildResults(results) { + const suggestions = document.getElementById('search-result-container') + + const children = await Promise.all(results.slice(0, MAX_RESULTS - 1).map(async (r, i) => { + const data = await r.data() + + const entry = renderResult(data) + + if (data.sub_results.length > 0) { + const subResults = document.createElement('ol') + subResults.classList.add('sub-suggestions') + + data.sub_results.forEach(subresult => { + const entry = renderResult(subresult) + subResults.appendChild(entry) + }) + entry.appendChild(subResults) + } + + return entry + })) + + if (results.length > 0) { + suggestions.classList.remove('hidden') + } else { + suggestions.classList.add('hidden') + } + + + suggestions.replaceChildren( + ...children + ) + + FOCUSED_ELEMENT_INDEX = -1 + FOCUSABLE_ELEMENTS = [...suggestions.querySelectorAll('a')] + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize) + } else { + initialize() + }; +})() diff --git a/docs/make.jl b/docs/make.jl index dfc83730..e3bcdb48 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -23,12 +23,12 @@ open(joinpath(@__DIR__, "src", "index.md"), write = true) do io io, """ -## Docstrings + ## Docstrings -```@autodocs -Modules = [MultiDocumenter] -``` -""", + ```@autodocs + Modules = [MultiDocumenter] + ``` + """, ) end cp( diff --git a/src/MultiDocumenter.jl b/src/MultiDocumenter.jl index cb085d87..896affca 100644 --- a/src/MultiDocumenter.jl +++ b/src/MultiDocumenter.jl @@ -5,31 +5,31 @@ using HypertextLiteral import Git: git module DocumenterTools -import Gumbo, AbstractTrees -include("documentertools/walkdocs.jl") -include("documentertools/canonical_urls.jl") + import Gumbo, AbstractTrees + include("documentertools/walkdocs.jl") + include("documentertools/canonical_urls.jl") end """ - SearchConfig(index_versions = ["stable"], engine = MultiDocumenter.FlexSearch, lowfi = false) + SearchConfig(index_versions = ["stable"], engine = MultiDocumenter.PageFind, lowfi = false) `index_versions` is a vector of relative paths used for generating the search index. Only the first matching path is considered. -`engine` may be `MultiDocumenter.FlexSearch`, `MultiDocumenter.Stork`, or a module that conforms -to the expected API (which is currently undocumented). +`engine` may be `MultiDocumenter.PageFind`, `MultiDocumenter.FlexSearch`, `MultiDocumenter.Stork`, +or a module that conforms to the expected API (which is currently undocumented). `lowfi = true` will try to minimize search index size. Only relevant for flexsearch. """ Base.@kwdef mutable struct SearchConfig index_versions = ["stable", "dev"] - engine = FlexSearch + engine = PageFind lowfi = false end """ abstract type DropdownComponent -The supertype for any component that can be put in a dropdown column and -rendered using `MultiDocumenter.render(::YourComponent, thispagepath, dir, prettyurls)`. +The supertype for any component that can be put in a dropdown column and +rendered using `MultiDocumenter.render(::YourComponent, thispagepath, dir, prettyurls)`. All `DropdownComponent`s go in [`Column`](@ref)s, which go in [`MegaDropdownNav`](@ref). @@ -72,14 +72,14 @@ struct MultiDocRef <: DropdownComponent end function MultiDocRef(; - upstream, - name, - path, - giturl = "", - branch = "gh-pages", - fix_canonical_url = true, -) - MultiDocRef(upstream, path, name, fix_canonical_url, giturl, branch) + upstream, + name, + path, + giturl = "", + branch = "gh-pages", + fix_canonical_url = true, + ) + return MultiDocRef(upstream, path, name, fix_canonical_url, giturl, branch) end """ @@ -128,15 +128,17 @@ function walk_outputs(f, root, docs::Vector, dirs::Vector{String}) break end end + return nothing end include("renderers.jl") +include("search/pagefind.jl") include("search/flexsearch.jl") include("search/stork.jl") include("canonical.jl") include("sitemap.jl") -const DEFAULT_ENGINE = SearchConfig(index_versions = ["stable", "dev"], engine = FlexSearch) +const DEFAULT_ENGINE = SearchConfig(; index_versions = ["stable", "dev"], engine = PageFind) """ make( @@ -174,22 +176,22 @@ Aggregates multiple Documenter.jl-based documentation pages `docs` into `outdir` - `sitemap_filename` can be used to override the default sitemap filename (`sitemap.xml`) """ function make( - outdir, - docs::Vector; - assets_dir = nothing, - brand_image::Union{Nothing,BrandImage} = nothing, - custom_stylesheets = [], - custom_scripts = [], - search_engine = DEFAULT_ENGINE, - prettyurls = true, - rootpath = "/", - hide_previews = true, - canonical_domain::Union{AbstractString,Nothing} = nothing, - sitemap::Bool = false, - sitemap_filename::AbstractString = "sitemap.xml", - # This keyword is for internal test use only: - _override_windows_isinteractive_check::Bool = false, -) + outdir, + docs::Vector; + assets_dir = nothing, + brand_image::Union{Nothing, BrandImage} = nothing, + custom_stylesheets = [], + custom_scripts = [], + search_engine = DEFAULT_ENGINE, + prettyurls = true, + rootpath = "/", + hide_previews = true, + canonical_domain::Union{AbstractString, Nothing} = nothing, + sitemap::Bool = false, + sitemap_filename::AbstractString = "sitemap.xml", + # This keyword is for internal test use only: + _override_windows_isinteractive_check::Bool = false, + ) if Sys.iswindows() && !isinteractive() if _override_windows_isinteractive_check || isinteractive() @warn """ @@ -213,9 +215,13 @@ function make( else !isnothing(canonical_domain) if !startswith(canonical_domain, r"^https?://") - throw(ArgumentError(""" - Invalid value for canonical_domain: $(canonical_domain) - Must start with http:// or https://""")) + throw( + ArgumentError( + """ + Invalid value for canonical_domain: $(canonical_domain) + Must start with http:// or https://""" + ) + ) end # We'll strip any trailing /-s though, in case the user passed something like # https://example.org/, because we want to concatenate the file paths with `/` @@ -246,13 +252,6 @@ function make( isdir(out_assets) || mkpath(out_assets) cp(joinpath(@__DIR__, "..", "assets", "default"), joinpath(out_assets, "default")) - if search_engine != false - if search_engine.engine == Stork && !Stork.has_stork() - @warn "stork binary not found. Falling back to flexsearch as search_engine." - search_engine = DEFAULT_ENGINE - end - end - inject_styles_and_global_navigation( dir, docs, @@ -347,11 +346,11 @@ function maybe_clone(docs::Vector) end function make_output_structure( - docs::Vector{DropdownComponent}, - prettyurls, - hide_previews; - canonical::Union{AbstractString,Nothing}, -) + docs::Vector{DropdownComponent}, + prettyurls, + hide_previews; + canonical::Union{AbstractString, Nothing}, + ) dir = mktempdir() for doc in Iterators.filter(x -> x isa MultiDocRef, docs) @@ -387,13 +386,13 @@ function make_output_structure( end function make_global_nav( - dir, - docs::Vector, - thispagepath, - brand_image, - search_engine, - prettyurls, -) + dir, + docs::Vector, + thispagepath, + brand_image, + search_engine, + prettyurls, + ) nav = @htl """