Skip to content

Instantly share code, notes, and snippets.

@adrianbk
Created June 12, 2017 08:07
Show Gist options
  • Save adrianbk/6cc7916996937dcbe99360cca62fc740 to your computer and use it in GitHub Desktop.
Save adrianbk/6cc7916996937dcbe99360cca62fc740 to your computer and use it in GitHub Desktop.
import asset.pipeline.AbstractProcessor
import asset.pipeline.AssetCompiler
import asset.pipeline.AssetFile
import asset.pipeline.AssetPipelineConfigHolder
import asset.pipeline.DirectiveProcessor
import org.springframework.web.util.UriComponents
import org.springframework.web.util.UriComponentsBuilder
import java.util.regex.Pattern
import static asset.pipeline.AssetHelper.getByteDigest
/**
* A processor to process JS files and replace links to .html or .htm files with the digested version of the file.
* Will match links both with and without a sub extension:
* `someFile.tpl.html`
* `someFile.tpl.htm`
* `someFile.html`
* `someFile.anysubextension.html`
* `../../someFile.tpl.html'
*
* Throws an exception if the replacement file cannot be resolved via AssetPipelineConfigHolder.resolvers
*/
class AngularTemplateJsProcessor extends AbstractProcessor {
static
final Pattern TEMPLATE_INCLUSION_PATTERN = ~/(['"]+?)((?:.\/)|(?:\.\.\/)+)?([a-zA-Z0-9\-_:\/@#? &+%=]+?)(\.[a-zA-Z]+)?(\.html|\.htm)(['"]+?)/
List<String> INCLUDED_DIRS = ["shared", "client", "merchants"]
AngularTemplateJsProcessor(AssetCompiler precompiler) {
super(precompiler)
}
/**
* Do not process external libraries like angular and bootstrap
* @param assetFile
* @return
*/
boolean shouldProcessFile(AssetFile assetFile) {
UriComponents uri = UriComponentsBuilder.fromPath(assetFile.getPath()).build()
String containingDir = uri.getPathSegments().first()
boolean include = INCLUDED_DIRS.contains(containingDir)
if (!include) {
log("Skipping asset [${assetFile.getPath()}] as it is not in one of the included dirs [${INCLUDED_DIRS}]")
}
return include
}
String process(final String inputText, final AssetFile assetFile) {
if (precompiler?.options?.enableDigests) {
//Only done in production mode
println("[${AngularTemplateJsProcessor.class.simpleName}] Precompilation enabled.")
if (shouldProcessFile(assetFile)) {
log("Processing file: ${assetFile.getPath()}")
final Map<String, String> cachedPaths = [:]
return inputText.replaceAll(TEMPLATE_INCLUSION_PATTERN) {
final String original,
final String openingQuote,
final String relativePathMarkers, // ../../ or ./
final String targetLocation,
final String optionalSubExtension,
final String extension,
final String closingQuote ->
String assetPrefix = "${relativePathMarkers ?: ''}${targetLocation}${optionalSubExtension ?: ''}"
String targetAssetLocation = assetPrefix + extension
log("Attempting to replace the templateUrl path to: [" + targetAssetLocation + "]")
final String cachedPath = cachedPaths[targetAssetLocation]
if (!cachedPath) {
final AssetFile targetAsset = tryFind(targetAssetLocation)
assert targetAsset: "Could not locate the asset [${targetAssetLocation}]"
String resolvedLocation = assetPrefix + '-' + getAssetDigest(targetAsset, precompiler) + extension
cachedPaths.put(targetAssetLocation, resolvedLocation)
}
String digestedPath = "${openingQuote}${cachedPaths.get(targetAssetLocation)}${closingQuote}"
log("Replacing path: [${targetAssetLocation}] with: [${digestedPath}]")
return digestedPath
}
}
}
return inputText
}
private static void log(String msg) {
println("[${AngularTemplateJsProcessor.class.simpleName}]: ${msg}")
}
private static String getAssetDigest(AssetFile assetFile, AssetCompiler precompiler) {
//assetFile.getByteDigest() does not match what asset.pipeline.AssetHelper.getByteDigest produces
return getByteDigest(new DirectiveProcessor(assetFile.contentType[0], precompiler).compile(assetFile).bytes)
}
private static AssetFile tryFind(String assetPath) {
for (resolver in AssetPipelineConfigHolder.resolvers) {
AssetFile file = resolver.getAsset(assetPath)
if (file) {
return file
}
}
return null
}
}
import asset.pipeline.AssetCompiler
import asset.pipeline.AssetPipelineConfigHolder
import asset.pipeline.JsAssetFile
import asset.pipeline.fs.AssetResolver
import asset.pipeline.fs.FileSystemAssetResolver
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import spock.lang.Ignore
import spock.lang.Shared
import spock.lang.Specification
import spock.lang.Unroll
import java.util.regex.Matcher
class AngularTemplateJsProcessorSpec extends Specification {
@Rule
public TemporaryFolder testDir = new TemporaryFolder()
@Shared
AssetResolver resolver
@Shared
File baseDir
@Shared
File compileDir
def setup() {
baseDir = testDir.newFolder('assets', 'javascripts').parentFile
compileDir = testDir.newFolder('target')
testDir.newFolder('assets', 'javascripts', 'client')
testDir.newFolder('assets', 'javascripts', 'template', 'client')
resolver = new FileSystemAssetResolver('application', baseDir.absolutePath)
AssetPipelineConfigHolder.resolvers.add(resolver)
}
@Unroll
def "template inclusion patterns should match [#line]"() {
when:
Matcher matcher = line =~ AngularTemplateJsProcessor.TEMPLATE_INCLUSION_PATTERN
then:
matcher
joinMatchedFilePath(matcher) == expectedCapture
where:
line | expectedCapture
'templateUrl: "/client/templ/some-file.html"' | '/client/templ/some-file.html'
'templateUrl: "/client/templ/some.html"' | '/client/templ/some.html'
'templateUrl: "/client/templ/some.erb.html"' | '/client/templ/some.erb.html'
'"/client/templ/some.tpl.html"' | '/client/templ/some.tpl.html'
'templateUrl: "/client/templ/some.tpl.html"' | '/client/templ/some.tpl.html'
'templateUrl: "../client/templ/some.tpl.html"' | '../client/templ/some.tpl.html'
'templateUrl: "../../client/templ/some.tpl.html"' | '../../client/templ/some.tpl.html'
'templateUrl: "./client/templ/some.tpl.html"' | './client/templ/some.tpl.html'
'templateUrl: "/client/templ/some_.tpl.html"' | '/client/templ/some_.tpl.html'
'templateUrl:"/client/templ/some.tpl.html"' | '/client/templ/some.tpl.html'
"templateUrl:'/client/templ/some.tpl.html'" | '/client/templ/some.tpl.html'
'templateUrl: templatePath("content.tpl.html"),' | 'content.tpl.html'
'nuts templateUrl: templatePath("content.tpl.html") and gum' | 'content.tpl.html'
'nuts templateUrl: templatePat"content.tpl.html") and gum' | 'content.tpl.html'
' return templatePath("template/form-element.tpl.html");' | 'template/form-element.tpl.html'
' return templatePath("template/form-element-v2.tpl.html");' | 'template/form-element-v2.tpl.html'
}
@Unroll
def "template inclusion does not match other urls"() {
when:
Matcher matcher = line =~ AngularTemplateJsProcessor.TEMPLATE_INCLUSION_PATTERN
then:
!matcher
where:
line << [
'templateUrl: "/client/templ/some.css"',
'templateUrl:"/client/templ/some.js"',
"templateUrl:'/client/templ/some.htmls'"]
}
def "a js file referencing an angular template is replaced with the path to the digested asset"() {
String includedFileName = 'content.tpl.html'
setup:
htmlFile(includedFileName)
String jsFileName = 'someFile.js'
jsFile(jsFileName, includedFileName)
maybeAddAngularTemplateJsProcessor()
when:
new AssetCompiler([compileDir: compileDir.absolutePath, enableDigests: true]).compile()
then:
Properties props = new Properties()
props.load(new File(compileDir, "manifest.properties").newDataInputStream())
File digestedJsFile = new File(compileDir, props.get("client/${jsFileName}".toString()) as String)
String replacedPath = props.get("template/client/${includedFileName}".toString())
replacedPath ==~ /template\/client\/content.tpl-[a-zA-Z0-9]{32}.html/
digestedJsFile.text.trim() == """
return {
templateUrl: "${replacedPath}",
scope: {
loyaltyCard: "=",
legacyLoyalty: "=",
favourite: "="
},
""".trim()
}
@Ignore
/**
* Fails for .html files without a sub-extension
* e.g:
* java.lang.AssertionError: Could not locate the asset [template/client/content-1f3a38ca383637a6c7ef3809b3a6c275.html].
* Expression: targetAsset. Values: targetAsset = null
* at au.com.sensis.zinc.assets.AngularTemplateJsProcessor.process_closure1(AngularTemplateJsProcessor.groovy:73)
* at groovy.lang.Closure.call(Closure.java:414)
* at groovy.lang.Closure.call(Closure.java:430)
*
*/
def "a js file referencing a html asset is replaced with the path to the digested asset"() {
String includedFileName = 'content.html'
setup:
htmlFile(includedFileName)
String jsFileName = 'someFile.js'
jsFile(jsFileName, includedFileName)
maybeAddAngularTemplateJsProcessor()
when:
new AssetCompiler([compileDir: compileDir.absolutePath, enableDigests: true]).compile()
then:
Properties props = new Properties()
props.load(new File(compileDir, "manifest.properties").newDataInputStream())
File digestedJsFile = new File(compileDir, props.get("client/${jsFileName}".toString()) as String)
String replacedPath = props.get("template/client/${includedFileName}".toString())
replacedPath ==~ /template\/client\/content-[a-zA-Z0-9]{32}.html/
digestedJsFile.text.trim() == """
return {
templateUrl: "${replacedPath}",
scope: {
loyaltyCard: "=",
legacyLoyalty: "=",
favourite: "="
},
""".trim()
}
def "does not replace with the digested path when in dev mode"() {
String includedFileName = 'content.tpl.html'
setup:
htmlFile(includedFileName)
jsFile('someFile.js', includedFileName)
maybeAddAngularTemplateJsProcessor()
when:
new AssetCompiler([compileDir: compileDir.absolutePath, enableDigests: false]).compile()
then:
File digestedJsFile = new File(compileDir, 'client/someFile.js')
digestedJsFile.text.trim() == """
return {
templateUrl: "template/client/${includedFileName}",
scope: {
loyaltyCard: "=",
legacyLoyalty: "=",
favourite: "="
},
""".trim()
}
private File htmlFile(String name) {
File f = testDir.newFile("assets/javascripts/template/client/${name}")
f << '''
<html>
<body>
<p>hello</p>
</body>
</htm/>
'''
return f
}
private File jsFile(String name, String templateFileName) {
File f = testDir.newFile("assets/javascripts/client/${name}")
f << """
return {
templateUrl: "template/client/${templateFileName}",
scope: {
loyaltyCard: "=",
legacyLoyalty: "=",
favourite: "="
},""".trim()
return f
}
private static void maybeAddAngularTemplateJsProcessor() {
if (!JsAssetFile.processors.contains(AngularTemplateJsProcessor)) {
JsAssetFile.processors.add(AngularTemplateJsProcessor)
}
}
private static String joinMatchedFilePath(Matcher matcher) {
(matcher.group(2) ?: '') + matcher.group(3) + (matcher.group(4) ?: '') + matcher.group(5)
}
}
@adrianbk
Copy link
Author

Grails config to add the processor: JsAssetFile.processors.add(AngularTemplateJsProcessor)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment