How to cross-post to Medium

Categories:

I host my blog on my own site and while this will always remain my main platform, I decided to write a node.js script to cross-post my articles to medium. I’ll show you how to cross-post Markdown posts to Medium using the node medium-sdk. There are several good reasons to do so. In fact, there’s not a single disadvantage if you’re doing it correctly.

Why cross-post to Medium?

  1. Medium already has a huge audience, which means new people will discover your content that wouldn’t have found you otherwise. You also don’t need to be afraid about Google reducing the search engine ranking of your self-hosted blog due to duplicated content because we can set the post’s canonical link, the single source of truth, in Medium to point back to the original post on our own website. This way, there is no SEO disadvantage of having your posts on both platforms.
  2. The on-site Medium Editor sucks. The editor doesn’t even allow you to write in Markdown directly. Even though Medium supports Markdown (as we’ll see later), there’s no toggle to switch between WYSIWYG / Markdown / HTML views. Most developers I know prefer to write in Markdown instead of inserting links/images through a toolbar. Using the cross-posting approach, we can write Markdown in our familiar environment - for me, it’s VS Code + gatsbyjs for live reloading.
  3. Posting to Medium programmatically is easier from a workflow perspective. Previously, I tried Medium’s Import Story feature directly on the post on my site, which often broke the formatting when reading the HTML, or to import the Markdown through tools like markdowntomedium, which work well, but they pollute your GitHub account with (private) gists and set an incorrect canonical URL to the GitHub gist. Now, with gatsbyjs or any other static site generator, my workflow looks like this:

    • Write post in Markdown
    • Push to GitHub => Netlify git-hook runs and deploys my site
    • I run my cross-post script: npm run crosspost which publishes the new post to Medium

If this convinced you, here’s how to setup cross-posting to Medium.

Setup

All you need is nodejs version 8 or higher, and a post written in Markdown. The Markdown post should have a frontmatter containing the title, slug, and medium tags, . For instance, this post looks like this:

---
title: How to cross-post to Medium
slug: how-to-crosspost-to-medium
medium:
- programming
- javascript
- medium
---

I host my blog ...

Creating credentials for the scripts

You need to set up an app in your Medium account settings and create an integration token to grant your script the rights to publish posts on your behalf.

1. Create the app

In your Medium settings, click on Manage Applications, New application and fill in the App details as you please:

Name: Cross-Post script
Description: 
Callback URLs: http://example.com/callback/medium // We don't need this, but still it's mandatory

Click on save and write down the Client ID and Client Secret of the app you just created.

2. Create an integration token

In your Medium settings, go to Integration Tokens, fill out the description, click on Get Integration Token and write down the token.

Medium Integration Token

Cross-post Script

Here comes the fun part - we’re ready to create the node.js script. Let’s take a look at the outline of what our script needs to do:

  1. Read the Markdown file, parse it into a markdown abstract syntax tree (MAST)
  2. Get the title, slug, and medium tags from the frontmatter.
  3. Rewrite all relative image and link URLs to absolute URLs prefixing our website
  4. Inject custom footer backlinking to our original post
  5. Use Medium API to create the post with the modified markdown post’s content, correct canonical-link, and tags.

I’ll go through the main ideas here, the fully functional code can be seen on GitHub.

Parsing the markdown file

We will use remark with its many plugins to parse the Markdown file. remark works by creating a chain of plugins. This way, we start with the file’s contents, and the output of each plugin is then given as the input for the next plugin in the chain. We’ll use the parse plugin to parse the markdown file, then process the returned MAST to parse the yaml frontmatter node, and finally use stringify again to recreate markdown content out of the MAST.

var vfile = require('to-vfile')
const frontmatterPlugin = require('remark-frontmatter')
const Remark = require(`remark`)
const parse = require('remark-parse')
const stringify = require('remark-stringify')

const transformPostFromPath = async (filePath) => {
  try {
    return new Promise((resolve, reject) => {
      new Remark()
        .data(`settings`, {
          commonmark: true,
          footnotes: true,
          pedantic: true,
        })
        // creates the MAST from markdown
        .use(parse)
        // to create a markdown yaml node in the MAST
        .use(frontmatterPlugin)
        // converts the MAST back to markdown
        .use(stringify)
        // apply it to the file
        .process(vfile.readSync(filePath), function(err, vfile) {
          if (err) return reject(err)
          const returnValue = {
            // this will be the markdown content
            content: String(vfile),
          }
          return resolve(returnValue)
        })
    })
  } catch (ex) {
    console.log(ex)
  }
}

We follow this pattern of putting new custom remark plugins into the chain for the rest of the steps we need to do.

Getting the frontmatter

To read the frontmatter and convert it to a javascript object, we will write our first remark plugin. It traverses the MAST checking for yaml nodes and creates a javascript object out of it.

Remark plugins are just functions that return a transformer function themselves.

const yaml = require('js-yaml')
const Remark = require(`remark`)
const parse = require('remark-parse')
const frontmatterPlugin = require('remark-frontmatter')
const stringify = require('remark-stringify')
const visit = require('unist-util-visit')

async function getFrontmatter(filePath) {
  let frontmatter
  function frontmatterToJs() {
    return function transformer(tree) {
      visit(tree, `yaml`, node => {
        frontmatter = yaml.load(node.value)
      })
    }
  }
  return new Promise((resolve, reject) => {
    new Remark()
      .data(`settings`, {
        commonmark: true,
        footnotes: true,
        pedantic: true,
      })
      .use(parse)
      .use(frontmatterPlugin)
      .use(frontmatterToJs)
      .process(vfile.readSync(filePath), function(err) {
        if (err) return reject(err)
        if (!frontmatter)
          return reject(new Error('No frontmatter found in markdown-AST'))
        console.log(`Found frontmatter ...`)
        return resolve(frontmatter)
      })
  })
}

Then our transformPostFromPath function can use the frontmatter to extract the slug:

const url = require('url')

const transformPostFromPath = async (filePath, transformerPlugin) => {
  try {
    const frontmatter = await getFrontmatter(filePath)
    const siteUrl = `https://cmichel.io`
    const { slug } = frontmatter
    const postUrl = url.resolve(siteUrl, `/${slug}`)

    return new Promise((resolve, reject) => {
      new Remark()
        // ...
          const returnValue = {
            content: String(vfile),
            frontmatter,
            postUrl,
            siteUrl,
            slug,
          }
        // ...
    }
  }
}

Resolving relative URLs to absolute URLs

Next, we need to write a plugin that transforms all relative image and link URLs to absolute URLs. The reason is that an image hosted on our own blog, which is embedded like

![Alt text](./cats.png)

will not work in Medium. Therefore, we will use the slug to rewrite the MAST for images and links to have a structure like this:

![Alt text](https://cmichel.io/how-to-crosspost-to-medium/cats.png)

The plugin’s code is quite simple, we use the visitor pattern again to traverse the MAST and rewrite the url property of these nodes.

const url = require('url')
const visit = require('unist-util-visit')

function urlIsRelative(url) {
  // catches http(s)://example.io but also //example.io
  const isAbsolute = new RegExp('^([a-z]+://|//)', 'i')
  return !isAbsolute.test(url)
}

function joinUrls(siteUrl, slug, relativeUrl) {
  const siteUrlWithSlug = url.resolve(siteUrl, `/${slug}`)
  return url.resolve(siteUrlWithSlug, `${relativeUrl}`)
}

function absoluteUrls(options) {
  const { siteUrl, slug } = options
  return transformer

  function replaceUrl(node) {
    if (!node.url || !urlIsRelative(node.url)) return
    const absoluteUrl = joinUrls(siteUrl, slug, node.url)
    console.log(`\tRewriting link "${node.url}" to "${absoluteUrl}" ...`)
    node.url = absoluteUrl
  }

  function transformer(tree) {
    console.log(`Rewriting image links ...`)
    visit(tree, 'image', replaceUrl)
    console.log(`Rewriting anchor links ...`)
    visit(tree, 'link', replaceUrl)
  }
}
// use the absoluteUrl plugin
const transformPostFromPath = async (filePath, transformerPlugin) => {
  // ...
    new Remark()
      .data(`settings`, {
        commonmark: true,
        footnotes: true,
        pedantic: true,
      })
      .use(parse)
      .use(frontmatterPlugin)
      // pass the siteUrl, slug as options
      .use(absoluteUrls, {
        siteUrl,
        slug,
        postUrl,
        frontmatter,
      })
      .use(stringify)
  // ...
}

Injecting the custom footer

Let’s add a footer to the medium markdown post linking back to the originally published post. We will just write a plugin which appends a divider and a paragraph to the children of the MAST.

const url = require('url')

const createHorizontalRule = () => ({
  type: `thematicBreak`,
})

const createReferenceToOriginalPost = postUrl => ({
  type: `paragraph`,
  children: [
    {
      type: `text`,
      value: `Originally published at `,
    },
    {
      type: 'link',
      url: postUrl,
      children: [
        {
          type: 'text',
          value: 'cmichel.io',
        },
      ],
    },
  ],
})

const createClapImage = siteUrl => ({
  type: `image`,
  title: null,
  alt: 'Medium Clap',
  url: url.resolve(siteUrl, '/images/medium_clap.gif'),
})

function appendFooter(options) {
  const { siteUrl, postUrl } = options
  return transformer

  function transformer(tree) {
    tree.children = [
      ...tree.children,
      createHorizontalRule(),
      createReferenceToOriginalPost(postUrl),
      createClapImage(siteUrl),
    ]
  }
}

// use the plugin like this:
.use(appendFooter, {
  siteUrl,
  slug,
  postUrl,
  frontmatter,
})

Using medium-sdk to publish the transformed post

Now we just need to call the transformPostFromPath function which sequentially executes all of our remark plugins, and returns the new post as markdown along with the frontmatter.

const transformedPost = await transformPostFromPath(path)
const response = await client.createPost(transformedPost)

The medium client implementation looks like this:

require('dotenv').config()
const medium = require('medium-sdk')

const mediumClient = new medium.MediumClient({
  clientId: process.env.MEDIUM_CLIENT_ID,
  clientSecret: process.env.MEDIUM_CLIENT_SECRET,
})

mediumClient.setAccessToken(process.env.MEDIUM_ACCESS_TOKEN)

const client = {
  createPost({ content, frontmatter, postUrl }) {
    return new Promise((resolve, reject) => {
      mediumClient.getUser(function(err, user) {
        mediumClient.createPost(
          {
            userId: user.id,
            // markdown post
            content,
            title: frontmatter.title,
            canonicalUrl: postUrl,
            // tags for medium read out of frontmatter
            tags: frontmatter.medium,
            // format is Markdown
            contentFormat: medium.PostContentFormat.MARKDOWN,
            publishStatus: medium.PostPublishStatus.DRAFT,
          },
          function(err, post) {
            if (err) return reject(err)
            return resolve(post)
          }
        )
      })
    })
  },
}

module.exports = client

You need to set the correct credentials mentioned in the setup section in a .env file.

Again, the complete working code can be seen on GitHub. After calling this function you should have a new story in your Medium account as a draft. 🎉

Enhancements

Writing these auto-post-generator scripts is addicting. Here are some more things you could do:

  • Write a remark-plugin that replaces code nodes with a Codepen / GitHub gist to enable syntax highlighting in Medium.
  • Add a signature / an image to the footer promoting your products.
  • Cross-post to other platforms. The next post will be on cross-posting to steemit.