sourceafDuvet::ScriptTagBuilder.fan

using afIoc::Inject
using afIocConfig::Config
using afBedSheet::BedSheetServer
using afBedSheet::ClientAssetCache
using afBedSheet::FileHandler
using afBedSheet::HttpResponse

** Defines a '<script>' tag to be injected into the bottom of your body. Created via `HtmlInjector`.
** 
** Note that any 'Content-Security-Policy' response header will be updated to ensure the script can be executed.
** 
** @see `https://developer.mozilla.org/en/docs/Web/HTML/Element/script` 
class ScriptTagBuilder {
    
    @Inject private HttpResponse        httpRes
    @Inject private ClientAssetCache    clientAssets
    @Inject { optional=true }           // nullable for testing
            private BedSheetServer?     bedServer
    @Inject { optional=true }           // nullable for testing
            private FileHandler?        fileHandler
    @Config private Bool                updateCspHeader
            private HtmlElement         element
    
    internal new make(|This|in) {
        in(this)
        this.element = HtmlElement("script")
        this.element["type"] = "text/javascript"
    }
    
    ** Sets the 'src' attribute to an external, absolute, URL.
    ** Returns 'this'.
    ** 
    **   syntax: fantom
    **   fromExternalUrl(`http://example.com/css/maxStyles.css`)
    ** 
    ScriptTagBuilder fromExternalUrl(Uri scriptUrl) {
        if (scriptUrl.host == null)
            throw ArgErr(ErrMsgs.externalUrlsNeedHost(scriptUrl))
        element["src"] = scriptUrl.encode
        
        if (updateCspHeader) {
            host := (scriptUrl + `/`).encode
            if (host.endsWith("/"))
                host = host[0..<-1]
            if (host.startsWith("//"))
                host = host[2..-1]
            csp  := httpRes.headers.contentSecurityPolicy
            if (addCsp(csp, host, null))
                httpRes.headers.contentSecurityPolicy = csp

            cspro := httpRes.headers.contentSecurityPolicyReportOnly
            if (addCsp(cspro, host, null))
                httpRes.headers.contentSecurityPolicyReportOnly = cspro
        }
    
        return this
    }

    ** Sets the 'src' attribute to a local URL. 
    ** The URL may be rebuilt to take advantage of any asset caching strategies, such as [Cold Feet]`http://eggbox.fantomfactory.org/pods/afColdFeet`.
    ** Returns 'this'.
    ** 
    **   syntax: fantom
    **   fromLocalUrl(`/css/maxStyles.css`)
    ** 
    ScriptTagBuilder fromLocalUrl(Uri scriptUrl) {
        // ClientAssets adds ColdFeet digests, BedServer does not
        clientUrl := clientAssets.getAndUpdateOrProduce(scriptUrl)?.clientUrl ?: bedServer.toClientUrl(scriptUrl)   
        element["src"] = clientUrl.encode
        
        if (updateCspHeader) {
            csp := httpRes.headers.contentSecurityPolicy
            if (addCsp(csp, "'self'", null))
                httpRes.headers.contentSecurityPolicy = csp

            cspro := httpRes.headers.contentSecurityPolicyReportOnly
            if (addCsp(cspro, "'self'", null))
                httpRes.headers.contentSecurityPolicyReportOnly = cspro
        }

        return this     
    }

    ** Creates a 'src' URL attribute from the given file. 
    ** The file **must** exist on the file system and be mapped by BedSheet's 'FileHandler' service.
    ** The URL is built to take advantage of any asset caching strategies, such as [Cold Feet]`http://eggbox.fantomfactory.org/pods/afColdFeet`.
    ** Returns 'this'.
    ** 
    ** 
    **   syntax: fantom
    **   fromServerFile(File(`web-static/css/maxStyles.css`))
    ** 
    ScriptTagBuilder fromServerFile(File scriptFile) {
        fileAsset := fileHandler.fromServerFile(scriptFile) // this adds any ColdFeet digests
        element["src"] = fileAsset.clientUrl.encode
        
        if (updateCspHeader) {
            csp := httpRes.headers.contentSecurityPolicy
            if (addCsp(csp, "'self'", null))
                httpRes.headers.contentSecurityPolicy = csp

            cspro := httpRes.headers.contentSecurityPolicyReportOnly
            if (addCsp(cspro, "'self'", null))
                httpRes.headers.contentSecurityPolicyReportOnly = cspro
        }

        return this     
    }
    
    ** Sets the 'type' attribute.
    ** Returns 'this'.
    ScriptTagBuilder withType(MimeType type) {
        element["type"] = type.toStr
        return this
    }
    
    ** Sets the 'id' attribute.
    ** Returns 'this'.
    ScriptTagBuilder withId(Str id) {
        element["id"] = id
        return this
    }

    ** Sets the contents of the script tag.
    ** Returns 'this'.
    ScriptTagBuilder withScript(Str script) {
        element.add(HtmlText(script))
        
        if (updateCspHeader) {
            csp := httpRes.headers.contentSecurityPolicy
            if (addCsp(csp, "'unsafe-inline'") |->Str| { "'sha256-" + script.toBuf.toDigest("SHA-256").toBase64 + "'" })
                httpRes.headers.contentSecurityPolicy = csp

            cspro := httpRes.headers.contentSecurityPolicyReportOnly
            if (addCsp(cspro, "'unsafe-inline'") |->Str| { "'sha256-" + script.toBuf.toDigest("SHA-256").toBase64 + "'" })
                httpRes.headers.contentSecurityPolicyReportOnly = cspro
        }

        return this
    }
    
    ** Sets the 'async' attribute to 'async'; the XHTML value, accepted by HTML.
    ** Returns 'this'.
    ScriptTagBuilder async() {
        element["async"] = "async"
        return this
    }
    
    ** Sets the 'defer' attribute to 'true'.
    ** Returns 'this'.
    ScriptTagBuilder defer() {
        element["defer"] = "true"
        return this
    }
    
    ** Sets an arbitrary attribute on the '<script>' element. 
    ScriptTagBuilder setAttr(Str name, Str value) {
        element[name] = value
        return this
    }
    
    ** Returns an attribute value on the '<script>' element. 
    Str? getAttr(Str name) {
        element[name]
    }   

    @NoDoc  // looks like it could be useful!
    HtmlNode htmlNode() {
        element
    }

    private static Bool addCsp([Str:Str]? csp, Str altDir, |->Str|? dirFn) {
        if (csp == null)
            return false

        directive   := csp["script-src"]?.trimToNull ?: csp["default-src"]?.trimToNull
        if (directive == null)
            return false

        directives  := directive.split
        if (directives.contains(altDir))    // e.g. 'unsafe-inline'
            return false
        
        newDir := dirFn != null ? dirFn() : altDir
        if (directives.contains(newDir))
            return false
        
        csp["script-src"] = directive + " " + newDir
        return true
    }
}