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
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:
-
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 theattributes
key. -
types
: The field types to convert to HTML. This will be an object consisting of two arrays: One forstandard
fields to render wrapped in<p>
tags and one forinline
fields which will not be wrapped. -
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
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')
}
}
}
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)
}
}
}
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)
}
}
}
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
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')
Next, instantiate the class.
const { md } = new StrapiMarkdown(model)
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)
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 }))
}
}
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 }))
}
}
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
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
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 }))
}
}
Why didn't I just say that in the first place? Well, you wouldn't have learned much then, would you? 😉
Top comments (3)
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.
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
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.