diff --git a/ext/HypertextTemplatesCommonMarkExt.jl b/ext/HypertextTemplatesCommonMarkExt.jl index a9afc0f..df50de9 100644 --- a/ext/HypertextTemplatesCommonMarkExt.jl +++ b/ext/HypertextTemplatesCommonMarkExt.jl @@ -28,10 +28,19 @@ function HypertextTemplates._handle_commonmark_ext( props = join(get(Vector{String}, frontmatter, "props"), " ") props = isempty(props) ? props : " $props" - str = "$(CommonMark.html(ast))" + str = "$(html_str(ast))" return HypertextTemplates._components_from_str(str, file, mod) end -function format_prop(k) end +# Custom wrapper around CommonMark's HTML writer so that we can enable sourcepos +# which allows us to use goto definition in browsers when dev mode is enabled. +function html_str(ast::CommonMark.Node) + html = CommonMark.HTML(; sourcepos = true) + io = IOBuffer() + env = Dict{String,Any}() + w = CommonMark.Writer(html, io, env) + CommonMark.write_html(w, ast) + return String(take!(io)) +end end diff --git a/ext/HypertextTemplatesReviseExt.jl b/ext/HypertextTemplatesReviseExt.jl index 22c8dd6..faa9570 100644 --- a/ext/HypertextTemplatesReviseExt.jl +++ b/ext/HypertextTemplatesReviseExt.jl @@ -24,7 +24,10 @@ function HypertextTemplates.is_stale_template(file::AbstractString, previous_mti end function HypertextTemplates._data_filename_attr(file::String, line::Int) - if HypertextTemplates._DATA_FILENAME_ATTR[] + # Skip the `data-htloc` attribute if the line is invalid. The way that the + # browser lookup handles this is that it will look to the parent element for + # the `data-htloc` attribute if it doesn't exist on the current element. + if HypertextTemplates._DATA_FILENAME_ATTR[] && line > 0 filekey = HypertextTemplates._register_filename_mapping!(file) return [Symbol("data-htloc") => "$filekey:$line"] else diff --git a/src/HypertextTemplates.jl b/src/HypertextTemplates.jl index 74124e3..c59afdc 100644 --- a/src/HypertextTemplates.jl +++ b/src/HypertextTemplates.jl @@ -16,7 +16,8 @@ end # Exports. -export @template_str, @custom_element, @create_template_str, render, TemplateFileLookup +export @template_str, + @custom_element, @create_template_str, render, TemplateFileLookup, slots # Constants. diff --git a/src/nodes/abstract.jl b/src/nodes/abstract.jl index f2a832e..59d1b1f 100644 --- a/src/nodes/abstract.jl +++ b/src/nodes/abstract.jl @@ -2,26 +2,26 @@ const FALLBACK_TAG = "fallback" abstract type AbstractNode end -transform(ns::Vector{EzXML.Node}) = [transform(n) for n in ns] +transform(ctx, ns::Vector{EzXML.Node}) = [transform(ctx, n) for n in ns] -function transform(n::EzXML.Node) +function transform(ctx, n::EzXML.Node) if EzXML.istext(n) - return Text(n) + return Text(ctx, n) else if EzXML.iselement(n) tag = EzXML.nodename(n) if tag == FOR_TAG - return For(n) + return For(ctx, n) elseif tag == JULIA_TAG - return Julia(n) + return Julia(ctx, n) elseif tag == SHOW_TAG - return Show(n) + return Show(ctx, n) elseif tag == MATCH_TAG - return Match(n) + return Match(ctx, n) elseif tag == SLOT_TAG - return Slot(n) + return Slot(ctx, n) else - return Element(n) + return Element(ctx, n) end else return Text(cdata(n), 0) diff --git a/src/nodes/element.jl b/src/nodes/element.jl index e62bb31..4620bcc 100644 --- a/src/nodes/element.jl +++ b/src/nodes/element.jl @@ -43,20 +43,47 @@ struct Element <: AbstractNode slots::Vector{Pair{String,Vector{AbstractNode}}} line::Int - function Element(name, attributes, body, slots, line) + function Element(ctx, name, attributes, body, slots, line) + attributes, line = _translate_data_sourcepos(ctx, attributes, line) return new(_restore_special_symbols(name), attributes, body, slots, line) end end -function Element(n::EzXML.Node) +# CommonMark HTML output stores a `data-sourcepos` attribute on each element +# to track source position. We use a slightly different attribute name in +# HypertextTemplates and don't care about column information. +function _translate_data_sourcepos(ctx, attributes::Vector, line::Integer) + attrs = Attribute[] + sourcepos = nothing + for each in attributes + if each.name == "data-sourcepos" + sourcepos = each.value + else + push!(attrs, each) + end + end + if isnothing(sourcepos) + return attrs, ctx.markdown ? 0 : line + else + m = match(r"(\d+):(\d+)-(\d+):(\d+)", sourcepos) + if isnothing(m) + return attrs, 0 + else + line = something(tryparse(Int, m.captures[1]), 0) + return attrs, line + end + end +end + +function Element(ctx, n::EzXML.Node) name = EzXML.nodename(n) if name in RESERVED_ELEMENT_NAMES error("elements cannot be named after reserved node names: $name") end attrs = attributes(n) if name in VALID_HTML_ELEMENTS - body = transform(EzXML.nodes(n)) - return Element(name, Attribute.(attrs), body, [], nodeline(n)) + body = transform(ctx, EzXML.nodes(n)) + return Element(ctx, name, Attribute.(attrs), body, [], nodeline(n)) else slots = [] nodes = EzXML.nodes(n) @@ -66,15 +93,22 @@ function Element(n::EzXML.Node) if contains(tag, ':') tag, slot = split(tag, ':'; limit = 2) child = - Element(tag, [], transform(EzXML.nodes(each)), [], nodeline(each)) + Element( + ctx, + tag, + [], + transform(ctx, EzXML.nodes(each)), + [], + nodeline(each), + ) push!(slots, slot => [child]) end end end if isempty(slots) - push!(slots, UNNAMED_SLOT => transform(nodes)) + push!(slots, UNNAMED_SLOT => transform(ctx, nodes)) end - return Element(name, Attribute.(attrs), [], slots, nodeline(n)) + return Element(ctx, name, Attribute.(attrs), [], slots, nodeline(n)) end end diff --git a/src/nodes/for.jl b/src/nodes/for.jl index 49768ec..2e38b20 100644 --- a/src/nodes/for.jl +++ b/src/nodes/for.jl @@ -18,7 +18,7 @@ struct For <: AbstractNode end end -function For(n::EzXML.Node) +function For(ctx, n::EzXML.Node) tag = EzXML.nodename(n) if tag == FOR_TAG attrs = Dict(attributes(n)) @@ -27,7 +27,7 @@ function For(n::EzXML.Node) iter = key_default(attrs, "iter") item = key_default(attrs, "item") index = key_default(attrs, "index") - return For(iter, item, index, transform(EzXML.nodes(n)), nodeline(n)) + return For(iter, item, index, transform(ctx, EzXML.nodes(n)), nodeline(n)) else error("expected a '' tag, found: $tag") end diff --git a/src/nodes/function.jl b/src/nodes/function.jl index d9be72f..dda89ea 100644 --- a/src/nodes/function.jl +++ b/src/nodes/function.jl @@ -259,6 +259,7 @@ struct TemplateFunction end function TemplateFunction(n::EzXML.Node, file::String, mod::Module) + ctx = (; markdown = endswith(file, ".md")) if isabspath(file) if EzXML.iselement(n) tag = EzXML.nodename(n) @@ -271,7 +272,7 @@ function TemplateFunction(n::EzXML.Node, file::String, mod::Module) name, false, Prop.(props), - transform(EzXML.nodes(n)), + transform(ctx, EzXML.nodes(n)), file, mod, nodeline(n), @@ -290,7 +291,7 @@ function TemplateFunction(n::EzXML.Node, file::String, mod::Module) name, true, [], - transform(EzXML.nodes(n)), + transform(ctx, EzXML.nodes(n)), file, mod, nodeline(n), @@ -374,6 +375,9 @@ function expression(c::TemplateFunction)::Expr end return nothing end + function $(name)(default_slots::NamedTuple = (;); default_props...) + return $(delay_io)($(name), default_slots, default_props) + end end else props = expression(context, c.props) @@ -402,6 +406,9 @@ function expression(c::TemplateFunction)::Expr end return nothing end + function $(name)(default_slots::NamedTuple = (;); default_props...) + return $(delay_io)($(name), default_slots, default_props) + end end end return expr |> lln_replacer(c.file, c.line) @@ -417,3 +424,9 @@ function _recompile_template(mod::Module, file::String, mtime::Float64) return false end end + +function delay_io(name, default_slots, default_props) + return function (io::IO, slots::NamedTuple = (;); props...) + name(io, merge(default_slots, slots); default_props..., props...) + end +end diff --git a/src/nodes/julia.jl b/src/nodes/julia.jl index 30b7f9c..e06af84 100644 --- a/src/nodes/julia.jl +++ b/src/nodes/julia.jl @@ -9,7 +9,7 @@ struct Julia <: AbstractNode end end -function Julia(n::EzXML.Node) +function Julia(ctx, n::EzXML.Node) attrs = attributes(n) if length(attrs) == 1 (name, value), = attrs diff --git a/src/nodes/match.jl b/src/nodes/match.jl index b05974d..34b2cf7 100644 --- a/src/nodes/match.jl +++ b/src/nodes/match.jl @@ -20,7 +20,7 @@ struct Match <: AbstractNode cases::Vector{Case} line::Int - function Match(n::EzXML.Node) + function Match(ctx, n::EzXML.Node) tag = EzXML.nodename(n) if tag == MATCH_TAG nodes, fallback = split_fallback(n) @@ -32,7 +32,7 @@ struct Match <: AbstractNode attrs = Dict(attributes(each)) if length(attrs) == 1 when = key_default(attrs, "when") - body = transform(EzXML.nodes(each)) + body = transform(ctx, EzXML.nodes(each)) push!(cases, Case(when, body, nodeline(each))) else error("'match' nodes require a single attribute.") diff --git a/src/nodes/show.jl b/src/nodes/show.jl index ee72953..06f5095 100644 --- a/src/nodes/show.jl +++ b/src/nodes/show.jl @@ -11,7 +11,7 @@ struct Show <: AbstractNode end end -function Show(n::EzXML.Node) +function Show(ctx, n::EzXML.Node) tag = EzXML.nodename(n) if tag == SHOW_TAG attrs = Dict(attributes(n)) @@ -20,8 +20,8 @@ function Show(n::EzXML.Node) nodes, fallback = split_fallback(n) return Show( when, - transform(nodes), - isnothing(fallback) ? [] : transform(EzXML.nodes(fallback)), + transform(ctx, nodes), + isnothing(fallback) ? [] : transform(ctx, EzXML.nodes(fallback)), nodeline(n), ) else diff --git a/src/nodes/slot.jl b/src/nodes/slot.jl index 7823c60..de1d0de 100644 --- a/src/nodes/slot.jl +++ b/src/nodes/slot.jl @@ -10,7 +10,7 @@ struct Slot <: AbstractNode end end -function Slot(n::EzXML.Node) +function Slot(ctx, n::EzXML.Node) attrs = attributes(n) if isempty(attrs) return Slot(nothing, nodeline(n)) @@ -36,3 +36,27 @@ function expression(c::BuilderContext, s::Slot) :($(c.slots).$(name)($(c.io))) end |> lln_replacer(c.file, s.line) end + +""" + slots([unnamed]; named...) + +Helper function to construct `NamedTuple`s to pass to templates as their slots. + +This function is useful when composing template functions within Julia code +rather than within template files templates. For example when rendering a +markdown template with a specific wrapper layout. + +```julia +base_layout(stdout, slots(markdown(; title = "My Title"))) +``` + +The above does several things: + + - Delays the rendering of the `markdown` template until the `base_layout` + template requests rendering within it's unnamed slot. + - Creates the `slots` object to assign the delayed `markdown` template to the + unnamed slot. + - Renders the `base_layout` template to `stdout` with the `slots` object, which + will render the `markdown` template within the unnamed slot. +""" +slots(unnamed = (io) -> nothing; named...) = (; named..., Symbol(UNNAMED_SLOT) => unnamed) diff --git a/src/nodes/text.jl b/src/nodes/text.jl index b601942..c22992e 100644 --- a/src/nodes/text.jl +++ b/src/nodes/text.jl @@ -7,7 +7,7 @@ struct Text <: AbstractNode end end -function Text(n::EzXML.Node) +function Text(ctx, n::EzXML.Node) if EzXML.istext(n) content = EzXML.nodecontent(n) diff --git a/test/runtests.jl b/test/runtests.jl index 60f571c..7a617a9 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -332,5 +332,28 @@ end @test contains(html, "$(mapping["button.html"]):2") @test contains(html, "$(mapping["dropdown.html"]):2") @test contains(html, "$(mapping["dropdown.html"]):3") + + html = render(Templates.Markdown.var"custom-markdown-name"; prop = "prop-value") + @test contains(html, "data-htloc") + + mapping = Dict( + basename(file) => line for + (file, line) in HypertextTemplates.DATA_FILENAME_MAPPING + ) + @test contains(html, "$(mapping["markdown.md"]):8") + @test contains(html, "$(mapping["markdown.md"]):10") + @test contains(html, "$(mapping["markdown.md"]):12") + + HypertextTemplates._DATA_FILENAME_ATTR[] = false + end + + @testset "composed templates" begin + template = Templates.Complex.var"base-layout"( + slots(Templates.Markdown.var"custom-markdown-name"(; prop = "prop-value")); + title = "title", + ) + html = render(template) + @test contains(html, "