Build a Markdoc tag
This topic explains how to register custom components that can be invoked by your content creators in Markdoc (Markdown) files.
Follow the tutorial to create two sample components: a simple line break component and a quiz component for some extra credit.
The line break is a very simple component, that adds a linebreak (<br>
). In reality, you can achieve the linebreak in Markdoc by ending a line with a \
. However, it makes for the simple "Hello World" of custom tags. And it requires almost no React knowledge.
The quiz component, while a bit rough around the edges, is a lot more robust. To understand it, you'll need to understand React. However, if you do, and want to do more complex behavior, this is a good example to help you understand how to do that.
Create directories
If they don't exist yet, create the @theme
directory in your project root. Inside of that, create a markdoc
directory.
This is what your directory structure may look after you follow this tutorial.
@theme └── markdoc ├── components.tsx └── schema.ts
Create your components
Create a file components.tsx
inside of the markdoc
directory, and paste the following contents into it.
import * as React from 'react'; export function Break() { return <br />; }
We'll create the Quiz.tsx
as extra credit later.
Create your tags
Create a file schema.ts
inside of the markdoc
directory. Paste the following contents in the file.
import type { Schema } from '@markdoc/markdoc'; export const tags: Record<string, Schema> = { br: { render: 'Break', selfClosing: true, }, };
You've created your first Markdoc tag. The only thing left to do is use it.
In any file that ends with .md
add the following {% br /%}
.
Add the Quiz component and tag
Add Quiz
component to the components.tsx
file. You can either implement component inline or re-export it from another file.
Let's implement it in Quiz.tsx
and re-export it from components.tsx
.
import * as React from 'react'; export { Quiz } from './components/Quiz'; export function Break() { return <br />; }
This is the directory structure after you've added the Quiz
component.
@theme └── markdoc ├── components │ └── Quiz.tsx ├── components.tsx └── schema.ts
See @theme/markdoc/components/Quiz.tsx
import * as React from 'react'; import styled from 'styled-components'; const { useState, useEffect, Fragment } = React; function Question({ question, setAnswerStatus }) { const [selectedAnswerIndex, setSelectedAnswerIndex] = useState(null); useEffect(() => { if (selectedAnswerIndex != null) { setAnswerStatus(selectedAnswerIndex === question.correctAnswerIndex); } }, [selectedAnswerIndex]); useEffect(() => { setSelectedAnswerIndex(null); }, [question]); const getClasses = (index) => { let classes = []; if (selectedAnswerIndex != null) { if (selectedAnswerIndex === index) { classes.push('selected'); } if (index === question.correctAnswerIndex) { if (selectedAnswerIndex === index) { classes.push('correct'); } else { classes.push('incorrect'); } } } return classes.join(' '); }; return ( <QuestionEl> <QuestionText>{question.question}</QuestionText> <Answers> {question.answers.map((answer, index) => { return ( <AnswerElement key={index} className={getClasses(index)} onClick={() => selectedAnswerIndex == null && setSelectedAnswerIndex(index)} > {answer} </AnswerElement> ); })} </Answers> </QuestionEl> ); } function ProgressBar({ currentQuestionIndex, totalQuestionsCount }) { const progressPercentage = (currentQuestionIndex / totalQuestionsCount) * 100; return ( <ProgressBarEl> <ProgressBarText> {currentQuestionIndex} answered ({totalQuestionsCount - currentQuestionIndex} remaining) </ProgressBarText> <ProgressBarInner style={{ width: `${progressPercentage}%` }} /> </ProgressBarEl> ); } export function Quiz({ questions }) { const [questionIndex, setQuestionIndex] = useState(null); const [answerStatus, setAnswerStatus] = useState(null); const [correctAnswerCount, setCorrectAnswerCount] = useState(0); const [quizComplete, setQuizComplete] = useState(false); useEffect(() => { setAnswerStatus(null); }, [questionIndex]); useEffect(() => { if (answerStatus) { setCorrectAnswerCount((count) => count + 1); } }, [answerStatus]); const onNextClick = () => { if (questionIndex === questions.length - 1) { setQuizComplete(true); } else { setQuestionIndex(questionIndex == null ? 0 : questionIndex + 1); } }; const onRestartClick = () => { setQuizComplete(false); setQuestionIndex(null); setCorrectAnswerCount(0); }; if (questionIndex == null) { return ( <Wrapper> <h1>Apply what was learned</h1> <Button onClick={onNextClick}>Start</Button> </Wrapper> ); } return ( <QuizWrapper> {quizComplete ? ( <Fragment> <h1>Quiz complete!</h1> <p> You answered {correctAnswerCount} questions correctly (out of a total {questions.length}{' '} questions) </p> </Fragment> ) : ( <Fragment> <ProgressBar currentQuestionIndex={questionIndex} totalQuestionsCount={questions.length} /> <Question question={questions[questionIndex]} setAnswerStatus={setAnswerStatus} /> {answerStatus != null && ( <div> <AnswerStatusEl> {!!answerStatus ? 'Correct! :)' : 'Your answer was incorrect :('} </AnswerStatusEl> <Button className="next" onClick={onNextClick}> {questionIndex === questions.length - 1 ? 'See results of this quiz' : 'Next Question ->'} </Button> </div> )} </Fragment> )} {questionIndex != null && ( <Button className="restart" onClick={onRestartClick}> Restart quiz </Button> )} </QuizWrapper> ); } const Wrapper = styled.div` display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; width: 600px; margin: auto; `; const QuizWrapper = styled.div` display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; width: 600px; margin: auto; `; const Button = styled.button` background: #e8e8e8; border: 0; padding: 10px 20px; cursor: pointer; border-bottom: 3px solid #c9c9c9; border-radius: 3px; &.next { background: #6ad85c; border-bottom: 3px solid #5abc4e; } &.start { margin-top: 20px; } &.restart { margin-top: 20px; } `; const QuestionEl = styled.div` width: 100%; `; const QuestionText = styled.div` font-size: 1.2em; margin: 20px 0; `; const Answers = styled.div` margin-bottom: 20px; `; const AnswerElement = styled.div` padding: 4px; text-align: center; background: #f3f3f3; margin-bottom: 5px; border-radius: 3px; cursor: pointer; &.selected { background: gainsboro; } &.correct { background: #6ad85c; font-weight: bold; } &.incorrect { background: #df3636; font-weight: bold; } `; const AnswerStatusEl = styled.div` font-weight: bold; margin-bottom: 20px; `; const ProgressBarEl = styled.div` width: 100%; background: #f3f3f3; height: 20px; position: relative; display: flex; justify-content: center; align-items: center; border-radius: 3px; `; const ProgressBarInner = styled.div` background: #6ad85c; position: absolute; height: 100%; top: 0; left: 0; transition: ease all 0.5s; border-radius: 3px; `; const ProgressBarText = styled.div` font-size: 0.7em; position: absolute; z-index: 10; `;
Next, add the quiz tag schema to schema.ts
import type { Schema } from '@markdoc/markdoc'; export const tags: Record<string, Schema> = { br: { render: 'Break', selfClosing: true, }, quiz: { attributes: { questions: { type: 'Object', required: true, }, }, render: 'Quiz', // please make sure to export it in components.ts, selfClosing: true, }, };
You can use the quiz tag. However, the quiz tag expects a property to define the quiz questions, which is easier to do in the front matter of the Markdown file. The front matter is passed in as shown below.
--- questions: - question: Did you learn how to add a custom Markdoc tag? answers: - Yes - No - Maybe correctAnswerIndex: 1 - question: How many places do you need to adjust to configure custom Markdoc tags? answers: - '0' - '1' - '2' - '3' - '4' correctAnswerIndex: 4 --- # Hello Here is my quiz. {% quiz questions=$frontmatter.questions /%}
We'd show you a working quiz here, but we'd rather spend more time on it and make it look and function better first.
Extra credit
Make a custom tag of your own (it could be simple). Our philosophy is in line with Confucious:
I hear and I forget, I see and I remember, I do and I understand.
Go do!