sourceafFancordion::FancordionSkin.fan

using fandoc
using concurrent

** Implement to create a skin for specification output. 
** Skins are used by Fancordion and its command to generate the HTML result files.
** 
** This mixin by default renders bare, but valid, HTML5 markup. Override methods to alter the markup generated.
mixin FancordionSkin {

    abstract Uri[] cssUrls
    abstract Uri[] scriptUrls
    
    // ---- Setup / Tear Down ---------------------------------------------------------------------
    
    ** Called before every fixture run.
    ** This should reset any state held by the skin, e.g. the 'cssUrls' and 'scriptUrls'.
    virtual Void setup() { }

    ** Called after every fixture run.
    ** This should reset / cleardown any state held by the skin, e.g. the 'cssUrls' and 'scriptUrls'.
    virtual Void tearDown() { }
    

    
    // ---- HTML Methods --------------------------------------------------------------------------
    
    ** Starts a '<html>' tag - this should also render the DOCTYPE.
    ** 
    ** Note that XHTML5 documents require the 'xmlns':
    ** 
    **   <html xmlns="http://www.w3.org/1999/xhtml"> 
    virtual Str html() {
        """<!DOCTYPE html>\n<html xmlns="http://www.w3.org/1999/xhtml">\n"""
    }
    ** Ends a '<html>' tag. 
    ** This also renders the 'cssUrls' as link tags into the '<head>'.
    virtual Str htmlEnd() {
        // insert the CSS links to the <head> tag
        headIdx := renderBuf.toStr.index("</head>")
        cssUrls.eachr { renderBuf.insert(headIdx, link(it)) }
        
        return "</html>\n"
    }
    
    ** Starts a <head> tag - this should also render a <title> tag.
    virtual Str head() {
        "<head>\n\t<title>${fixtureMeta.title.toXml} : Fancordion</title>\n"
    }
    virtual Str headEnd() { "</head>\n" }
    
    ** Starts a '<body>' tag and renders the breadcrumbs. 
    virtual Str body() { "<body>\n" + breadcrumbs }
    ** Ends a '</body>' tag.
    **  
    ** This also calls 'footer()' and renders the 'scriptUrls' as '<script>' tags.
    virtual Str bodyEnd() {
        bodyBuf := StrBuf().add(footer)

        // render the script tags
        scriptUrls.each { bodyBuf.add(script(it)) }

        return bodyBuf.add("</body>\n").toStr
    }
    
    ** Starts an *example* section.
    ** By default this returns a 'div' with the class 'example':
    ** 
    **   <div class="example">
    virtual Str example()       { """<div class="example">\n""" }
    ** Ends an *example* section.
    ** By default this ends a div:
    ** 
    **   </div>
    virtual Str exampleEnd()    { "</div>\n" }

    ** Starts a heading tag, e.g. '<h1>'
    virtual Str heading(Int level, Str title, Str? anchorId) {
        id := (anchorId == null) ? Str.defVal : " id=\"${anchorId.toXml}\"" 
        return "<h${level}${id}>"
    }
    ** Ends a heading tag, e.g. '</h1>'
    virtual Str headingEnd(Int level) {
        """</h${level}>\n"""
    }

    ** Starts a '<p>' tag.
    ** The admonition is added as a class (lowercase):
    ** 
    **   LEAD: Here I am  --> <p class="lead">Here I am</p>
    virtual Str p(Str? admonition) { admonition == null ? "<p>" : """<p class="${admonition.lower.toXml}">""" }
    ** Ends a '</p>' tag.
    virtual Str pEnd() { "</p>\n" }

    ** Starts a '<pre>' tag.
    virtual Str pre()           { "<pre>" }
    ** Ends a '</pre>' tag.
    virtual Str preEnd()        { "</pre>\n" }
    
    ** Starts a '<blockquote>' tag.
    virtual Str blockQuote()    { "<blockquote>" }
    ** Ends a '</blockquote>' tag.
    virtual Str blockQuoteEnd() { "</blockquote>\n" }
    
    ** Starts an '<ol>' tag.
    ** By default the list style is added as a CSS style attribute:
    ** 
    **    <ol style="list-style-type: upper-roman;">
    virtual Str ol(OrderedListStyle style)  { """<ol style="list-style-type: ${style.htmlType};">""" }
    ** Ends an '</ol>' tag.
    virtual Str olEnd()         { "</ol>" }
    
    ** Starts a '<ul>' tag.
    virtual Str ul()            { "<ul>" }
    ** Ends a '</ul>' tag.
    virtual Str ulEnd()         { "</ul>\n" }
    
    ** Starts a '<li>' tag.
    virtual Str li()            { "<li>" }
    ** Ends a '</li>' tag.
    virtual Str liEnd()         { "</li>\n" }
    
    ** Starts an '<emphasis>' tag.
    virtual Str emphasis()      { "<emphasis>" }
    ** Ends an '</emphasis>' tag.
    virtual Str emphasisEnd()   { "</emphasis>" }
    
    ** Starts a '<strong>' tag.
    virtual Str strong()        { "<strong>" }
    ** Ends a '</strong>' tag.
    virtual Str strongEnd()     { "</strong>" }
    
    ** Starts a '<code>' tag.
    virtual Str code()          { "<code>" }
    ** Ends a '</code>' tag.
    virtual Str codeEnd()       { "</code>" }
    
    
    
    // ---- Un-Matched HTML ---------------------

    ** Renders a complete '<link>' tag. 
    ** 
    ** Note that in HTML5 the '<link>' tag is a [Void element]`http://www.w3.org/TR/html5/syntax.html#void-elements` and may be self closing. 
    virtual Str link(Uri href)          { """<link rel="stylesheet" type="text/css" href="${href.encode.toXml}" />\n""" }
    
    ** Renders a complete '<script>' tag.
    ** 
    ** Note that in HTML5 the '<script>' tag is NOT a [Void element]`http://www.w3.org/TR/html5/syntax.html#void-elements` and therefore MUST not be self colsing. 
    virtual Str script(Uri src)         { """<script type="text/javascript" src="${src.encode.toXml}"></script>\n""" }
    
    ** Renders a complete '<a>' tag.
    virtual Str a(Uri href, Str text)   { """<a href="${href.encode.toXml}">${text.toXml}</a>""" }
    
    ** Renders the given text. 
    ** By default the text is XML escaped.
    virtual Str text(Str text)          { text.toXml }

    ** Renders a complete '<img>' tag. 
    ** 
    ** Note that in HTML5 the '<img>' tag is a [Void element]`http://www.w3.org/TR/html5/syntax.html#void-elements` and may be self closing. 
    virtual Str img(Uri src, Str alt)   {
        srcUrl := copyFile(src.get, `images/`.plusName(src.name))
        return """<img src="${srcUrl.encode.toXml}" alt="${alt.toXml}" />"""
    }

    ** Renders the breadcrumbs. Makes a call to 'breadcrumbPaths()'
    virtual Str breadcrumbs() {
        """<span class="breadcrumbs">""" + breadcrumbPaths.join(" > ") |text, href| { a(href, text) } + "</span>"
    }
    
    ** Returns an ordered map of URLs to fixture titles to use for the breadcrumbs.
    virtual Uri:Str breadcrumbPaths() {
        paths := Uri:Str[:] { ordered = true}
        metas := (FixtureMeta[]) ThreadStack.elements("afFancordion.fixtureMeta")
        metas.each |meta| {         
            url := meta.resultFile.normalize.uri.relTo(fixtureMeta.resultFile.parent.normalize.uri)
            str := meta.title
            paths[url] = str
        }
        return paths
    }
    
    ** Renders a footer.
    ** This is (usually) called by 'bodyEnd()'. 
    ** By default it just renders a simple link to the Fancordion website.
    virtual Str footer() {
        "<footer>\n" + a(`http://www.fantomfactory.org/pods/afFancordion`, "Fancordion v${Pod.of(this).version}") + "</footer>"
    }


    
    // ---- Table Methods -------------------------------------------------------------------------

    ** Starts a '<table>' tag.
    virtual Str table() {       setInTable(true); return "<table>\n"        }
    ** Ends a '</table>' tag.
    virtual Str tableEnd() {    setInTable(false); return "</table>"    }
    ** Starts a '<tr>' tag.
    virtual Str tr() {          "<tr>"      }
    ** Ends a '</tr>' tag.
    virtual Str trEnd() {       "</tr>\n"       }
    ** Returns a '<th>' tag.
    virtual Str th(Str heading) {
        "<th>${heading}</th>"
    }
    ** Returns a '<td>' tag.
    virtual Str td(Str heading) {
        "<td>${heading}</td>"
    }
    
    
    
    // ---- Test Results --------------------------------------------------------------------------
    
    ** Called to render an ignored command.
    virtual Str cmdIgnored(Str text) {
        """<${cmdElem} class="ignored">${text.toXml}</${cmdElem}>"""
    }

    ** Called to render a command success.
    virtual Str cmdSuccess(Str text, Bool escape := true) {
        html := escape ? text.toXml : text
        return """<${cmdElem} class="success">${html}</${cmdElem}>"""
    }

    ** Called to render a command failure.
    virtual Str cmdFailure(Str expected, Obj? actual, Bool escape := true) {
        html := escape ? expected.toXml : expected
        return """<${cmdElem} class="failure"><del class="expected">${html}</del> <span class="actual">${firstLine(actual?.toStr).toXml}</span></${cmdElem}>"""
    }

    ** Called to render a command error.
    virtual Str cmdErr(Uri cmdUrl, Str cmdText, Err err) {
        """<${cmdElem} class="error"><del class="expected">${cmdText.toXml}</del> <span class="actual">${firstLine(err.msg).toXml}</span></${cmdElem}>"""
    }
    
    ** Custom commands may use this method as a generic hook into the skin.
    ** 
    ** By default this method returns an empty string.
    virtual Str cmdHook(Uri cmdUrl, Str cmdText, Obj?[]? data) { Str.defVal }


    
    // ---- Helper Methods ------------------------------------------------------------------------
    
    ** Returns meta associated with the current fixture.
    virtual FixtureMeta fixtureMeta() {
        ThreadStack.peek("afFancordion.fixtureMeta")
    }

    ** Returns the context associated with the current fixture.
    virtual FixtureCtx fixtureCtx() {
        ThreadStack.peek("afFancordion.fixtureCtx")
    }

    ** Copies the given css file to the output dir and adds the resultant URL to 'cssUrls'.
    virtual Void addCss(File cssFile) {
        cssUrl  := copyFile(cssFile, `css/`.plusName(cssFile.name))
        cssUrls.add(cssUrl)
    }

    ** Copies the given script file to the output dir and adds the resultant URL to 'scriptUrls'.
    virtual Void addScript(File scriptFile) {
        scriptUrl   := copyFile(scriptFile, `scripts/`.plusName(scriptFile.name))
        scriptUrls.add(scriptUrl)
    }

    ** Copies the given file to the destination URL - which is relative to the output folder.
    ** Returns a URL to the destination file relative to the current fixture file. 
    ** Use this URL for embedding href's in the fixture HTML. Example:
    ** 
    **   copyFile(`fan://afFancordion/res/fancordion.css`.get, `etc/fancordion.css`)
    **   --> `../../etc/fancordion.css`
    virtual Uri copyFile(File srcFile, Uri destUrl) {
        if (!destUrl.isPathOnly)
            throw ArgErr(ErrMsgs.urlMustBePathOnly("Dest URL", destUrl, `etc/fancordion.css`))
        if (destUrl.isPathAbs)
            throw ArgErr(ErrMsgs.urlMustNotStartWithSlash("Dest URL", destUrl, `etc/fancordion.css`))
        if (destUrl.isDir)
            throw ArgErr(ErrMsgs.urlMustNotEndWithSlash("Dest URL", destUrl, `etc/fancordion.css`))

        dstFile := fixtureMeta.baseOutputDir + destUrl
        srcFile.copyTo(dstFile, ["overwrite": false])
        
        return dstFile.normalize.uri.relTo(fixtureMeta.resultFile.parent.normalize.uri)
    }
    
    
    // ---- Private Helpers -----------------------------------------------------------------------
    
    private Str firstLine(Str? txt) {
        txt?.splitLines?.exclude { it.trim.isEmpty }?.first ?: Str.defVal
    }
    
    private StrBuf renderBuf() {
        fixtureCtx.renderBuf
    }
    
    private Str cmdElem() {
        Actor.locals.containsKey("afFancordion.inTable") ? "td" : "span"
    }
    
    private Void setInTable(Bool in) {
        if (in)
            Actor.locals["afFancordion.inTable"] = true
        else
            Actor.locals.remove("afFancordion.inTable")
    }
}