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.
π‘ 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.
Contributors β¨
Thanks goes to these people (emoji key):
Kent C. Dodds π» π π β οΈ | MichaΓ«l De Boey π π» π β οΈ π | Matt Johnston π | Eduardo Reveles π» | Titus π | Brad Garropy π» π | Mike Stecker π» β οΈ π |
mono π» β οΈ |
This project follows the all-contributors specification. Contributions of any kind welcome!
LICENSE
MIT