Last updated

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!