Search

Since I am using Markdown to format my entries, I needed some way of preserving this syntax for front end editing forms. If I was going to use the xsl:copy instruction, my form textarea would be filled with raw HTML. It was time to use some Ninja XSL techniques.

For now, I am sticking to the most commonly used elements:

h1 h2 h3 h4 h5 h6 p ul li strong em a

First, provide the instruction to match all elements of the content node:

<textarea name="fields[body]" rows="15" cols="50"><xsl:apply-templates select="body"/></textarea>

Then add the following to the page template:

<xsl:template match="body//*">
  <xsl:element name="{name()}">
    <xsl:apply-templates select="* | @* | text()"/>
  </xsl:element>
</xsl:template>

<xsl:template match="h1" priority="1">
  <xsl:text># </xsl:text><xsl:value-of select="text()"/>
</xsl:template>

<xsl:template match="h2" priority="1">
  <xsl:text>## </xsl:text><xsl:value-of select="text()"/>
</xsl:template>

<xsl:template match="h3" priority="1">
  <xsl:text>### </xsl:text><xsl:value-of select="text()"/>
</xsl:template>

<xsl:template match="h4" priority="1">
  <xsl:text>#### </xsl:text><xsl:value-of select="text()"/>
</xsl:template>

<xsl:template match="h5" priority="1">
  <xsl:text>##### </xsl:text><xsl:value-of select="text()"/>
</xsl:template>

<xsl:template match="h6" priority="1">
  <xsl:text>###### </xsl:text><xsl:value-of select="text()"/>
</xsl:template>

<xsl:template match="p" priority="1">
  <xsl:value-of select="* | text()"/>
</xsl:template>

<xsl:template match="ul" priority="1">
  <xsl:apply-templates select="* | text()"/>
</xsl:template>

<xsl:template match="li" priority="1">
  <xsl:text>* </xsl:text>
  <xsl:apply-templates select="*"/><xsl:value-of select="text()"/>
</xsl:template>

<xsl:template match="strong" priority="1">
  <xsl:text> **</xsl:text><xsl:value-of select="text()"/><xsl:text>** </xsl:text>
</xsl:template>

<xsl:template match="em" priority="1">
  <xsl:text> _</xsl:text><xsl:value-of select="text()"/><xsl:text>_ </xsl:text>
</xsl:template>

<xsl:template match="a" priority="1">
  <xsl:text>[</xsl:text>
  <xsl:value-of select="text()"/>
  <xsl:text>](</xsl:text>
  <xsl:value-of select="@href"/>
  <xsl:if test="@title">
    <xsl:text> "</xsl:text>
    <xsl:value-of select="@title"/>
    <xsl:text>"</xsl:text>
  </xsl:if>
  <xsl:text>)</xsl:text>
</xsl:template>

For character entities, these will be preserved just fine, so no need really to mess with the typography. If anyone wants to add to this to complete compatibility with the full Markdown syntax, please feel free.

Nifty ninja-ing, Stephen! How would you handle ol lists, or even worse, nested ol lists? In entries with mixed ordered and unordered lists, my guess is that it would be difficult to discriminate what kind of li markup, or Markdown to be precise, the XSL should produce. Yes? Just for the banter, in the ordered list scenario, the li entry would be:

<xsl:template match="li" priority="1">
    <xsl:text>1. </xsl:text>
    <xsl:apply-templates select="*"/><xsl:value-of select="text()"/>
</xsl:template>

(Oops! Forgot the period!) Even I could work that out (almost...)! ;)

David.

To accommodate ordered lists, it looks like the ul and li need some adjustments as well. These assume that the ul element does not have a text() value, simply because I don't think Markdown supports it:

<xsl:template match="ul" priority="1">
  <xsl:apply-templates select="*"/>
</xsl:template>

<xsl:template match="ul/li" priority="1">
  <xsl:text>* </xsl:text>
  <xsl:apply-templates select="*"/><xsl:value-of select="text()"/>
</xsl:template>

<xsl:template match="ol" priority="1">
  <xsl:apply-templates select="*"/>
</xsl:template>

<xsl:template match="ol/li" priority="1">
  <xsl:value-of select="position()"/>
  <xsl:text>. </xsl:text>
  <xsl:apply-templates select="*"/><xsl:value-of select="text()"/>
  <xsl:text>
</xsl:text>
</xsl:template>

This appears to work for single-level lists.

No, that didn't work. Placing the value of the text node outside of the xsl:apply-templates instruction was an attempt to control the white space, that is, to remove the extra return characters that were messing up the syntax. I was experimenting with a bunch of different ways of doing this, then finally remembered the normalize-space() function. By applying this function to the text nodes that were causing the problem, I came up with the following solution. You'll just need an extra template for each level of nested lists.

<xsl:template match="ul" priority="1">
  <xsl:apply-templates select="*"/>
</xsl:template>

<xsl:template match="ul/li" priority="1">
  <xsl:text>* </xsl:text>
  <xsl:apply-templates select="* | text()"/>
  <xsl:text>
</xsl:text>
</xsl:template>

<xsl:template match="ul/li/text()" priority="1">
  <xsl:value-of select="normalize-space(.)"/>
</xsl:template>

<xsl:template match="ul/li/ul/li" priority="1">
  <xsl:text>
    * </xsl:text>
  <xsl:apply-templates select="* | text()"/>
</xsl:template>

<xsl:template match="ul/li/ul/li/ul/li" priority="1">
  <xsl:text>
        * </xsl:text>
  <xsl:apply-templates select="* | text()"/>
</xsl:template>

<xsl:template match="ol" priority="1">
  <xsl:apply-templates select="*"/>
</xsl:template>

<xsl:template match="ol/li" priority="1">
  <xsl:value-of select="position()"/>
  <xsl:text>. </xsl:text>
  <xsl:apply-templates select="* | text()"/>
  <xsl:text>
</xsl:text>
</xsl:template>

<xsl:template match="ol/li/text()" priority="1">
  <xsl:value-of select="normalize-space(.)"/>
</xsl:template>

<xsl:template match="ol/li/ol/li" priority="1">
  <xsl:text>
    </xsl:text>
  <xsl:value-of select="position()"/>
  <xsl:text>. </xsl:text><xsl:apply-templates select="* | text()"/>
</xsl:template>

<xsl:template match="ol/li/ol/li/ol/li" priority="1">
  <xsl:text>
        </xsl:text>
  <xsl:value-of select="position()"/>
  <xsl:text>. </xsl:text><xsl:apply-templates select="* | text()"/>
</xsl:template>

It took me way too long to figure that one out.

Might as well add a couple more:

p/code blockquote

Add this to the page template:

<xsl:template match="p/code" priority="1">
  <xsl:text>`</xsl:text><xsl:value-of select="text()"/><xsl:text>`</xsl:text>
</xsl:template>

<xsl:template match="blockquote" priority="1">
  <xsl:text>&gt; </xsl:text><xsl:apply-templates select="* | text()"/>
</xsl:template>

<xsl:template match="blockquote/text()" priority="1">
  <xsl:value-of select="normalize-space(.)"/>
</xsl:template>

I'm stumped on how to do code blocks. I tried using translate() to convert a return character into a return character plus four spaces. These templates don't appear to work:

<xsl:template match="pre" priority="1">
  <xsl:apply-templates select="* | text()"/>
</xsl:template>

<xsl:template match="pre/code" priority="1">
  <xsl:text>   </xsl:text><xsl:apply-templates select="text()"/>
</xsl:template>

<xsl:template match="pre/code/text()" priority="1">
  <xsl:copy-of select="translate(.,'
  ','
    ')"/>
</xsl:template>

I'm probably better off leaving this as raw HTML anyway.

Code blocks are a little more involved since each line has to be parsed separately and output with the appropriate white space.

<xsl:template match="pre/code/text()" priority="1">
  <xsl:text>    </xsl:text>
  <xsl:call-template name="markdown-code-block">
    <xsl:with-param name="input" select="."/>
  </xsl:call-template>
</xsl:template>

<xsl:template name="markdown-code-block">
  <xsl:param name="input"/>
  <xsl:param name="value">
    <xsl:choose>
      <xsl:when test="contains($input,'&#xa;')">
        <xsl:value-of select="substring-before($input,'&#xa;')"/>
      </xsl:when>
      <xsl:otherwise>
        <xsl:value-of select="$input"/>
      </xsl:otherwise>
    </xsl:choose>
  </xsl:param>
  <xsl:param name="remaining-values" select="substring-after($input,'&#xa;')"/>
  <xsl:value-of select="substring-before($input,'&#xa;')"/><xsl:text>&#xa;    </xsl:text>
  <xsl:if test="$remaining-values != ''">
    <xsl:call-template name="markdown-code-block">
      <xsl:with-param name="input" select="$remaining-values"/>
    </xsl:call-template>
  </xsl:if>
</xsl:template>

Brilliant work, Stephen! This will come in very handy.

Thanks, Egor.

I realized that if I want to use apply-templates on the body for any other sorts of transformations, having these templates set to priority="1" was not going to work well. So instead of using the priority attribute, it would be better to use mode. That way, I could potentially make one pass to do something like searching and replacing string values, and then I can convert HTML to Markdown with the following xsl:apply-templates instruction:

<textarea name="fields[body]" rows="15" cols="50"><xsl:apply-templates select="body" mode="markdown"/></textarea>

Then, the templates are instructed to match the elements and process the HTML to Mardown conversions only when mode="markdown". For example,

<xsl:template match="h1" mode="markdown">
  <xsl:text># </xsl:text><xsl:value-of select="text()"/>
</xsl:template>

The full code is too long to post here, so I've attached an html-manipulation.xsl utility with the HTML to Markdown templates. Then, it's just a matter of importing this utility into your page template:

<xsl:import href="../utilities/html-manipulation.xsl"/>

And add whatever other sorts of HTML transformations you like to this utility (or another utility).

I just realized it's a simple thing to be able to modify either the raw HTML or the Markdown formatted text. First, I test to see whether the $action URL parameter is equal to the string edit. If it is, then I show the edit form. I can also have a $result URL parameter that I use for form action results such as "success" or "error". I can also use this param for requesting a different mode for the edit form. If $result is equal to "html", I can switch the form content to raw HTML by using the xsl:copy-of instruction:

<textarea name="fields[body]" rows="15" cols="50">
  <xsl:choose>
    <xsl:when test="$result = 'html'">
      <xsl:copy-of select="body/*"/>
    </xsl:when>
    <xsl:otherwise>
      <xsl:apply-templates select="body" mode="markdown"/>
    </xsl:otherwise>
  </xsl:choose>
</textarea>

I overlooked an important element: br. It was a pretty simple one.

<xsl:template match="br" mode="markdown">
  <xsl:text>  </xsl:text>
</xsl:template>

I have updated the html-manipulation.xsl file above.

Hi Stephen: This is a great thing to have available within Symphony. If anyone is interested in a non-Symphony utility to do HTML-to-Markdown conversion, I ran into this bookmarklet recently which I have found works quite nicely. The hint on that page to use in conjunction with the Firefox “Aardvark” extension was a useful one, too!

FWIW! David.

Thanks for this, Stephen. It rocks, I'm playing with some front-end editing at the moment and it works great. I'm just wondering if there's a neat way to deal with HTML that's entered alongside Markdown text.

At the moment custom elements — i.e. <blockquote class="large"> — are converted back to Markdown at the front-end. Do you see an easy way of dealing with that?

I was thinking maybe you could ignore any elements that have an attribute (unless it's an img/a/etc). I'm only just getting into XSLT, so please excuse my ignorance.

Ignoring elements with attributes is possible. The logic would be something like this:

<xsl:templates match="*[not(@*)]">
    ...
</xsl:templates>

This is from the top of my head, so I might have missed something. The logic is as follows:

"Match any element where any attribute not exist"

Thanks, Allen. I couldn't quite get that to work, but doing the opposite seems to. I've modified the html-manipulation file to include the following:

&lt;xsl:template match="body//*[@*] | intro//*[@*]" mode="markdown"&gt;
    &lt;xsl:copy-of select="."/&gt;
&lt;/xsl:template&gt;

I'm using two fields called intro and body, hence the extra rule. Also, I noticed there wasn't a case for <img>s. So I've also added:

&lt;xsl:template match="img" priority="1" mode="markdown"&gt;
  &lt;xsl:text&gt;![&lt;/xsl:text&gt;
  &lt;xsl:value-of select="@alt"/&gt;
  &lt;xsl:text&gt;](&lt;/xsl:text&gt;
  &lt;xsl:value-of select="@src"/&gt;
  &lt;xsl:if test="@title"&gt;
    &lt;xsl:text&gt; "&lt;/xsl:text&gt;
    &lt;xsl:value-of select="@title"/&gt;
    &lt;xsl:text&gt;"&lt;/xsl:text&gt;
  &lt;/xsl:if&gt;
  &lt;xsl:text&gt;)&lt;/xsl:text&gt;
&lt;/xsl:template&gt;

@Makenosound, thank you! I've added this to my System Navigation ensemble.

Just noticed there wasn't a case for dealing with horizontal rules.

  <xsl:template match="hr" priority="1" mode="markdown">
    <xsl:text>* * * * *</xsl:text>
  </xsl:template>

You might want to change this depending on your preference for marking up


elements in Markdown.

@Makenosound, thanks for noticing. I'll add this when I get a chance.

Another addition, the above code for dealing with images removes any references to class or id attributes. You'll need this instead:

<xsl:template match="img[not(@class | @id)]" priority="1" mode="markdown">
  <xsl:text>![</xsl:text>
  <xsl:value-of select="@alt"/>
  <xsl:text>](</xsl:text>
  <xsl:value-of select="@src"/>
  <xsl:if test="@title">
    <xsl:text> "</xsl:text>
    <xsl:value-of select="@title"/>
    <xsl:text>"</xsl:text>
  </xsl:if>
  <xsl:text>)</xsl:text>
</xsl:template>

Just discovered a little bug in this utility. When you have a link nested within a list item the conversion removes the spaces around the Markdown syntax, so instead of this [is a](http://blah.com) link you end up with this[is a](http://blah.com)link. Quick fix is to add an additional template for the nested items with a space before and after the normal link syntax:

<xsl:template match="li/a" priority="1" mode="markdown">
  <xsl:text> [</xsl:text>
  <xsl:value-of select="text()"/>
  <xsl:text>](</xsl:text>
  <xsl:value-of select="@href"/>
  <xsl:if test="@title">
    <xsl:text> "</xsl:text>
    <xsl:value-of select="@title"/>
    <xsl:text>"</xsl:text>
  </xsl:if>
  <xsl:text>) </xsl:text>
</xsl:template>

Create an account or sign in to comment.

Symphony • Open Source XSLT CMS

Server Requirements

  • PHP 5.3-5.6 or 7.0-7.3
  • PHP's LibXML module, with the XSLT extension enabled (--with-xsl)
  • MySQL 5.5 or above
  • An Apache or Litespeed webserver
  • Apache's mod_rewrite module or equivalent

Compatible Hosts

Sign in

Login details