Skip to main content
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: Eric Stevens
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: Eric Stevens

CF_SaveFile Custom Tag In ColdFusion

By
Published in Comments (8)

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>
					&mdash; 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&#x20;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>
			&mdash; 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

247 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.

15,902 Comments

@Chris,

OMG, I can't tel you how many times I've gotten syntax errors because XML documents have leading whitespace. #ParserFail

15,902 Comments

@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:

component hint="Application.cfc" {
	this.mappings = {
		"/tags": _path_to_my_tags_folder_
	};
}

Then, in my application logic, I'll invoke the custom tag using the CFModule tag. Something like:

<cfmodule template="/tags/SaveContent.cfm" filePath="">
	// ...rest of the content ...
</cfmodule>

Since we setup a mapping for /tags, this code will work from anywhere within the application; and, will be able to locate my SaveContent.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):

<cfimport prefix="my" taglib="../../lib/tags" />

<my:SaveContent> ... </my:SaveContent>

Also, another option is to define the this.customTagPaths property in the Application.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 the cf_ syntax, like:

<cf_SaveFile> ... </cf_SaveFile>

That's a lot to take in -- hopefully that helps point you in the right direction.

20 Comments

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 and code blocks,

15,902 Comments

@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 the pre blocks in order to get it to render correctly. As an example:

<section>
	<p> ... some content ... </p>
	<div>
<pre><code> ... manually dedented ... </code></pre>
	</div>
</section>

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 :)

20 Comments

@Ben

As it turns out, jSoup's parse() method will return tidy HTML so I just ended up running the generated HTML through parse() and write the contents to disk.

15,902 Comments

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

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel