Building a markdown input with a preview tab (like GitHub and Stack Overflow) with Remix
Markdown is meant to be a simple way to write rich text in plain text. Most of its formatting is common sense: *
for italics, **
for bold, #
for headers, and so on. It's a great way to write content that's easy to read and write.
But then you try to add links or images, and the sensibility starts to break down. Square brackets for the text, parentheses for the URL, and a !
in front of the brackets for images.
Certainly, being able to preview your markdown to make sure you wrote it correctly is a good idea. GitHub offers this. StackOverflow does as well. Why not join them?
Rendering markdown into HTML
There are a lot of markdown parsers out there, and they are all more or less interchangeable. The one I like is femark on account of it being insanely fast.
Despite its speed, we still don't want to render the same markdown more than once. This is a good use-case for a server-side cache. If we try to render the same markdown twice, we can just return the cached result.
Create a new file markdown.server.ts
and add the following code.
import { processMarkdownToHtml as processMarkdownToHtmlImpl } from "@benwis/femark"import Lrucache from "lru-cache"const cache = new Lrucache({ ttl: 1000 * 60, maxSize: 1024, sizeCalculation: (value) => Buffer.byteLength(JSON.stringify(value)),})function cachify<TArgs, TReturn>( fn: (args: TArgs) => TReturn,) { return function (args: TArgs): TReturn { if (cache.has(args)) { return cache.get(args) as TReturn } const result = fn(args) cache.set(args, result) return result }}export const processMarkdownToHtml = cachify( processMarkdownToHtmlImpl,)
This will power the endpoint that we'll use to render markdown into HTML. Create a new action in a route of your choice and add the following code.
import { processMarkdownToHtml } from "./markdown.server.ts"export async function action({ params, request,}: ActionFunctionArgs) { const formData = await request.formData() const description = formData.get("description") || "" const html = processMarkdownToHtml( description.toString().trim(), ) // Optionally, store this in a database // const id = params.id as string // db[id].description = description.toString() // db[id].preview = html.content return new Response(html.content, { status: 200, headers: { "Content-Type": "text/html", }, })}
Sending markdown to this endpoint should successfully return the HTML.
Thinking in progressive enhancement
Progressive enhancement means our page should be interactive before javascript loads on the client, to make it compatible with devices that have javascript disabled or when javascript fails to load for one of many reasons.
If we were building this without javascript, what would that look like?
- The textarea would need to be a form and have a submit button
- The tabs would need to be links that set a query parameter to
?tab=edit
or?tab=preview
- The preview would need to be rendered server-side
Those requirements set the general approach, and then we can think about what javascript would add to the experience.
- When the user finishes typing, send the markdown to the server to render the preview automatically
- Update the preview tab with the up-to-date content
- We can change tabs client-side without a page reload
Writing an auto-submitting form
The best way to make a form submit automatically is with Remix's useFetcher()
hook.
Use fetcher.Form
and add a callback to make it submit on change.
Don't forget to include a regular submit button so that the form can be submitted without javascript.
export default function Example() { const fetcher = useFetcher() return ( <div> <fetcher.Form method="POST" onChange={(e) => { fetcher.submit(e.currentTarget, { replace: true, }) }} > <label htmlFor="description">Description</label> <textarea id="description" name="description" rows={8} defaultValue={description || ""} /> <div> <button type="submit">Save</button> </div> </fetcher.Form> </div> )}
Displaying the HTML preview
We can use the fetcher.data
property to get the response from the server.
Some markdown solutions will just return JSON you can pass directly into a React component, but femark
returns HTML. We can use dangerouslySetInnerHTML
to render it.
export default function Example() { const fetcher = useFetcher() return ( <div> {/* Previous code for the fetcher.Form */} <div dangerouslySetInnerHTML={{ __html: fetcher.data, }} /> </div> )}
Tabbing between edit and preview mode
The tabs will be simple links to the same page with the query parameter changed.
export default function Example() { const fetcher = useFetcher() const [searchParams] = useSearchParams({ tab: "edit" }) return ( <div> <div> <Link to="?tab=edit">Edit</Link> <Link to="?tab=preview"> Preview </Link> </div> {searchParams.get("tab") === "edit" ? ( <fetcher.Form /> ) : ( <div dangerouslySetInnerHTML={{ __html: fetcher.data, }} /> )} </div> )}
Depending on how you've built out your project, this will probably be quite slow. On every navigation, Remix is running the matching loaders to revalidate the data.
Since we know there's nothing happening on these tab changes that requires revalidating every upstream loader, we can tell Remix not to run the loaders again.
Create a shouldRevalidate
function in your route, and return false if the only changes have been the query parameters.
export const shouldRevalidate: ShouldRevalidateFunction = ({ currentUrl, nextUrl,}) => { if (currentUrl.pathname === nextUrl.pathname) { return false } return true}