Using ColdFusion Custom Tags To Create An HTML Email DSL In Lucee CFML 5.3.7.47, Part IX
In an attempt to round-out the set of ColdFusion custom tags that I might need in my DSL (Domain Specific Language) for HTML emails, I wanted to take a look a the <pre>
and <code>
tags. The <pre>
tag, specifically, is an interesting topic since it has special properties that don't pertain to the rest of HTML. That is, it maintains the whitespace that is present in the underlying markup. This poses a fun challenge because my ColdFusion custom tags actually try to remove most superfluous whitespace in order to minify the content and manage line-breaks. As such, I had take special measures for these two semantic tags.
View this code in my ColdFusion Custom Tag Emails project on GitHub.
As the final step in rendering the HTML email in my ColdFusion custom tags, I perform several operations:
- Normalize all the line-breaks to be just "newline".
- Collapse sibling line-breaks down into a single line-break.
- Remove all leading whitespace (spaces and tabs) on a per-line basis.
- Split the
style
attributes onto their own line (in order to limit mid-property line-breaks in some email clients).
Of course, the <pre>
tag flies-in-the-face of all of this because the <pre>
is specifically meant to exert explicit control over all the rendering of whitespace. As such, I have to "skip" all of this whitespace management inside of a <pre>
.
"Skipping", however, means complexity. So, instead of having to apply special logic within the <pre>
tag, what I ended up doing was deferring the rendering of the <pre>
tag content until after the email body has been minified. If we look at the <html:pre>
tag logic, what we'll see is that it doesn't output the thistag.generatedContent
directly - instead, it appends it do an array and then outputs a token that corresponds to the given <pre>
tag output:
<!--- Import custom tag libraries. --->
<cfimport prefix="core" taglib="../" />
<!--- Define custom tag attributes. --->
<cfparam name="attributes.class" type="string" default="" />
<cfparam name="attributes.margins" type="string" default="small xlarge" />
<cfparam name="attributes.style" type="string" default="" />
<cfparam name="attributes.tabSize" type="string" default="4" />
<!--- // ------------------------------------------------------------------------- // --->
<!--- // ------------------------------------------------------------------------- // --->
<cfswitch expression="#thistag.executionMode#">
<cfcase value="start">
<cfoutput>
<!---
Since the "code" entity has some base styles, we need to unset some of
them for use in the "pre" tag.
--->
<core:HtmlEntityTheme entity="code">
background-color: transparent ;
border-radius: 0px ;
display: block ;
padding: 0px ;
white-space: pre-wrap ;
word-break: break-all ;
</core:HtmlEntityTheme>
</cfoutput>
</cfcase>
<cfcase value="end">
<cfoutput>
<!---
When the email content is being rendered, all unnecessary whitespace is
removed. However, we don't want this to happen in the PRE tag since the
PRE tag is intended to maintain whitespace. As such, instead of rendering
the content directly, we're going to store it. Then, we're going to
replace it back into the body after the email has been minified.
--->
<cfset email = getBaseTagData( "cf_email" ) />
<cfset email.preContentBlocks.append( thistag.generatedContent ) />
<cfset preContentBlockToken = "__PRE:#email.preContentBlocks.len()#__" />
<core:Styles
variable="tdStyle"
entityName="pre"
entityClass="#attributes.class#"
entityStyle="#attributes.style#">
tab-size: #attributes.tabSize# ;
</core:Styles>
<core:Styles variable="nativePreStyle">
Margin: 0 ; <!--- For Outlook. --->
margin: 0px ;
padding: 0px ;
white-space: pre-wrap ;
word-break: break-all ;
</core:Styles>
<core:BlockMargins margins="#attributes.margins#">
<!---
CAUTION: We are using raw HTML elements here instead of the "html"
custom tags module so that we don't accidentally apply Theme styles
to this markup.
--->
<table role="presentation" width="100%" border="0" cellpadding="10" cellspacing="0">
<tr>
<td class="#trim( 'html-entity-pre #attributes.class#' )#" style="#tdStyle#">
<pre style="#nativePreStyle#">#preContentBlockToken#</pre>
</td>
</tr>
</table>
</core:BlockMargins>
<!--- Reset the generated content since we're overriding the output. --->
<cfset thistag.generatedContent = "" />
</cfoutput>
</cfcase>
</cfswitch>
As you can see, this ColdFusion custom tag is storing the generated content into the array, preContentBlocks
. Now, in the Email.cfm
root ColdFusion custom tag, I have to update the minification process to apply this aggregate of content as a final step in the content preparation:
<cfscript>
// .... truncated code ....
/**
* I strip out as much white-space in the given content as possible.
*
* @content I am the email body content being minified.
*/
public string function minifyEmailContent( required string content ) {
var newline = chr( 10 );
var minifiedContent = trim( arguments.content );
// Normalizing line-breaks and spaces.
minifiedContent = reReplaceAll( minifiedContent, "(?m)^[ \t]+", "" );
minifiedContent = reReplaceAll( minifiedContent, "[\r\n]+", newline );
// Wrap each STYLE attribute onto its own line in order to help prevent mid-
// style text-wrapping applied by the more stringent email clients.
minifiedContent = reReplaceAll( minifiedContent, "(\bstyle="")", "#newline#$1" );
// Now that we've removed all the superfluous whitespace, as the last step in our
// minification, let's apply any PRE tag content (which is intended to contain
// meaningful whitespace).
preContentBlocks.each(
( preContent, i ) => {
minifiedContent = reReplaceAll( minifiedContent, "__PRE:#i#__", preContent );
}
);
return( minifiedContent );
}
// .... truncated code ....
</cfscript>
As you can see, as the final step in the process, I replace the <pre>
tag tokens with the original content. This leaves all of the original formatting in place.
To see this in action, here's a sample code snippet file:
<h1>
Hello world
</h1>
<p>
This is an inlined code sample file.
</p>
<p>
This is a really long line that will have to have some sort of wrapping in order to not break the layout. And, the following line is a really long line that has no whitespace.
</p>
<p>
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</p>
And, here's an example that pulls this code into a pre/code snippet display:
<!--- Import custom tag libraries. --->
<cfimport prefix="core" taglib="./core/" />
<cfimport prefix="html" taglib="./core/html/" />
<!--- // ------------------------------------------------------------------------- // --->
<!--- // ------------------------------------------------------------------------- // --->
<core:Email
subject="Code blocks"
teaser="Talking nerdy to me!">
<core:Body>
<cfoutput>
<html:h1>
Sharing code blocks in email
</html:h1>
<html:p>
Let's look at some <html:code>inline code</html:code> samples. Code
blocks tend to use a monospace font and have a light background color.
If we're going to include HTML tags in our code, we have to be sure
to encode the brackets:
<html:code>#encodeForHtml( "<strong>Cool Beans</strong>" )#</html:code>.
</html:p>
<html:p>
We can also use pre-formatted blocks of code - these are a bit more
complex, because they use both
<html:code>#encodeForHtml( "<html:pre>" )#</html:code> and
<html:code>#encodeForHtml( "<html:code>" )#</html:code> tags and have
significantly more formatting.
</html:p>
<html:pre><html:code>#htmlEditFormat( fileRead( "./ex10/code-sample.txt" ) )#</html:code></html:pre>
<html:p>
Easy peasy, lemon squeezey!
</html:p>
</cfoutput>
</core:Body>
</core:Email>
Normally, when interpolating "content" into a ColdFusion template, I would use the encodeForHtml()
function. However, Outlook does not seem to interpret encoded-content property inside the <pre>
tag. As such, I have to opt for htmlEditFormat()
, which encodes relatively fewer HTML entities. This allows Outlook to have a more consistent rendering.
And, see this rendering on both ancient and modern devices, here's a small sample of the output:
Apple Mail 13 on MacOS
Outlook 2013 on Windows 10
iPhone 11 Pro on MacOS
As you can see, it's decently consistent. Though, you will probably notice that the line-breaks happen mid-word. In a "real world" environment, I would have something like overflow: auto
on the container and have the long lines just create horizontal scrolling. However, in an email context, we don't have access to overflow
constructs. As such, I have to fallback to forcing line-breaks in an attempt to "save" the layout (for the greater good!).
ASIDE: The long string of
xxxxx
values breaks AOL Mail even with theword-break
CSS settings.
Entity Tags That's Aren't Direct HTML Tag Wrappers
The <html:pre>
ColdFusion custom tag, much like the <html:blockquote>
tag, isn't implemented as a simple wrapper around the semantic HTML tag. Both tags are actually implemented as <table>
tags under the hood in order to get more fine-grained control over the layout and styling so that we can ensure better consistency across all email clients.
This impedance mismatch makes it easier to build; but, it makes it harder to customize from a developer consumption stand-point. If a developer wanted to add more styling to these abstractions, they would start to become leaky abstractions in that the developer would have to actually understand how they were implemented under the hood in order to know which styles were available to customize.
I am not quite sure what the best long-term strategy for this is. The current approach works; but, it feels brittle. One thing I am considering doing is exposing all the underlying elements as themeable entities.
With all that said, I'm still really excited about the progress here! I'm actually planning to start applying these practices at InVision next week during out all company Hackathon! I'm going to see how long it takes me to replace (much of) our current email system using these ColdFusion custom tags!
Want to use code from this post? Check out the license.
Reader Comments