Created
September 16, 2024 11:11
-
-
Save BretCameron/77fbe5c6292ecdc7a97fe7c0e86d565f to your computer and use it in GitHub Desktop.
A custom Remark plugin to create a super minimal MDX writing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/lib/frontmatter.plugin.mjs | |
import yaml from "js-yaml"; | |
import { valueToEstree } from "estree-util-value-to-estree"; | |
// Helper function to traverse nodes recursively | |
const traverse = (node, callback) => { | |
callback(node); | |
if ("children" in node && Array.isArray(node.children)) { | |
node.children.forEach((child) => traverse(child, callback)); | |
} | |
}; | |
function calculateReadingTimeInMinutes(content) { | |
const wordsPerMinute = 200; | |
const words = content.trim().split(/\s+/).length; | |
const minutes = words / wordsPerMinute; | |
return Math.ceil(minutes); | |
} | |
const isThematicBreak = (node) => node.type === "thematicBreak"; | |
const isMdxjsEsm = (node) => node.type === "mdxjsEsm"; | |
const isMdxJsxFlowElement = (node) => node.type === "mdxJsxFlowElement"; | |
// This plugin checks for the presence of YAML front matter and passes it to predefined JSX elements, as well as exporting the Next.js `metadata` object for the given page. | |
const frontMatterPlugin = ({ jsxElementNames = [] }) => { | |
return (tree, file) => { | |
const children = tree.children; | |
if (!isThematicBreak(children[0])) { | |
return; | |
} | |
// Find the second thematic break indicating the end of front matter | |
let secondThematicBreakIndex = children.findIndex( | |
(node, index) => index > 0 && isThematicBreak(node) | |
); | |
if (secondThematicBreakIndex === -1) { | |
// If no second thematic break is found, assume front matter ends at index 1 | |
secondThematicBreakIndex = 1; | |
} | |
// Remove front matter and thematic breaks from the tree | |
children.splice(0, secondThematicBreakIndex + 1); | |
const match = file.value.match(/---\n([\s\S]*?)---\n([\s\S]*)/); | |
const url = | |
"/" + file.history[0]?.split("/src/app/")[1]?.replace("/page.mdx", ""); | |
let frontMatterData = { | |
url, | |
title: url, | |
}; | |
if (!match) { | |
frontMatterData.readTime = `${calculateReadingTimeInMinutes( | |
file.value | |
)} min read`; | |
return; | |
} | |
const [, rawFrontMatter, restOfContent] = match; | |
try { | |
const yamlData = yaml.load(rawFrontMatter.trim()); | |
frontMatterData = { | |
...frontMatterData, | |
...yamlData, | |
}; | |
} catch (error) { | |
file.message(`Error parsing YAML front matter: ${error.message}`); | |
console.error( | |
`Error parsing YAML front matter: ${ | |
error.message | |
}. File history: ${file.history.join("\n")}` | |
); | |
return; | |
} | |
const readTime = `${calculateReadingTimeInMinutes(restOfContent)} min read`; | |
frontMatterData.readTime = readTime; | |
const estreeFrontMatter = valueToEstree(frontMatterData); | |
const programData = { | |
type: "Program", | |
body: [ | |
{ | |
type: "ExpressionStatement", | |
expression: estreeFrontMatter, | |
}, | |
], | |
sourceType: "module", | |
}; | |
traverse(tree, (node) => { | |
if ( | |
isMdxJsxFlowElement(node) && | |
(jsxElementNames.includes(node.name ?? "") || | |
jsxElementNames.includes("*")) | |
) { | |
node.attributes.push({ | |
type: "mdxJsxAttribute", | |
name: "frontMatter", | |
value: { | |
type: "mdxJsxAttributeValueExpression", | |
value: JSON.stringify(frontMatterData), | |
data: { estree: programData }, | |
}, | |
}); | |
} | |
}); | |
const indexOfLastImportStatement = children.findLastIndex( | |
(node) => | |
isMdxjsEsm(node) && (node.value ?? "").trim().startsWith("import") | |
); | |
const importDeclaration = { | |
type: "ImportDeclaration", | |
specifiers: [ | |
{ | |
type: "ImportSpecifier", | |
imported: { type: "Identifier", name: "generateMetadata" }, | |
local: { type: "Identifier", name: "generateMetadata" }, | |
}, | |
], | |
source: { type: "Literal", value: "../generateMetadata" }, | |
}; | |
const exportDeclaration = { | |
type: "Program", | |
body: [ | |
{ | |
type: "ExportNamedDeclaration", | |
declaration: { | |
type: "VariableDeclaration", | |
declarations: [ | |
{ | |
type: "VariableDeclarator", | |
id: { | |
type: "Identifier", | |
name: "metadata", | |
}, | |
init: { | |
optional: false, | |
type: "CallExpression", | |
callee: { | |
type: "Identifier", | |
name: "generateMetadata", | |
}, | |
arguments: [estreeFrontMatter], | |
}, | |
}, | |
], | |
kind: "const", | |
}, | |
specifiers: [], | |
source: null, | |
}, | |
], | |
sourceType: "module", | |
}; | |
const importNode = { | |
type: "mdxjsEsm", | |
value: `import { generateMetadata } from "../generateMetadata";`, | |
data: { | |
estree: { | |
type: "Program", | |
body: [importDeclaration], | |
sourceType: "module", | |
}, | |
}, | |
}; | |
const exportNode = { | |
type: "mdxjsEsm", | |
value: `export const metadata = generateMetadata(${JSON.stringify( | |
frontMatterData | |
)});`, | |
data: { estree: exportDeclaration }, | |
}; | |
children.splice(indexOfLastImportStatement + 1, 0, importNode, exportNode); | |
}; | |
}; | |
export default frontMatterPlugin; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment