Skip to content

πŸ”— Remark plugin to convert URLs to embed code in markdown.

License

Notifications You must be signed in to change notification settings

remark-embedder/core

Repository files navigation

@remark-embedder/core πŸ”—

Remark plugin to convert URLs to embed code in markdown.


Build Status Code Coverage version downloads MIT License All Contributors PRs Welcome Code of Conduct

The problem

I used to write blog posts on Medium. When I moved on to my own site, I started writing my blog posts in markdown and I missed the ability to just copy a URL (like for a tweet), paste it in the blog post, and have Medium auto-embed it for me.

This solution

This allows you to transform a link in your markdown into the embedded version of that link. It's a remark plugin (the de-facto standard markdown parser). You provide a "transformer" the plugin does the rest.

Table of Contents

Installation

This module is distributed via npm which is bundled with node and should be installed as one of your project's dependencies:

npm install @remark-embedder/core

Usage

Here's the most complete, simplest, practical example I can offer:

import remarkEmbedder from '@remark-embedder/core'
// or, if you're using CJS:
// const {default: remarkEmbedder} = require('@remark-embedder/core')
import remark from 'remark'
import html from 'remark-html'

const CodeSandboxTransformer = {
  name: 'CodeSandbox',
  // shouldTransform can also be async
  shouldTransform(url) {
    const {host, pathname} = new URL(url)

    return (
      ['codesandbox.io', 'www.codesandbox.io'].includes(host) &&
      pathname.includes('/s/')
    )
  },
  // getHTML can also be async
  getHTML(url) {
    const iframeUrl = url.replace('/s/', '/embed/')

    return `<iframe src="${iframeUrl}" style="width:100%; height:500px; border:0; border-radius: 4px; overflow:hidden;" allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking" sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"></iframe>`
  },
}

const exampleMarkdown = `
This is a CodeSandbox:

https://codesandbox.io/s/css-variables-vs-themeprovider-df90h
`

async function go() {
  const result = await remark()
    .use(remarkEmbedder, {
      transformers: [CodeSandboxTransformer],
    })
    .use(html)
    .process(exampleMarkdown)

  console.log(result.toString())
  // logs:
  // <p>This is a CodeSandbox:</p>
  // <iframe src="https://codesandbox.io/embed/css-variables-vs-themeprovider-df90h" style="width:100%; height:500px; border:0; border-radius: 4px; overflow:hidden;" allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking" sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"></iframe>
}

go()

Options

The transformers option is required (otherwise the plugin won't do anything), but there are a few optional options as well.

transformers: Array<Transformer | [Transformer, unknown]>

Take a look at @remark-embedder/transformer-oembed which should cover you for most things you'll want to convert to embeds.

The transformer objects are where you convert a link to it's HTML embed representation.

name: string

This is the name of your transformer. It's required because it's used in error messages. I suggest you use your module name if you're publishing this transformer as a package so people know where to open issues if there's a problem.

shouldTransform: (url: string) => boolean | Promise<boolean>

Only URLs on their own line will be transformed, but for your transformer to be called, you must first determine whether a given URL should be transformed by your transformer. The shouldTransform function accepts the URL string and returns a boolean. true if you want to transform, and false if not.

Typically this will involve checking whether the URL has all the requisite information for the transformation (it's the right host and has the right query params etc.).

You might also consider doing some basic checking, for example, if it looks a lot like the kind of URL that you would handle, but is missing important information and you're confident that's a mistake, you could log helpful information using console.log.

getHTML: (url: string, config?: unknown) => string | null | Promise<string | null>

The getHTML function accepts the url string and a config option (learn more from the services option). It returns a string of HTML or a promise that resolves to that HTML. This HTML will be used to replace the link.

It's important that the HTML you return only has a single root element.

// This is ok βœ…
return `<iframe src="..."></iframe>`

// This is not ok ❌
return `<blockquote>...</blockquote><a href="...">...</a>`

// But this would be ok βœ…
return `<div><blockquote>...</blockquote><a href="...">...</a></div>`

Some services have endpoints that you can use to get the embed HTML (like twitter for example).

handleHTML?: (html: GottenHTML, info: TransformerInfo) => GottenHTML | Promise<GottenHTML>

Add optional HTML around what is returned by the transformer. This is useful for surrounding the returned HTML with custom HTML and classes.

Here's a quick example of an HTML handler that would handle adding TailwindCSS aspect ratio classes to YouTube videos:

import remark from 'remark'
import remarkEmbedder, {TransformerInfo} from '@remark-embedder/core'
import oembedTransformer from '@remark-embedder/transformer-oembed'
import remarkHtml from 'remark-html'

const exampleMarkdown = `
Check out this video:

https://www.youtube.com/watch?v=dQw4w9WgXcQ
`

function handleHTML(html: string, info: TransformerInfo) {
  const {url, transformer} = info
  if (
    transformer.name === '@remark-embedder/transformer-oembed' ||
    url.includes('youtube.com')
  ) {
    return `<div class="embed-youtube aspect-w-16 aspect-h-9">${html}</div>`
  }
  return html
}

const result = await remark()
  .use(remarkEmbedder, {
    transformers: [oembedTransformer],
    handleHTML,
  })
  .use(remarkHtml)
  .process(exampleMarkdown)

// This should return:
// <p>Check out this video:</p>
// <div class="embedded-youtube aspect-w-16 aspect-h-9"><iframe width="200" height="113" src="https://www.youtube.com/embed/dQw4w9WgXcQ?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe></div>

handleError?: (errorInfo: ErrorInfo) => GottenHTML | Promise<GottenHTML>

type ErrorInfo = {
  error: Error
  url: string
  transformer: Transformer<unknown>
  config: TransformerConfig
}

If remark-embedder encounters an error with any transformer, then compilation will fail. I've found this to be problematic when using @remark-embedder/transformer-oembed for tweets and the tweet author deletes their tweet. It'll prevent you from building and that's annoying.

So handleError allows you to handle any error that's thrown by a transformer. This way you can gracefully fallback to something rather than crashing everything. Even if you provide a handleError, we'll log to the console so you can fix the problem in the future.

Here's a quick example of an error handler that would handle deleted tweets:

function handleError({error, url, transformer}) {
  if (
    transformer.name !== '@remark-embedder/transformer-oembed' ||
    !url.includes('twitter.com')
  ) {
    // we're only handling errors from this specific transformer and the twitter URL
    // so we'll rethrow errors from any other transformer/url
    throw error
  }
  return `<p>Unable to embed <a href="${url}">this tweet</a>.</p>`
}

const result = await remark()
  .use(remarkEmbedder, {
    transformers: [oembedTransformer],
    handleError,
  })
  .use(html)
  .process(exampleMarkdown)

You'll get an error logged, but it won't break your build. It also won't be cached (if you're using the cache option).

cache?: Map<string, string | null>

You'll mostly likely want to use @remark-embedder/cache

Because some of your transforms may make network requests to retrieve the HTML, we support providing a cache. You could pass new Map(), but that would only be useful during the life of your process (which means it probably wouldn't be all that helpful). You'll want to make sure to persist this to the file system (so it works across compilations), which is why you should probably use @remark-embedder/cache.

The cache key is set to remark-embedder:${transformerName}:${urlString} and the value is the resulting HTML.

Also, while technically we treat the cache as a Map, all we really care about is that the cache has a get and a set and we await both of those calls to support async caches (like @remark-embedder/cache or Gatsby's built-in plugin cache).

Configuration

You can provide configuration for your transformer by specifying the transformer as an array. This may not seem very relevant if you're creating your own custom transformer where you can simply edit the code directly, but if the transformer is published to npm then allowing users to configure your transformer externally can be quite useful (especially if your transformer requires an API token to request the embed information like with instagram).

Here's a simple example:

const CodeSandboxTransformer = {
  name: 'CodeSandbox',
  shouldTransform(url) {
    // ...
  },
  getHTML(url, config) {
    // ...
  },
}

const getCodeSandboxConfig = url => ({height: '600px'})

const result = await remark()
  .use(remarkEmbedder, {
    transformers: [
      someUnconfiguredTransformer, // remember, not all transforms need/accept configuration
      [codesandboxTransformer, getCodeSandboxConfig],
    ],
  })
  .use(html)
  .process(exampleMarkdown)

The config is typed as unknown so transformer authors have the liberty to set it as anything they like. The example above uses a function, but you could easily only offer an object. Personally, I think using the function gives the most flexibility for folks to configure the transform. In fact, I think a good pattern could be something like the following:

const CodeSandboxTransformer = {
  name: 'CodeSandbox',
  shouldTransform(url) {
    // ...
  },
  // default config function returns what it's given
  getHTML(url, config = html => html) {
    const html = '... embed html here ...'
    return config({url, html})
  },
}

const getCodeSandboxConfig = ({url, html}) =>
  hasSomeSpecialQueryParam(url) ? modifyHTMLBasedOnQueryParam(html) : html

const result = await remark()
  .use(remarkEmbedder, {
    transformers: [
      someUnconfiguredTransformer, // remember, not all transforms need/accept configuration
      [CodeSandboxTransformer, getCodeSandboxConfig],
    ],
  })
  .use(html)
  .process(exampleMarkdown)

This pattern inverts control for folks who like what your transform does, but want to modify it slightly. If written like above (return config(...)) it could even allow the config function to be async.

Making a transformer module

Here's what our simple example would look like as a transformer module:

import type {Transformer} from '@remark-embedder/core'

type Config = (url: string) => {height: string}
const getDefaultConfig = () => ({some: 'defaultConfig'})

const transformer: Transformer<Config> = {
  // this should be the name of your module:
  name: '@remark-embedder/transformer-codesandbox',
  shouldTransform(url) {
    // do your thing and return true/false
    return false
  },
  getHTML(url, getConfig = getDefaultConfig) {
    // get the config...
    const config = getConfig(url)
    // do your thing and return the HTML
    return '<iframe>...</iframe>'
  },
}

export default transformer
export type {Config}

If you're not using TypeScript, simply remove the type import and the : Transformer bit.

If you're using CommonJS, then you'd also swap export default transformer for module.exports = transformer

NOTE: If you're using export default then CommonJS consumers will need to add a .default to get your transformer with require.

To take advantage of the config type you export, the user of your transform would need to cast their config when running it through remark. For example:

// ...
import transformer from '@remark-embedder/transformer-codesandbox'
import type {Config as CodesandboxConfig} from '@remark-embedder/transformer-codesandbox'

// ...

remark().use(remarkEmbedder, {
  transformers: [codesandboxTransformer, config as CodesandboxConfig],
})
// ...

Inspiration

This whole plugin was extracted out of Kent C. Dodds' Gatsby website into gatsby-remark-embedder by MichaΓ«l De Boey and then Kent extracted the remark plugin into this core package.

Other Solutions

  • MDX Embed: Allows you to use components in MDX files for common services. A pretty different approach to solving a similar problem.

Issues

Looking to contribute? Look for the Good First Issue label.

πŸ› Bugs

Please file an issue for bugs, missing documentation, or unexpected behavior.

See Bugs

πŸ’‘ Feature Requests

Please file an issue to suggest new features. Vote on feature requests by adding a πŸ‘. This helps maintainers prioritize what to work on.

See Feature Requests

Contributors ✨

Thanks goes to these people (emoji key):

Kent C. Dodds
Kent C. Dodds

πŸ’» πŸ“– πŸš‡ ⚠️
MichaΓ«l De Boey
MichaΓ«l De Boey

πŸ› πŸ’» πŸ“– ⚠️ πŸ‘€
Matt Johnston
Matt Johnston

πŸ“–
Eduardo Reveles
Eduardo Reveles

πŸ’»
Titus
Titus

πŸ‘€
Brad Garropy
Brad Garropy

πŸ’» πŸ›
Mike Stecker
Mike Stecker

πŸ’» ⚠️ πŸ“–
mono
mono

πŸ’» ⚠️

This project follows the all-contributors specification. Contributions of any kind welcome!

LICENSE

MIT