Output Formats

“Targets” represent different ways to output Project objects. This might be as a literal file or tree of files, or it may be test results from running doctests, or perhaps checking validity of external links.

writer(f, p::Project, temp::AbstractString) = f(p, temp)

html(p::Project, changed, temp) = writer(html, p, temp)
search(p::Project, changed, temp) = writer(search, p, temp)
pdf(p::Project, changed, temp) = writer(pdf, p, temp)
test(p::Project, changed, temp) = writer(test, p, temp)

"""
    html(source, [dir])

Write `source` to HTML format. `dir` optionally provides the directory to write
the final content to. When this directory is not provided then a temporary
directory is used.
"""
function html(source, temp=nothing)
    p = from_source(source)
    sandbox(temp) do
        # Clean up the index.html since we check it's existance for redirect page.
        rm("index.html"; force=true)
        # Copy over resources that are important to this target type.
        suitable_mimes = map(MIME, ("text/html", "text/javascript", "text/css"))
        write_resources(p, suitable_mimes)
        # Write all the pages out to html.
        toc_root = tocroot(p)
        # Define the page ordering. We partition the order so that we have
        # access to each page's previous and next siblings for use in page
        # navigation. For this we need a `fake` page at the start and end of
        # our document.
        fake = Ref(nothing => nothing)
        order = Iterators.flatten((fake, p.pages, p.docs, fake))
        for ((prev, _), (path, page), (next,_)) in IterTools.partition(order, 3, 1)
            rpath = relpath(path, toc_root)
            rdir = dirname(rpath)
            isdir(rdir) || mkpath(rdir)
            name, _ = splitext(rpath)
            html_file = name * ".html"
            # Calculate the previous and next page URLs.
            if prev !== nothing
                name, _ = splitext(relpath(prev, dirname(path)))
                p.env["publish"]["html"]["prev"] = "$name.html"
            end
            if next !== nothing
                name, _ = splitext(relpath(next, dirname(path)))
                p.env["publish"]["html"]["next"] = "$name.html"
            end
            # Calculate the table of contents for this page.
            p.env["publish"]["html"]["toc"] = build_html_toc(p, path)
            # Activate smart linking relative to the current `html_file`.
            p.env["publish"]["smartlink-engine"] = (mime, obj, node, env) -> smartlink(mime, obj, node, env, p, html_file, page)
            relative_paths(p, p.env["publish"], html_file) do pub
                open(html_file, "w") do handle
                    fm = frontmatter(page.node)
                    pub = isempty(fm) ? pub : CommonMark.recursive_merge(pub, fm)
                    CommonMark.html(handle, page.node, pub)
                end
            end
            # Delete previous and next pages.
            delete!(p.env["publish"]["html"], "prev")
            delete!(p.env["publish"]["html"], "next")
        end
        # Generate a fake index.html page that redirects to the first page in the p.pages dict.
        if !isfile("index.html") && !isempty(p.pages)
            path, _ = first(p.pages)
            rpath = relpath(path, toc_root)
            name, _ = splitext(rpath)
            content = """
            <!DOCTYPE html>
            <html><head><meta http-equiv = "refresh" content = "0; url = $name.html" /></head></html>
            """
            write("index.html", content)
        end
        # Build the search page, overwrites any search.html already written.
        html_file = "search.html"
        p.env["publish"]["html"]["toc"] = build_html_toc(p, joinpath(p.project.name))
        relative_paths(p, p.env["publish"], html_file) do pub
            open(html_file, "w") do handle
                node = load_markdown(IOBuffer("# Search\n<div id='search-results'></div>"))
                CommonMark.html(handle, node, pub)
            end
        end
        # Write the search data to file.
        search(p, pwd())
        # Build a basic versions.js file, this is overwritten by `deploy`.
        open("versions.js", "w") do handle
            println(handle, "const PUBLISH_ROOT = '';")
            println(handle, "const PUBLISH_VERSION = null;")
            println(handle, "const PUBLISH_VERSIONS = [];")
        end
        # Remove the generated table of contents string.
        delete!(p.env["publish"]["html"], "toc")
    end
    return source
end

First some helper methods for looking up the Binding object of a value.

function binding(mod::Module, expr::Expr)
    if Meta.isexpr(expr, :.)
        parent = binding(mod, expr.args[1])
        if Docs.defined(parent)
            return binding(Docs.resolve(parent), expr.args[2:end]...)
        end
    end
    return Docs.Binding(mod, nameof(mod))
end
binding(mod::Module, str::AbstractString) = binding(mod, Meta.parse(str; raise=false))
binding(mod::Module, symbol::Symbol) = Docs.Binding(mod, symbol)
binding(mod::Module, quot::QuoteNode) = Docs.Binding(mod, quot.value)
binding(mod, other...) = Docs.Binding(mod, nameof(mod))
binding(str::AbstractString) = binding(Main, str)
binding(mod::Module) = binding(mod, nameof(mod))

“Smart” link cross-referencing.

"""
    smartlink(mime, obj, node, env, p, html_file, page)

Generate a cross-reference link.
"""
function smartlink(
    ::MIME"text/html",
    obj::CommonMark.Link,
    node::CommonMark.Node,
    env,
    p::Project,
    html_file::AbstractString,
    page
)
    if obj.destination == "#"
        # The following closures are used in multiple branches and so are
        # defined above to avoid repetition.
        function docs_func!(literal::AbstractString)
            dict = page.dict === nothing ? Dict() : page.dict
            module_binding = binding(get(dict, "module", project_to_module(p.env)))
            if Docs.defined(module_binding)
                target_binding = binding(Docs.resolve(module_binding), literal)
                if Docs.defined(target_binding)
                    obj = deepcopy(obj)
                    rel = relpath("docstrings/$target_binding.html", dirname(html_file))
                    obj.destination = rel
                    @goto END
                end
            end
            @warn "cross-reference link '$literal' on page '$html_file' cound not be found."
            @label END
            return nothing
        end
        function header_func!(literal::AbstractString)
            toc_dir = joinpath(dirname(p.project.name), dirname(p.env["publish"]["toc"]))
            slug = CommonMark.slugify(literal)
            for (path, page) in p.pages, (node, enter) in page.node
                if enter && get(node.meta, "id", nothing) == slug
                    name, _ = splitext(relpath(relpath(path, toc_dir), dirname(html_file)))
                    obj = deepcopy(obj)
                    obj.destination = "$name.html#$slug"
                    obj.title = ""
                    @goto END
                end
            end
            @warn "cross-reference link '$literal' on page '$html_file' could not be found."
            @label END
            return nothing
        end
        # `#` is used for cross-references. The link is determined by either
        # the provided `.title` field of the link, or the contents of the link.
        if isempty(obj.title)
            # No title provided so we use the contents of the link.
            (!CommonMark.isnull(node.first_child) && node.first_child.t isa CommonMark.Code) ?
                docs_func!(node.first_child.literal) : header_func!(node.first_child.literal)
        else
            # The `.title` is available, so use that to determine the link.
            m = match(r"^`(.+)`$", obj.title)
            m === nothing ? header_func!(obj.title) : docs_func!(m[1])
        end
        return obj
    elseif startswith(obj.destination, '#')
        # Ignore bare anchor links.
        return obj
    else
        # Links that aren't cross-references are either left as is, or adjusted
        # to point at the generated `.html` file rather than the original
        # source file.
        uri = parse(HTTP.URIs.URI, obj.destination)
        uri.scheme == "" || return obj
        # Only try local URLs.
        obj = deepcopy(obj)
        if get(env, "#toc", false)

Adjust paths due to inclusion of a toc, which has a different path.

            rpath = joinpath(".", relpath(html_file, tocroot(p)))
            name, _ = splitext(relpath(obj.destination, dirname(rpath)))
            obj.destination = "$name.html"
        else
            name, _ = splitext(obj.destination)
            obj.destination = "$name.html"
        end
        return obj
    end
end
smartlink(mime, obj, node, env, p, html_file, page) = obj

And some other helpers needed for html generation.

function build_html_toc(p::Project, path::AbstractString)
    fn = (mime, obj, node, env) -> smartlink(mime, obj, node, env, p, path, nothing)
    return CommonMark.html(p.env["_toc"].node, Dict("smartlink-engine" => fn, "#toc" => true))
end

function relative_paths(f, p::Project, pub::AbstractDict, file::AbstractString)
    root = dirname(p.project.name)
    file_dir = dirname(joinpath(".", file))
    temp_pub = with_replacement(pub) do value
        if value in DEFAULT_ASSETS_SET
            # Default resources are set relative to the `src/templates`
            # directory.  which 'mirrors' the project root directory. Resources
            # with the same names that the user provides will get overwritten.
            rpath = relpath(value, joinpath(@__DIR__, "templates"))
            return relpath(rpath, file_dir)
        elseif isabspath(value) && isfile(value)
            # Handles template files.
            return value
        else
            path = joinpath(root, value)
            return _isfile(path) ? relpath(value, file_dir) : value
        end
    end
    f(temp_pub)
end

_isfile(s::AbstractString) = length(s) ≤ 144 && isfile(s) # TODO: hack for ENAMETOOLONG.

with_replacement(f, value) = value
with_replacement(f, value::AbstractString) = f(value)
with_replacement(f, vec::Vector{T}) where T = T[with_replacement(f, elem) for elem in vec]
with_replacement(f, dict::T) where T <: AbstractDict = T(k => with_replacement(f, v) for (k, v) in dict)

from_source(p::Project) = p
from_source(m::Module) = Project(m)
from_source(s::AbstractString) = Project(s)

JSON Search Data Target

function search(source, temp=nothing)
    p = from_source(source)
    dict = Dict{String,String}()
    root = tocroot(p)
    for (path, page) in Iterators.flatten((p.pages, p.docs))
        path = relpath(path, root)
        name, _ = splitext(path)
        path = "$name.html"
        id = path
        for (node, enter) in page.node
            if enter
                if haskey(node.meta, "id")
                    id = "$path#$(node.meta["id"])"
                end
                if (node.t isa CommonMark.Text || node.t isa CommonMark.Code)
                    if haskey(dict, id)
                        dict[id] = "$(dict[id]) $(node.literal)"
                    else
                        dict[id] = node.literal
                    end
                end
            end
        end
    end
    json = Dict{String,String}[]
    for (id, body) in dict
        push!(json, Dict("id" => id, "body" => body))
    end
    sandbox(temp) do
        write("search.json", JSON.json(json))
    end
    return source
end

PDF generation.

Our PDF creation uses a LaTeX engine, in the form of tectonic, to make PDF output.

"""
    pdf(source, [dir])

Write `source` project to PDF format. `dir` may optionally specify the
directory to write the finished document to. Intermediate `.tex` files are
retained for debugging purposes.
"""
function pdf(source, temp=nothing)
    p = from_source(source)
    sandbox(temp) do
        toc_root = tocroot(p)
        # Write pages to separate document and include it in main tex document.
        includes = IOBuffer()
        println(includes, "```{=latex}")
        for (path, page) in p.pages
            rpath = relpath(path, toc_root)
            name, _ = splitext(rpath)
            name = unix_style_path(name) # Path adjustments for Windows.
            println(includes, "\\include{$name}")
            out = tex(name)
            dir = dirname(out)
            isdir(dir) || mkpath(dir)
            open(out, "w") do handle
                CommonMark.latex(handle, page.node)
            end
        end
        println(includes, "```")
        parser = CommonMark.enable!(CommonMark.Parser(), CommonMark.RawContentRule())
        ast = load_markdown(includes, parser)
        project_file = tex(p.env["name"])
        open(project_file, "w") do handle
            CommonMark.latex(handle, ast, p.env["publish"])
        end
        # Build the final PDF document using tectonic.
        Tectonic.tectonic() do path
            run(`$path $project_file`)
        end
    end
    return source
end

# Needed by latex engine since it doesn't like windows paths.
if Sys.iswindows()
    unix_style_path(path) = unix_joinpath(splitpath(path)...)
else
    unix_style_path(path) = path
end
function unix_joinpath(path::AbstractString, paths::AbstractString...)::String
    for p in paths
        if isabspath(p)
            path = p
        elseif isempty(path) || path[end] == '/'
            path *= p
        else
            path *= "/" * p
        end
    end
    return path
end
unix_joinpath(path::AbstractString) = path

“Doctests” Stub

This is not implemented yet. Output should be the same as for the Test module. Probably use it to do the actual testing.

"""
    test(source)

Run all doctests defined within project `source`.
"""
function test(source, temp=nothing)
    p = from_source(source)
    sandbox(temp) do
        for (path, page) in p.pages, (node, enter) in page.node
            if enter && isdoctest(node)
                # TODO: do testing here.
            end
        end
    end
    return source
end

Live Server

Used to do iterative editing by avoiding the edit-compile-read-loop.

"""
    serve(source, [targets...])

Start watching the project defined by `source` for changes and rebuild it when
any occur. `targets` lists the functions to run when any changes take place. By
default this is [`html`](#), which runs a background HTTP server that presents
the generated HTML output at `localhost:8000`.
"""
function serve(source, targets...=html; kws...)
    p = from_source(source)
    w = WatchedProject(p, targets...)
    try
        for target in targets
            target(w; kws...)
        end
    finally
        wait() # Wait until all tasks done.
        stop(w)
    end
    return source
end

function html(w::WatchedProject; port=8000, kws...)
    dir = w.dirs[html]
    html(w.p, dir)
    @async LiveServer.serve(; dir=dir, port=port)
end

function search(w::WatchedProject; kws...)
    if haskey(w.dirs, html)
        # Search data is written to the HTML directory.
        dir = w.dirs[html]
        search(w.p, dir)
    end
end

function pdf(w::WatchedProject; kws...)
    dir = w.dirs[pdf]
    pdf(w.p, dir)
    file = joinpath(dir, w.p.env["name"] * ".pdf")
    run(`$PDF_VIEWER $file`; wait=false)
end

Utilities

const PDF_VIEWER = Sys.iswindows() ? "start" : Sys.isapple() ? "open" : "xdg-open"

isdoctest(n) = n.t isa CommonMark.CodeBlock && n.t.info == "jldoctest"

function write_resources(p::Project, mimes)
    project_root = projectroot(p)
    assets_root = joinpath(@__DIR__, "templates")
    for (path, file) in p.resources
        rpath = relpath(path, path in DEFAULT_ASSETS_SET ? assets_root : project_root)
        dir = dirname(rpath)
        if file.text !== nothing && file.mime in mimes
            isdir(dir) || mkpath(dir)
            write(rpath, file.text)
        end
    end
end

sandbox(f, ::Nothing) = mktempdir(dir -> cd(f, dir))
sandbox(f, temp::AbstractString) = isdir(temp) ? cd(f, temp) : (mkpath(temp); cd(f, temp))

tocroot(p::Project) = abspath(joinpath(dirname(p.project.name), dirname(p.env["publish"]["toc"])))
projectroot(p::Project) = abspath(dirname(p.project.name))
tex(filename::AbstractString) = filename * ".tex"