sourceafFancordion::FancordionSkin.fan

using fandoc

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

    ** CSS URLs are rendered in the '<head>' element. 
    ** CSS URLs may be added at any stage of rendering as they are added during 'htmlEnd()'.
    Uri[]   cssUrls     := [,]
    
    ** Script URLs are rendered just before the closing '</body>' element. 
    ** Script URLs may be added at any stage of rendering as they are added during 'bodyEnd()'.
    Uri[]   scriptUrls  := [,]
    
    ** The 'StrBuf' that this Skin renders to.
    StrBuf  renderBuf   := StrBuf()

    
    
    // ---- 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() {
        // create a larger buf based on spec size
        renderBuf = StrBuf(fixtureMeta.specificationSrc.size * 2)
    }

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

    
    // ---- 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 This html() {
        write("""<!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 This htmlEnd() {
        // insert the CSS links to the <head> tag
        headIdx := renderBuf.toStr.index("</head>")
        cssUrls.unique.eachr |url| { renderBuf.insert(headIdx, render |->| { link(url) } ) }        
        return write("</html>\n")
    }
    
    ** Starts a <head> tag - this should also render a <title> tag.
    virtual This head() {
        write("<head>\n\t<title>${fixtureMeta.title.toXml} : Fancordion</title>\n")
        write("<meta charset=\"UTF-8\" />\n")
        return this
    }
    virtual This headEnd() { write("</head>\n") }
    
    ** Starts a '<body>' tag and renders the breadcrumbs. 
    virtual This body() { write("<body>\n"); return breadcrumbs }
    ** Ends a '</body>' tag.
    **  
    ** This also calls 'footer()' and renders the 'scriptUrls' as '<script>' tags.
    virtual This bodyEnd() {
        footer
        scriptUrls.unique.each { script(it) }
        return write("</body>\n")
    }
    
    ** Starts a section.
    ** By default this returns a 'div' with the class 'example':
    ** 
    **   <div class="example">
    virtual This section()      { write("""<div class="example">\n""") }
    ** Ends an *example* section.
    ** By default this ends a div:
    ** 
    **   </div>
    virtual This sectionEnd()   { write("</div>\n") }

    ** Starts a heading tag, e.g. '<h1>'
    virtual This heading(Int level, Str title, Str? anchorId) {
        id := (anchorId == null) ? Str.defVal : " id=\"${anchorId.toXml}\"" 
        return write("<h${level}${id}>")
    }
    ** Ends a heading tag, e.g. '</h1>'
    virtual This headingEnd(Int level) {
        write("""</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 This p(Str? admonition) { write(admonition == null ? "<p>" : """<p class="${admonition.lower.toXml}">""") }
    ** Ends a '</p>' tag.
    virtual This pEnd()         { write("</p>\n") }

    ** Starts a '<pre>' tag.
    virtual This pre()          { write("<pre>") }
    ** Ends a '</pre>' tag.
    virtual This preEnd()       { write("</pre>\n") }
    
    ** Starts a '<blockquote>' tag.
    virtual This blockQuote()   { write("<blockquote>") }
    ** Ends a '</blockquote>' tag.
    virtual This blockQuoteEnd() { write("</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 This ol(OrderedListStyle style) { write("""<ol style="list-style-type: ${style.htmlType};">""") }
    ** Ends an '</ol>' tag.
    virtual This olEnd()        { write("</ol>") }
    
    ** Starts a '<ul>' tag.
    virtual This ul()           { write("<ul>") }
    ** Ends a '</ul>' tag.
    virtual This ulEnd()        { write("</ul>\n") }
    
    ** Starts a '<li>' tag.
    virtual This li()           { write("<li>") }
    ** Ends a '</li>' tag.
    virtual This liEnd()        { write("</li>\n") }
    
    ** Starts an '<em>' tag.
    virtual This em()           { write("<em>") }
    ** Ends an '</em>' tag.
    virtual This emEnd()        { write("</em>") }
    
    ** Starts a '<strong>' tag.
    virtual This strong()       { write("<strong>") }
    ** Ends a '</strong>' tag.
    virtual This strongEnd()    { write("</strong>") }
    
    ** Starts a '<code>' tag.
    virtual This code()         { write("<code>") }
    ** Ends a '</code>' tag.
    virtual This codeEnd()      { write("</code>") }
    
    
    
    // ---- Un-Matched HTML ---------------------

    ** Renders a CSS '<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 This link(Uri href)         { write("""<link rel="stylesheet" type="text/css" href="${href.encode.toXml}" />\n""") }
    
    ** Renders a javascript '<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 This script(Uri src)        { write("""<script type="text/javascript" src="${src.encode.toXml}"></script>\n""") }
    
    ** Renders a complete '<a>' tag.
    virtual This a(Uri href, Str text)  { write("""<a href="${href.encode.toXml}">${text.toXml}</a>""") }
    
    ** Renders the given text. 
    ** By default the text is XML escaped.
    virtual This text(Str text)         { write(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 This img(Uri src, Str alt)  {
        srcUrl := copyFile(src.get, `images/`.plusName(src.name))
        return write("""<img src="${srcUrl.encode.toXml}" alt="${alt.toXml}" />""")
    }

    ** Renders the breadcrumbs. Makes a call to 'breadcrumbPaths()'
    virtual This breadcrumbs() {
        html := """<span class="breadcrumbs">""" + breadcrumbPaths.join(" > ") |text, href| { renderAnchor(href, text) } + "</span>"
        return write(html)
    }
    
    ** 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 This footer() {
        write("<footer>\n" + a(`http://www.fantomfactory.org/pods/afFancordion`, "Fancordion v${Pod.of(this).version}") + "</footer>")
    }


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

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

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

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

    ** Called to render a command error.
    virtual This cmdErr(Str cmdUrl, Str cmdText, Err err) {
        write("""<${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 This cmdHook(Uri cmdUrl, Str cmdText, Obj?[]? data) { this }


    
    // ---- 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, Bool overwrite := false) {
        cssUrl  := copyFile(cssFile, `css/`, overwrite)
        cssUrls.add(cssUrl)
    }

    ** Copies the given script file to the output dir and adds the resultant URL to 'scriptUrls'.
    virtual Void addScript(File scriptFile, Bool overwrite := false) {
        scriptUrl   := copyFile(scriptFile, `scripts/`, overwrite)
        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`
    ** 
    ** If 'destUrl' is a dir, then the file is copied into it.
    virtual Uri copyFile(File srcFile, Uri destUrl, Bool overwrite := false) {
        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)
            destUrl = destUrl.plusName(srcFile.name)

        dstFile := fixtureMeta.baseOutputDir + destUrl
        srcFile.copyTo(dstFile, ["overwrite": overwrite])
        
        return dstFile.normalize.uri.relTo(fixtureMeta.resultFile.parent.normalize.uri)
    }
    
    
    // ---- Private Helpers -----------------------------------------------------------------------
    
    ** Renders and returns an '<a>' anchor. 
    public Str renderAnchor(Uri href, Str text) {
        render |->| {
            a(href, text)
        }
    }

    ** Renders the contents of the given func into a 'Str' without appending it to the skin's 'renderBuf'.
    ** 
    **   syntax: fantom
    **   activeLink := skin.render |->| {
    **       write("<div class='active'>")
    **       a(`http://fantom.org`, "Fantom")
    **       write("</div>")
    **   }
    public Str render(|->| writeFunc) {
        oldBuf := renderBuf
        newBuf := StrBuf()
        renderBuf = newBuf
        writeFunc()
        renderBuf = oldBuf
        return newBuf.toStr
    }
    
    public This write(Str str) {
        renderBuf.add(str)
        return this
    }
    
    private Str firstLine(Str? txt) {
        txt?.splitLines?.exclude { it.trim.isEmpty }?.first ?: Str.defVal
    }
    
    @NoDoc  // 'cos it's a bit of hack!
    protected Bool inTable
    @NoDoc  // 'cos it's a bit of hack!
    protected Bool inPre
    @NoDoc  // 'cos it's a bit of hack!
    protected Str cmdElem() {
        if (inPre)
            return "pre"
        if (inTable)
            return "td"
        return "span"
    }
}