CF_SaveFile Custom Tag In ColdFusion
A couple of years ago, on the Lucee Dev forum, I proposed a file
attribute for the CFSaveContent
tag in ColdFusion. This comes out of a pattern that I employ quite often in which I use the CFSaveContent
tag to generate the contents of a static file; and then, use the fileWrite()
function to save said content disk. So, why not combine these two steps into a single step using a ColdFusion custom tag.
The CFSaveContent
tag is little more than a custom tag that the ColdFusion server has made globally available. There's no magic to it—it simply grabs the generatedContent
value, stores it into the provided variable
name, and then resets the output. We can do this ourselves in our own custom tag. Only, instead of assigning the content to a variable, we can write the content directly to a file.
Yesterday, I wrote about dedenting text in ColdFusion. That post was a precursor to this post. When you consider the aesthetics of using the CFSaveContent
tag, especially when used in conjunction with the CFOutput
tag, there is always some unnecessary whitespace on the left side of the content:
<cfsavecontent variable="data">
<cfoutput>
<!--- ... CONTENT ... --->
</cfoutput>>
</cfsavecontent>
In order to keep the content properly nested, it requires at least one level of indentation below the CFSaveContent
tag; and, possibly two levels if there's an intermediary CFOutput
tag. But, we don't need—or want—this indentation in the resultant file. As such, before we save the content, we need to dedent it by one or two tabs.
In Lucee CFML's version of the CFSaveContent
tag, they introduced the trim
and append
attributes. We can include those attributes as well in our custom tag; and, provide new dedent
(Boolean) and indentation
("tab"
or "space"
) attributes to control the dedenting behavior.
First, let's see how this might be used (with sane defaults). In the following ColdFusion code, I'm generating a static .html
file.
<cfscript>
author = "Ben Nadel";
createdAt = now();
</cfscript>
<cf_SaveFile filepath="#expandPath( './static.html' )#">
<cfoutput>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta nane="author" content="#encodeForHtmlAttribute( author )#" />
<meta nane="generated" content="#createdAt.dateFormat( "yyyy-mm-dd" )#" />
</head>
<body>
<h1>
SaveFile ColdFusion Custom Tag Demo
</h1>
<figure>
<blockquote>
Be excellent to each other!
</blockquote>
<figcaption>
— Ted "Theodore" Logan
</figcaption>
</figure>
</body>
</html>
</cfoutput>
</cf_SaveFile>
As you can see, we're invoking our ColdFusion custom tag using the CF_SaveFile
syntax. The contents of the HTML are indented using a base indentation of 2-tabs (as discussed above). However, when we open the resultant static.html
file, we can see that all of the unnecessary tabs have been stripped-out:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta nane="author" content="Ben Nadel" />
<meta nane="generated" content="2024-04-20" />
</head>
<body>
<h1>
SaveFile ColdFusion Custom Tag Demo
</h1>
<figure>
<blockquote>
Be excellent to each other!
</blockquote>
<figcaption>
— Ted "Theodore" Logan
</figcaption>
</figure>
</body>
</html>
Note that the left-most parts of the content are all now flush with the left-edge of the HTML file. That's the magic of dedenting.
Other than the dedenting functionality, the rest of the logic in our SaveFile.cfm
custom tag is rather straightforward:
<cfscript>
// Define tag attributes and defaults.
param name="attributes.filePath" type="string";
param name="attributes.dedent" type="string" default=true;
param name="attributes.indentation" type="string" default="tab";
param name="attributes.trim" type="boolean" default=true;
param name="attributes.append" type="boolean" default=false;
param name="attributes.charset" type="string" default="utf-8";
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// Since this ColdFusion custom tag deals with generated output, we only care about
// the tag in its "end" mode once we have generated content to consume.
if ( thistag.executionMode == "start" ) {
exit
method = "exitTemplate"
;
}
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
fileContent = thistag.generatedContent;
if ( attributes.dedent ) {
fileContent = dedentContent( fileContent, attributes.indentation );
}
if ( attributes.trim ) {
fileContent = trim( fileContent );
}
targetFile = ( attributes.append )
? fileOpen( attributes.filePath, "append", attributes.charset )
: fileOpen( attributes.filePath, "write", attributes.charset )
;
try {
fileWrite( targetFile, fileContent, attributes.charset )
} finally {
fileClose( targetFile );
}
// We don't want this tag to generate content - all content was written to the file.
thistag.generatedContent = "";
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
/**
* I remove indentation from the given input but leave the input such that the relative
* indentation across lines remains constant.
*/
private string function dedentContent(
required string input,
required string indentation
) {
var indentationCharacter = ( indentation == "tab" )
? chr( 9 ) // Tab.
: chr( 32 ) // Space.
;
// In order to figure out how much we can dedent the text, we must first locate
// the smallest amount of indentation that currently exists across all lines of
// text. However, we only care about lines of text that have non-indentation
// characters on them (ie, we want to skip over empty lines). As such, we're going
// to create a pattern that must end on non-indentation (or line-break) character.
var minWidth = input
.reMatch( "(?m)^#indentationCharacter#*[^#indentationCharacter#\r\n]" )
.map(
( linePrefix ) => {
return ( linePrefix.len() - 1 );
}
)
// NOTE: ArrayMin() returns zero if array is empty.
.min()
;
if ( ! minWidth ) {
return input;
}
// Now that we've found the smallest amount of indentation across all lines of
// text, we can remove exactly that amount of indentation from each line of text.
var result = input
.reReplace( "(?m)^#indentationCharacter#{#minWidth#}", "", "all" )
;
return result;
}
</cfscript>
Ultimately, the whole dedenting concept is a nice-to-have feature of the SaveContent.cfm
custom tag. But, the general workflow would have worked just as well without it; and would have greatly reduced the logic within our implementation. Only, we would have had some superfluous tabs in our generated file—not the end of the world.
Which leads me back to my original proposition: that it would be nice for the native CFSaveContent
tag to include a file
attribute. Perhaps even one that could represent either a file path or an instantiated file object.
Man, I love ColdFusion! Having both a tag-based model and a script-based model makes the language so darned flexible!
Want to use code from this post? Check out the license.
Reader Comments
The use case that immediately comes to mind for me is archiving off generated transactional emails. Another is generating XML sitemaps, where dedenting isn't just a nice to have, it's necessary.
how can you make this available in all of your application?
@Chris,
OMG, I can't tel you how many times I've gotten syntax errors because XML documents have leading whitespace. #ParserFail
@Daniel,
Typically what I'll do in my applications is that I have a designated folder for custom tags. For the sake of discussion, let's call it:
/lib/tags/
Then, I usually define a per-application mapping in my
Application.cfc
for this folder:Then, in my application logic, I'll invoke the custom tag using the
CFModule
tag. Something like:Since we setup a mapping for
/tags
, this code will work from anywhere within the application; and, will be able to locate mySaveContent.cfc
template.Of course, there are other things you can do. If you don't mind including the relative file path in each invocation, you can use the
CFImport
tag (which can't use per-application mappings since it's a compile-time construct):Also, another option is to define the
this.customTagPaths
property in theApplication.cfc
. I haven't used this one in a while, so I forget; but, I think it's just a list of directory paths. If you add/lib/tags
to this setting, you can then reference the custom tag using thecf_
syntax, like:<cf_SaveFile> ... </cf_SaveFile>
That's a lot to take in -- hopefully that helps point you in the right direction.
The dedent method is intriguing. I use
savecontent
with commandbox-ssg (static site generator) to render pages and one issue I have with it is how the html is formed. It is a little annoying, but it renders.That looks like a good fix as long as it plays nice with
pre
andcode
blocks,@Robert,
🤔 So, I think the
pre
blocks might cause a problem. I don't know how you're authoring your stuff; but I would guess that you might be manually dedenting the code in thepre
blocks in order to get it to render correctly. As an example:If you're doing something like that, then the
pre
tag will act as the left-most content and nothing will be dedented. It depends on what you got and what you need :)@Ben
As it turns out, jSoup's
parse()
method will return tidy HTML so I just ended up running the generated HTML throughparse()
and write the contents to disk.Nice 👍 jSoup is great, I use it all the time. I actually author my posts in markdown (via Flexmark) and then run it should jSoup to modify it a bit.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →