DEV Community

Cover image for Render Markdown as HTML in Strapi using controllers
Daniel Sieradski
Daniel Sieradski

Posted on

Render Markdown as HTML in Strapi using controllers

Strapi is a wonderfully robust headless content management system — one I absolutely adore and believe you will as well — but one shortcoming some users have identified, myself included, is that there's no built-in option to render HTML from the Markdown generated by the default WYSIWYG text editor.

To address this issue, some users opt to replace the default editor with one that saves HTML to the database rather than Markdown, such as CKEditor. Others choose to render the Markdown in their frontend application directly, adding the additional overhead to their site builds, which is not always desirable when working with the JAMstack and every kilobyte in the lambda counts.

There is, however, another way. While Strapi does not parse your Markdown for you, it does provide a powerful interface for modifying outgoing API responses, called controllers, which allow for you to parse the Markdown in the response itself.

It all depends on our dependencies

For this project, I have selected Marked as the Markdown parser we'll be using. Before beginning, install it in your Strapi project's root folder:

yarn add marked
Enter fullscreen mode Exit fullscreen mode

Head of the class

Create a new folder in your Strapi project's root called functions. Fire up your editor and create a new file within the functions folder called md.js.

We'll now create a new class for our Markdown renderer. The class constructor will take three parameters:

  1. model: The model of the collection or single-type whose text fields will be rendered to HTML. From this object, we'll take the value of the attributes key.
  2. types: The field types to convert to HTML. This will be an object consisting of two arrays: One for standard fields to render wrapped in <p> tags and one for inline fields which will not be wrapped.
  3. options: This is an options object with settings corresponding to our chosen Markdown parser.

We'll also specify global defaults and instantiate our Markdown renderer.

const defaults = {
  types: {
    standard: ['richtext'],
    inline: ['string']
  },
  options: {
    smartypants: true,
    headerIds: false,
    breaks: true
  }
}

class StrapiMarkdown {
  constructor(
    model,
    types = defaults.types,
    options = defaults.options
  ) {
      this.model = model.attributes
      this.types = types

      this.marked = require('marked')
      this.marked.setOptions(options)
    }
}

module.exports = StrapiMarkdown
Enter fullscreen mode Exit fullscreen mode

You're valid

Now that the class is defined, we'll add some validation to ensure that any errors in the data passed to our constructor are caught. We'll do this with some basic if/else statements that check whether the necessary fields exist in the passed objects.

For brevity's sake, I've redacted the defaults and module export and will continue in this fashion as we proceed.

class StrapiMarkdown {
  constructor(model, types, options) {
    if (model && model.attributes) {
      this.model = model.attributes
    } else {
      throw new Error('`model` must be valid model object')
    }

    if (types && types.standard && Array.isArray(types.standard) && types.inline && Array.isArray(types.inline)) {
      this.types = types
    } else {
      throw new Error('`types` must be object containing `standard` and `inline` arrays')
    }

    if (options && options.constructor === Object) {
      this.marked = require('marked')
      this.marked.setOptions(options)
    } else {
      throw new Error('`options` must be valid object')
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The method to the madness

Next, we'll add two methods to our class, in the form of arrow functions, so that they inherit the class' this object.

The first, parse, is the actual data handler. It steps through the model and applies the Markdown parser to the data fields matching those specified in our types configuration object. For each key in the collection/single type's model, we'll check if the corresponding key exists in the incoming response object. If so, we'll check whether its type matches either the standard or inline format, and then apply the Markdown parser.

Because Strapi passes all responses as promises, we'll need to resolve the promise to properly access the data. I prefer the async/await syntax, so that's what I'll be using.

class StrapiMarkdown {
  constructor(model, types, options) { ... }

  parse = async data => {
    try {
      const item = await data

      for (let key in this.model) {
        if (item[key]) {
          if (this.types.standard.includes(this.model[key].type)) {
            item[key] = this.marked(item[key])
          } else if (this.types.inline.includes(this.model[key].type)) {
            item[key] = this.marked.parseInline(item[key])
          }
        }
      }
      return item
    } catch (err) {
      console.error(err)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The second method, md, determines whether the response data passed in from our controller is an array or a single object and, in turn, passes it to the data handler accordingly. Because we need to resolve all the promises in the parse method before passing back the data to our controller, we'll use Promise.all to resolve each object as it's mapped over.

class StrapiMarkdown {
  constructor(model, types, options) { ... }
  parse = async data => { ... }

  md = data => {
    try {
      if (Array.isArray(data)) {
        return Promise.all(data.map(obj => this.parse(obj)))
      } else {
        return this.parse(data)
      }
    } catch (err) {
      console.error(err)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Bringing it all back home

Our completed md.js file should now look like this:

const defaults = {
  types: {
    standard: ['richtext'],
    inline: ['string']
  },
  options: {
    smartypants: true,
    headerIds: false,
    breaks: true
  }
}

class StrapiMarkdown {
  constructor(
    model,
    types = defaults.types,
    options = defaults.options
  ) {
    if (model && model.attributes) {
      this.model = model.attributes
    } else {
      throw new Error('`model` must be valid model object')
    }

    if (types && types.standard && Array.isArray(types.standard) && types.inline && Array.isArray(types.inline)) {
      this.types = types
    } else {
      throw new Error('`types` must be object containing `standard` and `inline` arrays')
    }

    if (options && options.constructor === Object) {
      this.marked = require('marked')
      this.marked.setOptions(options)
    } else {
      throw new Error('`options` must be valid object')
    }
  }

  parse = async data => {
    try {
      const item = await data

      for (let key in this.model) {
        if (item[key]) {
          if (this.types.standard.includes(this.model[key].type)) {
            item[key] = this.marked(item[key])
          } else if (this.types.inline.includes(this.model[key].type)) {
            item[key] = this.marked.parseInline(item[key])
          }
        }
      }
      return item
    } catch (err) {
      console.error(err)
    }
  }

  md = data => {
    try {
      if (Array.isArray(data)) {
        return Promise.all(data.map(obj => this.parse(obj)))
      } else {
        return this.parse(data)
      }
    } catch (err) {
      console.error(err)
    }
  }
}

module.exports = StrapiMarkdown
Enter fullscreen mode Exit fullscreen mode

Everything is under control

With our class finished, we can now add it to our API's controllers.

Navigate to the controllers folder corresponding to the collection or single type whose output you want to modify and open the controller file in your editor (eg. api/posts/controllers/posts.js).

First, import the class we created, then the model of the collection or single type.

const StrapiMarkdown = require('../../functions/md.js')
const model = require('../models/posts.settings.json')
Enter fullscreen mode Exit fullscreen mode

Next, instantiate the class.

const { md } = new StrapiMarkdown(model)
Enter fullscreen mode Exit fullscreen mode

If you wish to change the fields to be parsed, or adjust the parser options, you can pass those settings in as well.

const types = {
  standard: ['richtext', 'text'],
  inline: []
}

const options = {
  smartypants: false,
  headerIds: true,
  breaks: true
}

const { md } = new StrapiMarkdown(model, types, options)
Enter fullscreen mode Exit fullscreen mode

Finally, we'll create custom find and findOne methods to replace the default methods Strapi generates internally. Each method will await the corresponding Strapi service method invoked with its corresponding default parameters but now wrapped in our Markdown class' md method.

module.exports = {
  async find(ctx) {
    return md(await strapi.services.posts.find(ctx.query))
  },
  async findOne(ctx) {
    const { id } = ctx.params
    return md(await strapi.services.posts.findOne({ id }))
  }
}
Enter fullscreen mode Exit fullscreen mode

Once it's all put together you should have:

const StrapiMarkdown = require('../../functions/md.js')
const model = require('../models/posts.settings.json')

const { md } = new StrapiMarkdown(model)

module.exports = {
  async find(ctx) {
    return md(await strapi.services.posts.find(ctx.query))
  },
  async findOne(ctx) {
    const { id } = ctx.params
    return md(await strapi.services.posts.findOne({ id }))
  }
}
Enter fullscreen mode Exit fullscreen mode

Rinse and repeat for each collection or single type whose output you wish to transform from Markdown to HTML.

Testing, testing, 1-2-3

Start up your Strapi project and give your API a call!

curl http://localhost:1337/your_modified_collection
Enter fullscreen mode Exit fullscreen mode

If all went well, you should now be seeing HTML instead of Markdown in your API's response data.

Wrapping up

You should now have some basic grounding in how to create your own custom Strapi controllers to transform your API response data.

If creating the handler seemed like an inordinate amount of work when you can just use something off-the-shelf in your frontend project, bear in mind, you only need to create such a module once and you can then use it over-and-over again in all your different Strapi projects.

In fact, I've already gone ahead and saved you the trouble, turning this tutorial into an npm module that you can import into your Strapi controllers so that you never have to roll your own!

yarn add strapi-markdown-parser
Enter fullscreen mode Exit fullscreen mode

Now you can skip every other step and jump straight to:

const StrapiMarkdown = require('strapi-markdown-parser')
const model = require('../models/posts.settings.json')

const { md } = new StrapiMarkdown(model)

module.exports = {
  async find(ctx) {
    return md(await strapi.services.posts.find(ctx.query))
  },
  async findOne(ctx) {
    const { id } = ctx.params
    return md(await strapi.services.posts.findOne({ id }))
  }
}
Enter fullscreen mode Exit fullscreen mode

Why didn't I just say that in the first place? Well, you wouldn't have learned much then, would you? 😉

Top comments (3)

Collapse
 
casey_milne profile image
Casey Milne

Thanks for making this Daniel. I'm going to try the NPM version, you put a lot into this, it's a more complex problem to solve than I realized. When I first search for converting Strapi rich text to HTML I had thought maybe it was a configuration in the field settings.

Collapse
 
mirdulagarwal1201 profile image
MIRDUL SWARUP

when i am copying and pasting the md.js file there is an error that,

This expression is not callable.
Type 'typeof import("marked")' has no call signatures.

in line 48,

and also when i am copying and pasting the controller file then i am receiving:
Cannot find module '../../functions/md.js' or its corresponding type declarations.
Cannot find module '../models/posts.settings.json' or its corresponding type declarations.

Property 'posts' does not exist on type 'Services'.

Had high hopes from the code blocks, but its just not working

Collapse
 
selfagency profile image
Daniel Sieradski • Edited

It's probably just a package incompatiblity issue with Marked. This was, after all, written a while ago. Just swap in another Markdown to HTML converter.