WordPress-style shortcodes using Vue

Michael Bøcker-Larsen • March 15, 2017

vue howto

If you have worked with WordPress as a blogger or developer before, you will undoubtedly know that most sites are built by extensive plugins that spit out shortcodes. To me, WordPress has always seemed a bit like a hack. Everything is squeezed into the Post model, whose main body contains more content types and ad hoc data types, depending what content-plugins you've installed. Yikes!

However, this is how it works and the wast number of themes, plugins, and users must be a testament to its success.

[gallery id="123" size="medium"]

That is a shortcode from the documentation of Wordpress' Shortcode API. Shortcodes are easy to read and understand. Although most of them are generated by a UI tool, anyone could author them by hand. Even as a blogger, you’d most likely have to every now and then.

The idea of shortcodes is that they provide a super simple content and layout language. When I was asked to help build a project that utilizes shortcodes in a themeable Vue.js application, I was quite excited to see how you could map these shortcodes onto Vue.js components and layout. This concept could provide a tool for site owners to customize the site beyond what was achievable with CSS.

The app we are building has a number of pages. Each of the pages has a few areas that can be customized; some have more customizable areas than others. For example, a checkout page in a web shop would normally have less customization options than the shop’s front page, where almost all content and layout could be changed in various ways. We call these areas slots.

The general idea is to come up with a number of basic components that a theme-developer can style and provide a basic layout for. The application structure remains the same in terms of pages, but the layout and content in each of the slots can vary.

Goal: Turn shortcodes into Vue components

Our goal in the building of this app is to be able to add both layout and content components to our shortcode Vue.js integration. For example:

<h2>Products</h2>
[row] [col class="s6"] [product-list name="featured" use="product-card" num=3/]
[/col] [col class="s6"] [product-list name="bestsellers" use="product-card"
num=3/] [/col] [/row]

In the above example, the row and col(umn) tags represent layout components whereas product-list and product-card will be turned into content components.

Since Vue is all about creating new components and extending the HTML vocabulary, we thought we’d use our Vue components directly. However, the app we are building excessively targets users in the WordPress community. Therefore, we wanted to try this approach.

Basically these are the steps we need to go through:

  1. Convert the string we get from the backend into some other data structure so we can alter the layout and content.
  2. Figure out how we can map the codes to our components, like the ProductList or a ProductCard.
  3. Figure out how to use Vue.js to construct a new component that can instantiate our components.

.. and then finally: Demo Time!

Step 1: Abstract Syntax Tree

Convert the string we get from the backend into some other data structure so we can reason about layout and content.

This first step is not so interesting. I have built a small library, shortcode-tokenizer, for lexing the sample block above and spitting out an Abstract Syntax Tree (AST).

From the example above, we get an array of nested objects similar to this:

[
    {
        type: 'TEXT',
        body: "<h1>Products</h1>"
        pos: 0
    },
    {
        type: 'OPEN',
        name: 'row',
        pos: 18,
        body: '[row]',
        isClosed: true,
        params: {},
        children: [
            ... a whitespace TEXT token ...,
            {
                type: 'OPEN',
                name: 'col',
                pos: 26,
                body: '[col class="s6"]',
                isClosed: true,
                params: {
                    class: 's6'
                },
                children: [ ... and so on ... ]
             }
        ]
    }
]

It provides us with a nested object structure that has all parameters prepared and all the elements labeled as text, self-closing, or regular shotcode.

We will use this data structure as the source of what to build in Vue.

Step 2: Mapping codes to components

Figure out how we map the codes to our components like the ProductList or a ProductCard.

Vue components are typically named according to the import of the parent component, but Vue is quite flexible and you can reference your components in a number of ways: TitleCase, lowerTitleCase, and kebab-case. The latter is the same syntax we would use for our shortcodes, so this shouldn't be too hard. In most cases, we can just use the same name to refer to the Vue components:

export default {
  components: {
    ProductList,
  },
}

ProductList can now, as you know, be referenced as <product-list> in the component template, thanks to a little vue-magic. Vue exposes its name tranformation functions through Vue.util. We will make use of them later.

An argument for making an explicit map: the code name and component name may differ because they follow different guidelines. For example, we have used [col] for a column above, which is a shorthand for a component name. I prefer full words as it reads more easily, but in shortcode land, it's okay to go with the short form — it mirrors the [row] code well (things that look alike...).

So although we don’t actually need to map the code names, this would still be a good idea because it wouldn’t mess with the way we like to work with Vue.

Another benefit is that we can refer to this map during the parsing. Additionally, in case we come across an unknown code (by absence in the map), we can treat it as an error in the same place as syntax errors—rather than having to let the errors occur in the Vue runtime when trying to instantiate components that do not exist.

So let us create a map:

const codeMap = {
  col: "column",
  row: "row",
  "product-list": "product-list",
}

The key is the shortcode and the value is the kebab-cased name of our Vue components.

Step 3: Wrapping up (or getting started)

Figure out how to use Vue to construct a new component that can instantiate our components.

Now that we have an AST, we need to parse or render into templates and components using the map we defined in the previous step.

Vue provides a few built-in components that can make your pages more dynamic in the sense that you don’t have to specify what components are rendered or how they are rendered beforehand. These are <component> and <slot> respectively.

The <component> component’s functionality is close to what we want. However, we have to include all our components on every page and in every parent component where we want to use or shortcode slot, since the slot, in theory, could contain references to all of our components.

<template>
  <component :is="chosenComponent"></component>
</template>
<script>
import Row from "components/Row"
import Column from "components/Column"
import ProductList from "components/ProductList"
import ProductCard from "components/ProductCard"
// ... and so on ...

export default {
  components: {
    Row,
    Column,
    ProductList,
    ProductCard,
    // ... and so on  ...
  },
  data() {
    return { chosenComponent: "product-list" }
  },
}
</script>

This a bit cumbersome to say the least. We don’t want to repeat this for each parent component. This chore needs to be encapsulated as well.

Requirements

So let's wrap this up. We will make a new component that is similar to <component>, but one that has knowledge of all the components that we want to expose to the shortcode slot. We will call this <code-slot>.

But first, recall our template is a mixture of text (HTML) and shortcodes. Basically, we can have text all around and between each of the open and close codes. We need to add this to the template as well.

When you have highly dynamic content, <component> and <slot> might not be enough. In that case, you can write your own render function.

(Internally, all your templates are turned into render functions.)

Our component acts as a controller in terms of what gets rendered but doesn’t add any value content or functionality besides the content of the slot. For these kinds of scenarios, you would go for a functional component, because it has less overhead as it does not maintain state. You would simply pass on control to another component. However, since we also have to render the text, we don’t have a single component that can handle all our needs. For this reason, we will use a regular component.

(If anyone knows if and how the following can be made as a functional component, I would love to learn how!)

Here goes. We will need Vue and the tokenizer:

// components/CodeSlot.vue import { default as Tokenizer } from
'shortcode-tokenizer' import Vue from 'vue'

We also want to import all the components we want to include as shortcodes:

import Row from "components/Row"
import Column from "components/Column"
import ProductList from "components/ProductList"
import ProductCard from "components/ProductCard"

Then we add our map, but with a little twist. We need the map in two cases.

  1. to get the kebab-cased name for building the template
  2. to pass into the components: property of our component, so instead of mapping from code to component name, we will map directly to the imported component:
const codeMap = {
  row: Row,
  column: Column,
  col: Column,
  "product-list": ProductList,
  "product-card": ProductCard,
}

const allComponents = Object.values(codeMap).reduce((all, c) => {
  all[Vue.util.hyphenate(c.name)] = c
  return all
}, {})

Note that we have both col and column in codeMap — this means that both can be used as shortcodes. However, Vue will complain if you try to use col because this is already an HTML tag. Instead of using the code map directly, we'll map it to a new object where the key is based on a hyphenated (kebab) version of the component’s name.

(Note: if you are using Webpack or something similar, you’ll need to add a name: property to your components. Otherwise, the name will be an odd internal Webpack value.)

The Component

So let's start to flesh out the actual component:

export default {
    props: {
        content: {
            type: String,
            required: true
        },
        strict: {
            type: Boolean,
            default: true
        }
    },
    methods: {
        renderContent() {
            // Turn AST into template and handle syntax error
        }
    },
    created: {
        this.tokenizer = new Tokenizer()
    },
    render(h) {
        return h(Vue.component('code-wrapper', {
            template: this.renderContent(),
            components: allComponents
        }))
    }
}

The idea behind this approach is to programmatically create yet another component — <code-wrapper> — as a child of our <code-slot> component. In our template code of a parent component, we include the code slot:

<aside>
  <code-slot :content="sidebarContent"></code-slot>
</aside>

So say that we put a product list in the sidebar slot:

<h3>Featured</h3>
[product-list name="featured" num=3 use="product-card"/]

Then our Vue hierarchy will end up looking like this:

Vue hierarchy from the Chrome developer tool (CodeSlot is our controlling component that creates CodeWrapper which is a template that contains both HTML and our custom component ProductList)

Rendering

I will not cover the entire rendering process but you can look into it more in this gist which includes the component in its entirety.

The body of this function can be kept pretty simply. An important part is how we handle syntax errors to prevent our Vue application from running astray.

export default {
    ...
    methods: {
      renderContent() {
        try {
          let ast = this.tokenizer
            .input(this.content)
            .ast()
          let content = ast
            .map(renderToken)
            .join('')
          return ensureOneRoot(ast, content)
        } catch (err) {
          console.error(err)
          return `<div class="error">${err.message}</div>`
        }
      }
    }
    ...
}

Our content prop value (from our parent) is passed into the tokenizer, which returns an AST, an array of root tokens. We pass each token into render function (not shown here), which returns a part of our final template that we then join with all the other parts. Lastly, before returning, we ensure that the generated template only has one root — a requirement of Vue components.

In the rendering process, I’m simply passing the tokens through a series of transformer functions recursively. As an example, this is the render function for codes that has an open and a close part:

function renderOpen(token) {
  if (token.type === Tokenizer.OPEN) {
    let name = getComponentName(token)
    let params = renderParams(token)
    let children = token.children.map(renderToken).join("")
    token.output = `<${name}${params}>${children}</${name}>`
  }
  return token
}

You don’t have to mirror AST exactly. You can skip nodes, add new ones, or as in this example, the keep-alive parameter is converted into a wrapper of the component:

// example input: [product-list keep-alive/]
function wrapKeepAlive(token) {
  if (typeof token.params["keep-alive"] !== "undefined") {
    token.output = `<keep-alive>${token.output}</keep-alive>`
  }
  return token
}

Error Handling

You should naturally validate your codes in all the authored content before publishing, but we will handle it here nonetheless. Besides, this component can also be used in the CMS, and the shortcode-tokenizer can be used in the backend if you run Node.js.

For this demo, the handling is no more than outputting the error with a token and outputting it to the console.

Demo time

Our next step will be to figure out how well this works with theming. For now, let’s see what we have achieved.

Questions and feedback are most welcome. I’m still relatively new to Vue.js myself. If you’ve just started learning Vue.js, make sure to check out Getting Started with Vue.js: Why Use it?

demo-time.gif (Note how syntax errors are shown whenever the the codes are incomplete. ProductCard and ProductListItem are two different components that the ProductList component can use to render a product. Row and Column are simple wrappers around a CSS grid framework.)

References: