My favorite music and MDX

My favorite music and MDX

Published at 2021/12/03

#mdx#next.js#markdown#tailwind-css

Spotify just created a playlist with the most listened songs by me during this 2020, so, I took this opportunity to write a tutorial about MDX and Next.js.

MDX is a Markdown language enhancement, making it more powerful and customizable, because MDX allows rendering React components inside of Markdown files.

This tutorial was written using MDX, so, below, you can see an example of a React component rendered in this post.

I'm a React component that uses Tailwind CSS for styling

Setting up Next.js and MDX

I built this website using Next.js, I'm going to talk about how I configured MDX to create the blog section. To accomplish it, this article posted by Sergio Xalambrí was helpful for me.

First of all, I configured MDX with Next.js and in this link you can check out the instructions, it is really easy to complete it.

Then, I installed the next-mdx-remote package. This package allows rendering MDX files that can be hosted on a remote location, for example, a CMS, a CDN, a database or anywhere. There are other reasons for which it is useful to use this package, in this section of its GitHub repository you can dig deep in the problems that next-mdx-remote solves.

Now, I'm going to explain how to use next-mdx-remote for rendering a blog post in a Next.js page depending on a URL param.

First, I created the function where the property is an array of slugs of all my blog posts that I want to pre-render.

blog-posts.tsx

export const getStaticPaths: GetStaticPaths = async function getStaticPaths() {
	const posts = await readFile<BlogPosts>(path.join(process.cwd(), "src/data/blog/posts.json"), "json");

	return {
		paths: Object.values(posts).map((post) => {
			return { params: { slug: post.slug } };
		}),
		fallback: "blocking",
	};
};

I chose the static-site generation approach because I don't have a lot of pages, probably less than 20 and its content does not change often. I recommend reading more about this topic in this Next.js docs page, its explanation is very clear.

Now, inside of the function, the MDX content of all blog posts is readed. This function only executes at build time, I mean, on the server side. At the moment I store all my blog posts MDX files inside of this website repository. In the future, I could store them on a CMS, for example.

[blog-post].tsx

import path from "path";
import type { GetStaticPaths, GetStaticProps } from "next";

import { readFile } from "@diegofrayo-pkg/utilities/files";

import { compile } from "~/features/mdx/server";
import type { BlogPosts, BlogPostWithContent } from "~/features/pages/blog";
import type { BlogPostPageProps } from "~/features/pages/blog/pages/[slug]";

export { default } from "~/features/pages/blog/pages/[slug]";

// --- NEXT.JS FUNCTIONS ---

export const getStaticProps: GetStaticProps<BlogPostPageProps, { slug: string }> = async ({
	params,
}) => {
	const slug = params?.slug || "";
	if (!slug) throw new Error(`Invalid slug: "${slug}"`);

	const post = await readFile<BlogPostWithContent>(
		path.join(process.cwd(), `src/data/blog/posts/${slug}.json`),
		"json",
	);
	const mdxCompiled = await compile({ content: post.content });

	return {
		props: {
			data: {
				details: post.details,
				content: mdxCompiled.code,
			},
		},
	};
};

Problem: I can't import components or variables directly inside of MDX files when I use next-mdx-remote to compile them. In this link you can read more about this.

Solution: I had to pass a config object when I invoked the function. The property is an object that contains the data (variables) that I need to use inside of my MDX files.

Finally, this is the component that renders a blog post:

blog-post-page.tsx

"use client";

import dynamic from "next/dynamic";

import { withRenderInBrowser } from "@diegofrayo-pkg/hocs";
import type UtilsTypes from "@diegofrayo-pkg/types";
import type ReactTypes from "@diegofrayo-pkg/types/react";
import { toQueryString } from "@diegofrayo-pkg/utilities/navigation";
import { generateSlug } from "@diegofrayo-pkg/utilities/strings";
import { isEmptyArray } from "@diegofrayo-pkg/validator";

import {
	BoxWithTitle,
	Callout,
	CopyToClipboardPopover,
	ImageWithLink,
	Toast,
	Tooltip,
} from "~/components/common";
import type { CopyToClipboardPopoverProps } from "~/components/common/copy-to-clipboard-popover";
import { MainLayout } from "~/components/layout";
import {
	Box,
	Button,
	Icon,
	Image,
	InlineText,
	Link,
	List,
	Paragraph,
	Separator,
} from "~/components/primitive";
import { IconCatalog } from "~/components/primitive/icon";
import { LinkVariants } from "~/components/primitive/link";
import { ListVariants } from "~/components/primitive/list";
import { BLOG_IMAGES_PATH } from "~/constants";
import WEBSITE_METADATA from "~/data/metadata.json";
import AnalyticsService from "~/features/analytics";
import { getMDXExport, MDXContent } from "~/features/mdx/client";

import { BlogPostCategory } from "../../components/blog-post-category";
import type { BlogPost, BlogPostWithContent } from "../../types";

export type BlogPostPageProps = {
	data: BlogPostWithContent;
};

function BlogPostPage({ data }: BlogPostPageProps): ReactTypes.JSXElement {
	const { details: blogPostDetails, content: blogPostContent } = data;

	return (
		<MainLayout title={blogPostDetails.title}>
			<Box className="border-t border-zinc-100 bg-slate-50 shadow-sm shadow-zinc-300">
				<Image
					src={`${BLOG_IMAGES_PATH}/thumbnails/${blogPostDetails.slug}.png`}
					alt={blogPostDetails.title}
					className="mx-auto h-auto w-full max-w-full"
					width={600}
					height={338}
					loading="eager"
				/>
			</Box>
			<Separator size={1.5} />
			<BlogPostDetails details={blogPostDetails} />
			<Separator size={1.5} />
			<MDXContent
				code={blogPostContent}
				components={getBlogPostDynamicComponents(getMDXExport(blogPostContent))}
				globals={{
					DATA: { post: blogPostDetails },
				}}
			/>
			<Separator size={4} />
			<BlogPostSources sources={blogPostDetails.sources} />
			<Separator size={2} />
			<BlogPostActions blogPostTitle={blogPostDetails.title} />
		</MainLayout>
	);
}

export default BlogPostPage;

// --- UTILS ---

function getBlogPostDynamicComponents(
	componentsMap: UtilsTypes.Object<string>,
): UtilsTypes.Object<unknown> {
	const DYNAMIC_COMPONENTS = [
		"Playground",
		"MFMAMGitHubRepo",
		"MFMAMHelloWorldMDX",
		"MFMAMSpotifyPlaylist",
		"SPVEEQPlaces",
		"SPVEEQRecommendations",
	];

	const components = {
		Callout,
		CopyToClipboardPopover,
		ImageWithLink,
		Toast,
		Tooltip,
	} as UtilsTypes.Object;

	Object.keys(componentsMap["Components"] || {}).forEach((componentName) => {
		if (DYNAMIC_COMPONENTS.includes(componentName)) {
			switch (componentName) {
				case "Playground": {
					components[componentName] = dynamic(
						() => import("../../../../../components/common/playground"),
						{ ssr: true },
					);
					break;
				}

				case "MFMAMGitHubRepo": {
					components[componentName] = dynamic(
						() => import("./components/my-favorite-music-and-mdx/mfmam-github-repo"),
						{ ssr: true },
					);
					break;
				}

				case "MFMAMHelloWorldMDX": {
					components[componentName] = dynamic(
						() => import("./components/my-favorite-music-and-mdx/mfmam-hello-world-mdx"),
						{ ssr: true },
					);
					break;
				}

				case "MFMAMSpotifyPlaylist": {
					components[componentName] = dynamic(
						() => import("./components/my-favorite-music-and-mdx/mfmam-spotify-playlist"),
						{ ssr: true },
					);
					break;
				}

				case "SPVEEQPlaces": {
					components[componentName] = dynamic(
						() => import("./components/sitios-para-visitar-en-el-quindio/spveeq-places"),
						{ ssr: true },
					);
					break;
				}

				case "SPVEEQRecommendations": {
					components[componentName] = dynamic(
						() => import("./components/sitios-para-visitar-en-el-quindio/spveeq-recommendations"),
						{ ssr: true },
					);
					break;
				}

				default:
					break;
			}
		}
	});

	return components;
}

// --- COMPONENTS ---

function BlogPostDetails({
	details,
}: {
	details: BlogPostPageProps["data"]["details"];
}): ReactTypes.JSXElement {
	return (
		<Box className="border border-zinc-200 bg-zinc-50 py-4 text-center text-sm">
			<Paragraph>
				<Icon
					name={IconCatalog.CALENDAR}
					className="relative -top-px"
				/>{" "}
				<InlineText>Published at</InlineText> <InlineText as="strong">{details.published_at}</InlineText>
			</Paragraph>
			<Separator size={0.5} />
			<Box className="flex flex-wrap items-center justify-center gap-x-2">
				{details.categories.map((category) => {
					return (
						<BlogPostCategory
							key={category}
							text={category}
						/>
					);
				})}
			</Box>
		</Box>
	);
}

function BlogPostSources({ sources }: Pick<BlogPost, "sources">): ReactTypes.JSXElementNullable {
	if (isEmptyArray(sources)) return null;

	return (
		<BoxWithTitle
			title="Sources"
			className="border-zinc-300 p-4 pb-2"
		>
			<List variant={ListVariants.SIMPLE}>
				{sources.map((source) => {
					const { host } = new URL(source.url);

					return (
						<List.Item key={generateSlug(source.title)}>
							<Link
								variant={LinkVariants.STYLED}
								href={source.url}
								isExternalLink
							>
								{source.title}
							</Link>
							<Paragraph className="text-xs italic">{host}</Paragraph>
						</List.Item>
					);
				})}
			</List>
		</BoxWithTitle>
	);
}

const BlogPostActions = withRenderInBrowser(function BlogPostActions({
	blogPostTitle,
}: {
	blogPostTitle: string;
}) {
	const ACTIONS = [
		{
			type: "LINK",
			icon: IconCatalog.MAILS,
			label: "Send a comment via e-mail",
			popoverConfig: undefined,
			props: {
				href: (function composeMailToURL(): string {
					const paramsValues = {
						subject: "Blog post comment",
						body: `Hi, I have a comment about this blog post: ${window.location.href}`,
					};

					const queryParams = toQueryString(paramsValues);
					const result = `mailto:${WEBSITE_METADATA.email}?${queryParams}`;

					return result;
				})(),
				isExternalLink: true,
				onClick: AnalyticsService.trackClickEvent("BLOG|SEND_EMAIL", { post: blogPostTitle }),
			},
		},
		{
			type: "BUTTON",
			icon: IconCatalog.LINK,
			label: "Copy URL",
			popoverConfig: { textToCopy: window.location.href },
			props: {
				onClick: AnalyticsService.trackClickEvent("BLOG|COPY_URL", { post: blogPostTitle }),
			},
		},
	] as const;

	return (
		<Box className="flex flex-col flex-wrap items-start justify-between gap-2 border border-x-8 border-black p-4 text-black sm:flex-row">
			{ACTIONS.map((action) => {
				return (
					<BlogPostActionsItemWrapper
						key={action.label}
						popoverConfig={action.popoverConfig}
					>
						<Button
							className="inline-flex items-center justify-start text-left text-sm"
							render={action.type === "LINK" ? <Link {...action.props} /> : undefined}
							onClick={action.type === "BUTTON" ? action.props.onClick : undefined}
						>
							<Icon
								className="mr-1"
								name={action.icon}
							/>
							<InlineText>{action.label}</InlineText>
						</Button>
					</BlogPostActionsItemWrapper>
				);
			})}
		</Box>
	);
});

type BlogPostActionsItemWrapperProps = {
	children: ReactTypes.Children;
	popoverConfig: Omit<CopyToClipboardPopoverProps, "children"> | undefined;
};

const BlogPostActionsItemWrapper = ({
	children,
	popoverConfig,
}: BlogPostActionsItemWrapperProps): ReactTypes.Node => {
	if (popoverConfig) {
		return (
			<CopyToClipboardPopover
				popoverInnerWrapperClassName="leading-none"
				{...popoverConfig}
			>
				{children}
			</CopyToClipboardPopover>
		);
	}

	return children;
};

In regards to the MDX components styling, at the beginning I used this Tailwind CSS plugin, but then, I decided don't use it anymore and I built a component with my own styles.

mdx-config.tsx

import { getMDXComponent, getMDXExport as getMDXExportBase } from "mdx-bundler/client";
import { useMemo } from "react";

import cn from "@diegofrayo-pkg/cn";
import type UtilsTypes from "@diegofrayo-pkg/types";
import type ReactTypes from "@diegofrayo-pkg/types/react";
import { isString } from "@diegofrayo-pkg/validator";

import { BoxWithTitle, CopyToClipboardPopover, ImageWithLink, SourceCode } from "~/components/common";
import {
	Blockquote,
	Box,
	Button,
	Code,
	Collapsible,
	Icon,
	InlineText,
	Link,
	List,
	Paragraph,
	Separator,
	Title,
} from "~/components/primitive";
import { ButtonVariants } from "~/components/primitive/button";
import { CodeVariants } from "~/components/primitive/code";
import { type ImageProps } from "~/components/primitive/image";
import { LinkVariants } from "~/components/primitive/link";
import { ListVariants } from "~/components/primitive/list";
import { SeparatorVariants } from "~/components/primitive/separator";
import { TitleVariants, type TitleProps } from "~/components/primitive/title";

import styles from "./styles.module.css";

type MDXContentProps = {
	code: string;
	components?: UtilsTypes.Object;
	globals?: UtilsTypes.Object;
};

export function MDXContent({
	code,
	components = {},
	globals = {},
}: MDXContentProps): ReactTypes.JSXElement {
	const Component = useMemo(() => getMDXComponent(code, globals), [code, globals]);

	return (
		<Box className={cn("dr-mdx-content", styles["dr-mdx-content"])}>
			{/* eslint-disable-next-line react-hooks/static-components */}
			<Component components={{ ...MDXComponents, ...components }} />
		</Box>
	);
}

// NOTE: mdx-bundler ships broken types for this function (`ExportedObject` is undeclared), so the return type is set here
export function getMDXExport(code: string): UtilsTypes.Object<string> {
	return getMDXExportBase(code) as UtilsTypes.Object<string>;
}

// --- CONSTANTS ---

// DOCS: https://mdxjs.com/table-of-components
const MDXComponents = {
	// --- PRIMITIVE COMPONENTS ---
	a: function CustomA({
		children,
		href,
	}: ReactTypes.DOM.HTMLElementAttributes["a"]): ReactTypes.JSXElement {
		return (
			<Link
				href={href || ""}
				variant={LinkVariants.STYLED}
				isExternalLink
			>
				{children}
			</Link>
		);
	},
	blockquote: Blockquote,
	code: function CustomCode({
		children,
	}: ReactTypes.DOM.HTMLElementAttributes["code"]): ReactTypes.JSXElement {
		if (isString(children)) {
			return (
				<CopyToClipboardPopover textToCopy={children}>
					<Button variant={ButtonVariants.SMOOTH}>
						<Code variant={CodeVariants.STYLED}>{children}</Code>
					</Button>
				</CopyToClipboardPopover>
			);
		}

		throw new Error("Code component: Invalid children");
	},
	h1: createTitleComponent("h1", { className: "text-3xl md:text-5xl" }),
	h2: createTitleComponent("h2", { className: "text-2xl md:text-4xl" }),
	h3: createTitleComponent("h3", { className: "text-xl md:text-3xl" }),
	h4: createTitleComponent("h4", { className: "text-lg md:text-2xl" }),
	hr: function CustomHr(): ReactTypes.JSXElement {
		return <Separator variant={SeparatorVariants.DASHED} />;
	},
	li: List.Item,
	p: Paragraph,
	pre: function CustomPre({
		children,
	}: ReactTypes.DOM.HTMLElementAttributes["pre"]): ReactTypes.JSXElement {
		return <SourceCode code={children} />;
	},
	ul: function CustomUl({
		children,
	}: ReactTypes.DOM.HTMLElementAttributes["ul"]): ReactTypes.JSXElement {
		return <List variant={ListVariants.SIMPLE}>{children}</List>;
	},

	// --- CUSTOM COMPONENTS ---
	Box,
	BoxWithTitle,
	Collapsible,
	Icon,
	InlineText,
	Link,
	MDXImage: (props: ImageProps): ReactTypes.JSXElement => {
		return (
			<ImageWithLink
				className="shadow-md shadow-zinc-900"
				{...props}
			/>
		);
	},
	SourceCode,
	Separator,
};

// --- UTILS ---

function createTitleComponent(Tag: "h1" | "h2" | "h3" | "h4", props: Omit<TitleProps, "as">) {
	return function CustomTitle({
		children,
	}: ReactTypes.DOM.HTMLElementAttributes["h1"]): ReactTypes.JSXElement {
		return (
			<Title
				{...props}
				as={Tag}
				variant={TitleVariants.STYLED}
			>
				{children}
			</Title>
		);
	};
}

mdx-content.css

@reference "tailwindcss";

/* Spacing: parent components */
.dr-mdx-content > :is(blockquote, hr, ol, p, ul, a > img, *[data-markdown-block]) {
	@apply mb-6;
}

.dr-mdx-content > *:last-child {
	@apply mb-0;
}

/* Spacing: nested components */
.dr-mdx-content
	:is(li > p, li > :global(.dr-source-code), li > blockquote, li > img, blockquote > p, li > a > img) {
	@apply mb-3;
}

/* Spacing: titles */
.dr-mdx-content > :is(h1, h2, h3, h4) {
	@apply mt-8;
	@apply mb-3;
}

/* Spacing: nested UL lists */
.dr-mdx-content :is(li > ul, li > :global(.dr-source-code)) {
	@apply mt-3;
}

/* Ordered lists */
.dr-mdx-content ol {
	@apply pl-9;
	list-style-type: decimal;
}

.dr-mdx-content ol > li {
	@apply mb-6;
}

/* Images */
.dr-mdx-content :is(> a > img, li > a > img) {
	@apply rounded-md;
	margin-left: auto;
	margin-right: auto;
}

directive allows me to use Tailwind CSS classes within my files.

I used sugar-high for code syntax highlighting:

source-code.tsx

import { isValidElement, useMemo } from "react";
import { highlight } from "sugar-high";

import cn from "@diegofrayo-pkg/cn";
import type ReactTypes from "@diegofrayo-pkg/types/react";
import { throwError } from "@diegofrayo-pkg/utilities/errors";
import { isNonEmptyString, isString } from "@diegofrayo-pkg/validator";

import { Box, Button, Icon, InlineText, Link, Paragraph, Pre } from "~/components/primitive";
import { ButtonVariants } from "~/components/primitive/button";
import { IconCatalog } from "~/components/primitive/icon";
import { LinkVariants } from "~/components/primitive/link";
import { PreVariants } from "~/components/primitive/pre";

import CopyToClipboardPopover from "./copy-to-clipboard-popover";

export type SourceCodeProps = {
	code: ReactTypes.Children;

	className?: string;
	language?: string;
	sourceURL?: string;
	title?: string;
};

function SourceCode({
	code,
	className,
	language: languageProp = "code",
	sourceURL = "",
	title = "",
}: SourceCodeProps): ReactTypes.JSXElement {
	// --- COMPUTED STATES ---
	const codeText = extractChildren(code);
	const language = extractLanguage(code) ?? languageProp;
	const highlightedCode = useMemo(() => highlight(codeText), [codeText]);
	const hasSourceURL = isNonEmptyString(sourceURL);

	// --- STYLES ---
	const classes = {
		container: cn("dr-source-code", "flex max-h-125 flex-col border bg-zinc-900", className),
		header: cn("flex shrink-0 justify-between border-b border-zinc-700 bg-zinc-900"),
		title: cn("flex gap-2 bg-zinc-700 p-2 pr-8 text-sm text-zinc-100"),
		actions: cn("flex items-center gap-2 px-2 text-white"),
		sourceLink: cn("leading-0"),
		code: cn("hide-scrollbar max-h-full min-h-0 flex-1 overflow-auto p-3 text-sm"),
	};

	return (
		<Box
			as="section"
			className={classes.container}
			data-markdown-block
		>
			<Box
				as="header"
				className={classes.header}
			>
				<Paragraph className={classes.title}>
					<Icon name={IconCatalog.CODE_XML} />
					<InlineText>{`${title}.${language}`}</InlineText>
				</Paragraph>

				<Box className={classes.actions}>
					{hasSourceURL && (
						<Link
							className={classes.sourceLink}
							variant={LinkVariants.SMOOTH}
							href={sourceURL}
							isExternalLink
						>
							<Icon name={IconCatalog.EXTERNAL_LINK} />
						</Link>
					)}
					<CopyToClipboardPopover textToCopy={codeText}>
						<Button variant={ButtonVariants.SMOOTH}>
							<Icon name={IconCatalog.COPY} />
						</Button>
					</CopyToClipboardPopover>
				</Box>
			</Box>

			<Pre
				variant={PreVariants.UNSTYLED}
				className={classes.code}
				dangerouslySetInnerHTML={{ __html: highlightedCode }}
			/>
		</Box>
	);
}

export default SourceCode;

// --- UTILS ---

function extractChildren(code: SourceCodeProps["code"]): string {
	const result = isValidElement<CodeChildElementProps>(code) ? code.props.children : code;

	return isString(result) ? result : throwError("Invalid text to copy or highlight");
}

function extractLanguage(code: SourceCodeProps["code"]): string | undefined {
	if (!isValidElement<CodeChildElementProps>(code)) return undefined;

	return code.props.className.replace("language-", "");
}

// --- TYPES ---

type CodeChildElementProps = {
	children: ReactTypes.Children;
	className: string;
};

Final words

I share the URL of this repository in case of you want to see more details about my website and my blog section were implemented. Allright, this is all, thanks for reading.

I share the Spotify playlist for which I wrote down this blog post: