diff --git a/.gitignore b/.gitignore index 3a88d5b..5c59bec 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ *.Manifest.toml docs/build *ipynb* +*.DS_Store diff --git a/Project.toml b/Project.toml index f7510b4..82626d7 100644 --- a/Project.toml +++ b/Project.toml @@ -5,6 +5,7 @@ version = "0.5.3" [deps] DefaultApplication = "3f0dd361-4fe0-5fc6-8523-80b14ec94d85" +EnumX = "4e289a0a-7415-4d19-859d-a7e5c4648b56" Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" diff --git a/docs/make.jl b/docs/make.jl index 488c681..ab69186 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -2,24 +2,22 @@ using Cobweb using Cobweb: h, Page, CSS using Markdown -css = Dict( - "html" => Dict( - "font-family" => "Arial" - ) -) - page = h.html( h.head( h.meta(charset="UTF-8"), h.meta(name="viewport", content="width=device-width, initial-scale=1.0"), h.title("Cobweb.jl Docs"), - CSS(css), + css""" + html { + font-family: Arial; + } + """ ), h.body( h.h1("This page was built with ", h.code("Cobweb.jl"), "."), - Markdown.parse(""" + md""" Take a look at [`docs/make.jl`](https://github.com/joshday/Cobweb.jl/blob/main/docs/make.jl) inside the [`Cobweb.jl` repo](https://github.com/joshday/Cobweb.jl). - """), + """, h.button("Click Me for an alert!", onclick="buttonClicked()"), Cobweb.Javascript("const buttonClicked = () => alert('This button was clicked!')"), ) @@ -27,4 +25,4 @@ page = h.html( index_html = touch(joinpath(mkpath(joinpath(@__DIR__, "build")), "index.html")) -Cobweb.save(Page(page), index_html) +open(io -> write(io, page), index_html, "w") diff --git a/src/Cobweb.jl b/src/Cobweb.jl index 937dc8f..ae1380b 100644 --- a/src/Cobweb.jl +++ b/src/Cobweb.jl @@ -3,37 +3,62 @@ module Cobweb using DefaultApplication: DefaultApplication using Scratch: @get_scratch! using OrderedCollections: OrderedDict +using EnumX -export Page, Tab, IFrame +export Page, Tab, h, preview, @js_str, @css_str, IFrame #-----------------------------------------------------------------------------# init -struct CobwebDisplay <: AbstractDisplay end - function __init__() global DIR = @get_scratch!("CobWeb") - isdefined(Main, :VSCodeServer) || pushdisplay(CobwebDisplay()) end +#-----------------------------------------------------------------------------# preview +function preview(content; reuse=true) + file = reuse ? joinpath(DIR, "index.html") : string(tempname(), ".html") + content2 = showable("text/html", content) ? content : HTML(content) + Base.open(io -> show(io, MIME("text/html"), content2), touch(file), "w") + DefaultApplication.open(file) +end + +function Page(x) + Base.depwarn("Page(x) is deprecated. Use preview(x) instead.", :Page; force=true) + preview(x) +end +function Tab(x) + Base.depwarn("Tab(x) is deprecated. Use preview(x; reuse=false) instead.", :Tab; force=true) + preview(x; reuse=false) +end + +#-----------------------------------------------------------------------------# consts & "enums" +const HTML5_TAGS = [:a,:abbr,:address,:area,:article,:aside,:audio,:b,:base,:bdi,:bdo,:blockquote,:body,:br,:button,:canvas,:caption,:cite,:code,:col,:colgroup,:command,:datalist,:dd,:del,:details,:dfn,:dialog,:div,:dl,:dt,:em,:embed,:fieldset,:figcaption,:figure,:footer,:form,:h1,:h2,:h3,:h4,:h5,:h6,:head,:header,:hgroup,:hr,:html,:i,:iframe,:img,:input,:ins,:kbd,:label,:legend,:li,:link,:main,:map,:mark,:math,:menu,:menuitem,:meta,:meter,:nav,:noscript,:object,:ol,:optgroup,:option,:output,:p,:param,:picture,:pre,:progress,:q,:rb,:rp,:rt,:rtc,:ruby,:s,:samp,:script,:section,:select,:slot,:small,:source,:span,:strong,:style,:sub,:summary,:sup,:svg,:table,:tbody,:td,:template,:textarea,:tfoot,:th,:thead,:time,:title,:tr,:track,:u,:ul,:var,:video,:wbr] + +const VOID_ELEMENTS = [:area,:base,:br,:col,:command,:embed,:hr,:img,:input,:keygen,:link,:meta,:param,:source,:track,:wbr] + +const SVG2_TAGS = [:a,:animate,:animateMotion,:animateTransform,:audio,:canvas,:circle,:clipPath,:defs,:desc,:discard,:ellipse,:feBlend,:feColorMatrix,:feComponentTransfer,:feComposite,:feConvolveMatrix,:feDiffuseLighting,:feDisplacementMap,:feDistantLight,:feDropShadow,:feFlood,:feFuncA,:feFuncB,:feFuncG,:feFuncR,:feGaussianBlur,:feImage,:feMerge,:feMergeNode,:feMorphology,:feOffset,:fePointLight,:feSpecularLighting,:feSpotLight,:feTile,:feTurbulence,:filter,:foreignObject,:g,:iframe,:image,:line,:linearGradient,:marker,:mask,:metadata,:mpath,:path,:pattern,:polygon,:polyline,:radialGradient,:rect,:script,:set,:stop,:style,:svg,:switch,:symbol,:text,:textPath,:title,:tspan,:unknown,:use,:video,:view] + +const css_units = (ch="ch", cm="cm", em="em", ex="ex", fr="fr", in="in", mm="mm", pc="pc", percent="%", + pt="pt", px="px", rem="rem", vh="vh", vmax="vmax", vmin="vmin", vw="vw") #-----------------------------------------------------------------------------# Node """ - Node(tag::String, attrs::Dict{String,String}, children::Vector) + Node(tag::Symbol, attrs::OrderedDict{Symbol,String}, children::Vector) Should not often be used directly. See `?Cobweb.h`. """ struct Node - tag::String - attrs::OrderedDict{String,String} + tag::Symbol + attrs::OrderedDict{Symbol, String} children::Vector{Any} - function Node(tag::AbstractString, attrs::AbstractDict, children::AbstractVector) - new(string(tag), OrderedDict(string(k) => string(v) for (k,v) in pairs(attrs)), collect(children)) - end +end +function Node(tag::Symbol, attrs::OrderedDict{Symbol, String}, children::AbstractVector) + tag in HTML5_TAGS || @warn "<$tag> is not a valid HTML5 tag." + Node(tag, attrs, collect(children)) end tag(o::Node) = getfield(o, :tag) attrs(o::Node) = getfield(o, :attrs) children(o::Node) = getfield(o, :children) -attrs(kw::AbstractDict) = OrderedDict(string(k) => string(v) for (k,v) in kw) +attrs(kw::AbstractDict) = OrderedDict(Symbol(k) => string(v) for (k,v) in pairs(kw)) (o::Node)(x...; kw...) = Node(tag(o), merge(attrs(o), attrs(kw)), vcat(children(o), x...)) @@ -44,8 +69,8 @@ Base.getproperty(o::Node, class::String) = o(class = lstrip(get(o, :class, "") * # methods that pass through to attrs(o) Base.propertynames(o::Node) = Symbol.(keys(o)) -Base.getproperty(o::Node, name::Symbol) = attrs(o)[string(name)] -Base.setproperty!(o::Node, name::Symbol, x) = attrs(o)[string(name)] = string(x) +Base.getproperty(o::Node, name::Symbol) = attrs(o)[name] +Base.setproperty!(o::Node, name::Symbol, x) = attrs(o)[name] = string(x) Base.get(o::Node, name, val) = get(attrs(o), string(name), string(val)) Base.get!(o::Node, name, val) = get!(attrs(o), string(name), string(val)) Base.haskey(o::Node, name) = haskey(attrs(o), string(name)) @@ -61,6 +86,60 @@ Base.iterate(o::Node, state) = iterate(children(o), state) Base.push!(o::Node, x) = push!(children(o), x) Base.append!(o::Node, x) = append!(children(o), x) +#-----------------------------------------------------------------------------# show Node +function print_opening_tag(io::IO, o::Node; self_close::Bool = false) + print(io, '<', tag(o)) + for (k,v) in attrs(o) + v == "true" ? print(io, ' ', k) : v != "false" && print(io, ' ', k, '=', '"', v, '"') + end + self_close && Symbol(tag(o)) ∉ VOID_ELEMENTS && length(children(o)) == 0 ? + print(io, " />") : + print(io, '>') +end + +function Base.show(io::IO, o::Node) + p(args...) = print(io, args...) + print_opening_tag(io, o) + foreach(x -> showable("text/html", x) ? show(io, MIME("text/html"), x) : p(x), children(o)) + p("') +end + +Base.show(io::IO, ::MIME"text/html", node::Node) = show(io, node) +Base.show(io::IO, ::MIME"text/xml", node::Node) = show(io, node) +Base.show(io::IO, ::MIME"application/xml", node::Node) = show(io, node) + +Base.write(io::IO, node::Node) = show(io, MIME"text/html"(), node) + +function pretty(io::IO, o::Node; depth=get(io, :depth, 0), indent=get(io, :indent, " "), self_close = get(io, :self_close, true)) + p(args...) = print(io, args...) + p(indent ^ depth) + print_opening_tag(io, o; self_close) + if length(children(o)) == 1 && !(only(o) isa Node) + x = only(o) + txt = showable("text/html", x) ? repr("text/html", x) : string(x) + if occursin('\n', txt) + println(io) + foreach(line -> p(indent ^ (depth+1), line, '\n'), lstrip.(split(txt, '\n'))) + p(indent ^ depth, "') + else + p(txt) + p("') + end + elseif length(children(o)) > 1 + child_io = IOContext(io, :depth => depth + 1, :indent => indent) + for child in children(o) + println(io) + pretty(child_io, child) + end + p('\n', indent ^ depth, "') + end +end +function pretty(io::IO, x; depth=get(io, :depth, 0), indent=get(io, :indent, " ")) + print(io, indent ^ depth) + showable("text/html", x) ? show(io, MIME("text/html"), x) : print(io, x) +end +pretty(x; kw...) = (io = IOBuffer(); pretty(io, x; kw...); String(take!(io))) + #-----------------------------------------------------------------------------# h """ h(tag, children...; kw...) @@ -81,16 +160,11 @@ h(tag, children...; kw...) = Node(tag, attrs(kw), collect(children)) h(tag, attrs::AbstractDict, children...) = Node(tag, attrs, collect(children)) -Base.getproperty(::typeof(h), tag::Symbol) = h(string(tag)) +Base.getproperty(::typeof(h), tag::Symbol) = h(tag) + Base.propertynames(::typeof(h)) = HTML5_TAGS #-----------------------------------------------------------------------------# @h -const HTML5_TAGS = [:a,:abbr,:address,:area,:article,:aside,:audio,:b,:base,:bdi,:bdo,:blockquote,:body,:br,:button,:canvas,:caption,:cite,:code,:col,:colgroup,:data,:datalist,:dd,:del,:details,:dfn,:dialog,:div,:dl,:dt,:em,:embed,:fieldset,:figcaption,:figure,:footer,:form,:h1,:h2,:h3,:h4,:h5,:h6,:head,:header,:hgroup,:hr,:html,:i,:iframe,:img,:input,:ins,:kbd,:label,:legend,:li,:link,:main,:map,:mark,:math,:menu,:menuitem,:meta,:meter,:nav,:noscript,:object,:ol,:optgroup,:option,:output,:p,:param,:picture,:pre,:progress,:q,:rb,:rp,:rt,:rtc,:ruby,:s,:samp,:script,:section,:select,:slot,:small,:source,:span,:strong,:style,:sub,:summary,:sup,:svg,:table,:tbody,:td,:template,:textarea,:tfoot,:th,:thead,:time,:title,:tr,:track,:u,:ul,:var,:video,:wbr] - -const VOID_ELEMENTS = [:area,:base,:br,:col,:command,:embed,:hr,:img,:input,:keygen,:link,:meta,:param,:source,:track,:wbr] - -SVG2_TAGS = [:a,:animate,:animateMotion,:animateTransform,:audio,:canvas,:circle,:clipPath,:defs,:desc,:discard,:ellipse,:feBlend,:feColorMatrix,:feComponentTransfer,:feComposite,:feConvolveMatrix,:feDiffuseLighting,:feDisplacementMap,:feDistantLight,:feDropShadow,:feFlood,:feFuncA,:feFuncB,:feFuncG,:feFuncR,:feGaussianBlur,:feImage,:feMerge,:feMergeNode,:feMorphology,:feOffset,:fePointLight,:feSpecularLighting,:feSpotLight,:feTile,:feTurbulence,:filter,:foreignObject,:g,:iframe,:image,:line,:linearGradient,:marker,:mask,:metadata,:mpath,:path,:pattern,:polygon,:polyline,:radialGradient,:rect,:script,:set,:stop,:style,:svg,:switch,:symbol,:text,:textPath,:title,:tspan,:unknown,:use,:video,:view] - macro h(ex) esc(_h(ex)) end @@ -106,12 +180,12 @@ function _h(ex::Expr) end ex end -_h(x::Symbol) = x in HTML5_TAGS ? Expr(:., :(Cobweb.h), QuoteNode(x)) : x +_h(x::Symbol) = x in propertynames(typeof(h)) ? Expr(:., :(Cobweb.h), QuoteNode(x)) : x #-----------------------------------------------------------------------------# escape escape_chars = ['&' => "&", '"' => """, ''' => "'", '<' => "<", '>' => ">"] -function escape(x; patterns = escape_chars) +function escape(x::AbstractString; patterns = escape_chars) for pat in patterns x = replace(x, pat) end @@ -120,106 +194,64 @@ end unescape(x::AbstractString) = escape(x; patterns = reverse.(escape_chars)) -#-----------------------------------------------------------------------------# show (html) -function print_opening_tag(io::IO, o::Node; self_close::Bool = false) - print(io, '<', tag(o)) - for (k,v) in attrs(o) - v == "true" ? print(io, ' ', k) : v != "false" && print(io, ' ', k, '=', '"', v, '"') - end - self_close && Symbol(tag(o)) ∉ VOID_ELEMENTS && length(children(o)) == 0 ? - print(io, " />") : - print(io, '>') -end - -function Base.show(io::IO, o::Node) - p(args...) = print(io, args...) - print_opening_tag(io, o) - foreach(x -> showable("text/html", x) ? show(io, MIME("text/html"), x) : p(x), children(o)) - p("') -end -Base.show(io::IO, ::MIME"text/html", node::Node) = show(io, node) -Base.show(io::IO, ::MIME"text/xml", node::Node) = show(io, node) -Base.show(io::IO, ::MIME"application/xml", node::Node) = show(io, node) - -function pretty(io::IO, o::Node; depth=get(io, :depth, 0), indent=get(io, :indent, " "), self_close = get(io, :self_close, true)) - p(args...) = print(io, args...) - p(indent ^ depth) - print_opening_tag(io, o; self_close) - if length(children(o)) == 1 && !(only(o) isa Node) - x = only(o) - txt = showable("text/html", x) ? repr("text/html", x) : string(x) - if occursin('\n', txt) - println(io) - foreach(line -> p(indent ^ (depth+1), line, '\n'), lstrip.(split(txt, '\n'))) - p(indent ^ depth, "') - else - p(txt) - p("') - end - elseif length(children(o)) > 1 - child_io = IOContext(io, :depth => depth + 1, :indent => indent) - for child in children(o) - println(io) - pretty(child_io, child) - end - p('\n', indent ^ depth, "') - end -end -function pretty(io::IO, x; depth=get(io, :depth, 0), indent=get(io, :indent, " ")) - print(io, indent ^ depth) - showable("text/html", x) ? show(io, MIME("text/html"), x) : print(io, x) -end -pretty(x; kw...) = (io = IOBuffer(); pretty(io, x; kw...); String(take!(io))) +#-----------------------------------------------------------------------------# Javascript +""" + Javascript(content::String) -#-----------------------------------------------------------------------------# show (javascript) +String wrapper to identify content as Javascript. Will be displayed appropriately in text/javascript and text/html mime types. +""" struct Javascript x::String end Base.show(io::IO, ::MIME"text/javascript", j::Javascript) = print(io, j.x) Base.show(io::IO, ::MIME"text/html", j::Javascript) = print(io, "") -#-----------------------------------------------------------------------------# CSS + +# TODO: validate Javascript content """ - CSS(::AbstractDict) + js"content" -Write CSS with a nested dictionary with keys (`selector => (property => value)`). +Same as `Javascript("content")`. +""" +macro js_str(x) + esc(Javascript(string(x))) +end -### Example +#-----------------------------------------------------------------------------# CSS +""" + CSS(content::String) - CSS(Dict( - "p" => Dict( - "font-family" => "Arial", - "text-transform" => "uppercase" - ) - )) +Wrapper to identify content as CSS. Will be displayed appropriately in text/css and text/html mime types. """ struct CSS - content::OrderedDict{String, OrderedDict{String,String}} - function CSS(o::AbstractDict) - new(OrderedDict(string(k) => OrderedDict(string(k2) => string(v2) for (k2,v2) in pairs(v)) for (k,v) in pairs(o))) - end + x::String end -function Base.show(io::IO, o::CSS) - for (k,v) in o.content +CSS(x::AbstractDict) = CSS(OrderedDict(string(k) => OrderedDict(string(k2) => string(v2) for (k2,v2) in pairs(v)) for (k,v) in pairs(x))) +function CSS(x::OrderedDict{String, OrderedDict{String, String}}) + io = IOBuffer() + for (k,v) in pairs(x) println(io, k, " {") for (k2, v2) in v println(io, " ", k2, ':', ' ', v2, ';') end println(io, '}') end + CSS(String(take!(io))) end -Base.show(io::IO, ::MIME"text/css", o::CSS) = print(io, o) -Base.show(io::IO, ::MIME"text/html", o::CSS) = show(io, h.style(repr("text/css", o))) -save(file::String, o::CSS) = save(o, file) -save(o::CSS, file::String) = open(io -> show(io, x), touch(file), "w") - -#-----------------------------------------------------------------------------# CSSUnits -baremodule CSSUnits - using Base: @eval, string - for k in [:ch,:cm,:em,:ex,:fr,:in,:mm,:pc,:percent,:pt,:px,:rem,:vh,:vmax,:vmin,:vw] - @eval ($k = string($(QuoteNode(k))); export $k) - end + +Base.show(io::IO, ::MIME"text/css", c::CSS) = print(io, c.x) +Base.show(io::IO, ::MIME"text/html", c::CSS) = print(io, "") + + +# TODO: validate css content +""" + css"content" + +Same as `CSS("content")`. +""" +macro css_str(x) + esc(CSS(string(x))) end #-----------------------------------------------------------------------------# Doctype @@ -227,8 +259,7 @@ struct Doctype content::String end Doctype() = Doctype("") -Base.show(io::IO, o::Doctype) = print(io, "") -Base.show(io::IO, ::MIME"text/html", o::Doctype) = show(io, o) +Base.show(io::IO, ::MIME"text/html", o::Doctype) = print(io, "") #-----------------------------------------------------------------------------# Comment """ @@ -240,71 +271,23 @@ struct Comment x::String Comment(x) = new(string(x)) end -Base.show(io::IO, o::Comment) = print(io, "") -Base.show(io::IO, ::MIME"text/html", o::Comment) = show(io, o) - -#-----------------------------------------------------------------------------# Page -""" - Page(content) - -Wrapper to display `content` in your web browser. Assumes `content` has an available -show method for `MIME("text/html")`. -""" -struct Page - content - function Page(content) - is_html = showable("text/html", content) - !is_html && @warn "Content ($(typeof(content))) does not have an HTML representation. Returning `Page(HTML(content))`." - new(is_html ? content : HTML(content)) - end -end -Page(pg::Page) = pg - -save(file::String, page::Page) = save(page, file) - -function save(page::Page, file=joinpath(DIR, "index.html")) - Base.open(io -> show(io, page), touch(file), "w") - file -end - -function Base.show(io::IO, o::Page) - print(io, "") - show(io, MIME("text/html"), o.content) -end - -Base.show(io::IO, ::MIME"text/html", page::Page) = show(io, page) - -Base.display(::CobwebDisplay, page::Page) = DefaultApplication.open(save(page)) - -#-----------------------------------------------------------------------------# Tab -struct Tab - content -end -save(file::String, tab::Tab) = save(tab, file) -save(tab::Tab, file=string(tempname(), ".html")) = save(Page(tab.content), file) -Base.display(::CobwebDisplay, t::Tab) = DefaultApplication.open(save(t)) +Base.show(io::IO, ::MIME"text/html", o::Comment) = print(io, "") #-----------------------------------------------------------------------------# IFrame """ - IFrame(content; kw...) + IFrame(content; attrs...) -Create an `