Next.js 블로그 만들기 (5) - 마크다운 에디터

shorecrab·2022년 6월 23일
3

Velog처럼 예쁘게 마크다운 에디터를 도입할 수는 없어도 최소한 온라인 마크다운 에디터는 있어야겠다고 생각을 해왔지만, 이렇게 빨리 도입하게 될 줄은 몰랐다. 그렇지만 사실 이전에 HTML 에디터를 사용해본 적이 있어서 귀찮았을 뿐 어렵지 않게 구현할 수 있었다. 이번에는 유명한 오픈소스 마크다운 에디터인 Toast-ui를 사용하여 글을 쓰고 수정할 수 있도록 개발했다.

Toast-UI 도입

우선 Toast UI 깃허브에서 Toast UI와 React 래퍼 패키지를 설치했다. codeSyntaxHighlight라는 별도의 플러그인이 있어서 Prism이나 highlight.js를 통해 하이라이팅을 할 수 있었다. 이전에 React-markdown을 사용할 때도 비슷한 방식으로 진행했었는데 아무래도 내부적으로 동일하게 Prism과 같은 패키지를 사용하기 때문에 그랬던 것 같다.

아래와 같이 에디터 API에 맞춰서 prop을 넣어주었다.

// blog
// src/stories/components/editor/index.tsx
<Editor
  ref={ref}
  initialEditType="markdown"
  previewStyle="vertical"
  height="80vh"
  plugins={[[codeSyntaxHighlight, { highlighter: Prism }]]}
  onKeydown={(type, event) => handleSave(type, event)}
  hooks={{
    async addImageBlobHook(blob, callback) {
      try {
        const formData = new FormData();
        formData.append('image', blob);

        const result = await caxios.post('/images', formData, {
          headers: {
            'Content-Type': 'multipart/form-data',
          },
        });
        callback(result.data);
      } catch (err) {
        console.log(err);
      }
    },
  }}
/>

여기에서 addImageBlobHook이 조금은 생소할 수 있는데, 이미지를 업로드할 때 이 함수가 실행되면서 해당 이미지에 관한 처리를 한 후 콜백함수에 URL을 넘겨주도록 호출하면 해당 URL을 통해서 이미지를 가져올 수 있도록 해준다. 여기서는 back-end로 multipart/form-data형식의 요청을 보내서 파일을 업로드하고 해당 파일을 접근할 수 있는 URL을 받아 오도록 했다.

마크다운 업로드 요청을 보내는 부분은 크게 변경된 것이 없어서 넘어가도록 하겠다. (코드는 항상 깃헙에 있다!)

동적 임포트

여기까지는 큰 문제가 없는데, 이제 Next.js를 사용하면 꼭 나오는 이슈가 등장한다. window객체가 없어서 에디터를 서버 사이드에서 사용할 수 없다는 것이다. 그래서 항상 next/dynamic으로 동적 임포트를 해줘야만 한다. 주로 에디터만 동적 임포트하는 경우가 많지만, 그렇게 하면 next/dynamic에서 useImperativeHandle을 사용하는 관계로 forwardRef를 통해서 ref 객체를 넘겨줘야하는 불편함이 있으므로, 여기서는 에디터 관련 로직을 모두 처리한 컴포넌트를 동적 임포트 하도록 했다.

// blog
// src/pages/posts/new.tsx
import { GetStaticPropsContext } from 'next';
import dynamic from 'next/dynamic';
import { Layout } from 'src/stories/components/layout';

const MarkdownEditor = dynamic<any>(
  () => import('src/stories/components/editor').then(mod => mod.MarkdownEditor),
  { ssr: false }
);

const PostNew = () => {
  return (
    <Layout>
      <MarkdownEditor />
    </Layout>
  );
};

export default PostNew;

export function getStaticProps(context: GetStaticPropsContext) {
  return { props: {} };
}

마크다운 에디터를 렌더링할 수 있도록 페이지를 만들었다. 여기서 next/dynamic을 통해서 우리가 만든 에디터 컴포넌트를 동적 임포트하는 모습을 볼 수 있다. 중요한 것은 {ssr: false} 옵션을 같이 주어야 한다는 것이다.

API 변경

이전에는 파일을 업로드 하는 방식이었다면, 이제는 마크다운 문자열을 보내도록 하는 방식으로 변경되었으므로 이에 맞춰 API 변경이 필요했다. 그리고 이것을 변경하는 동시에 title과 summary를 직접 입력하는 방식이 아니라 마크다운 문자열에서 추출해 낼 수 있도록 변경했다.

// blog_server
// server/routes/posts/index.ts
async (req, res, next) => {
      try {
        if ((req.user as any | undefined)?.authLevel !== 'admin') {
          res.status(500).end();
          throw Error('Unauthorized access occured');
        }
        const { markdown, published } = req.body;
        const { title, summary, imgUrl, imageNodes } =
          extractFromMarkdown(markdown);

        // Open DB
        const db = await open({
          filename: 'db/blog.db',
          driver: sqlite3.Database,
        });

        let id = req.query.id ? +req.query.id : null;
        const { absolutePath, relativePath, param } = await createDir(db, id);
        id = param;
        removeUnusedImage(relativePath, imageNodes);

        // upsert
        await db.all(`
          INSERT INTO posts (id, title, summary, markdown, published, imgUrl) 
          VALUES(${id}, '${title}', '${summary}', '${markdown}', ${published}, '${imgUrl}')
          ON CONFLICT(id)
          DO UPDATE SET title='${title}', summary='${summary}', markdown='${markdown}', published=${published}, imgUrl='${imgUrl}'
        `);

        await db.close();
        res.status(200).json(id);
      } catch (err) {
        console.error(err);
        res.status(500).end();
      }
    }

그리고 SQLite에 upsert 기능을 추가했다. 요청에서 id가 query string으로 명시된 경우에는 기존 데이터를 업데이트하고, 그렇지 않으면 새로운 포스트를 만들도록 했다. 그래서 추후에 포스트를 수정할 수 있도록 했다. 또한 이를 통해서 부가적으로 임시저장 기능을 도입할 수 있었다. 만약 published가 false일 경우에는 임시저장하고, 그렇지 않으면 다른 사용자에게 보이도록 했다.

또한 extractFromMarkdownremoveUnusedImage함수가 눈에 띄는데, 이것은 아래에서 설명할 것이다.

마크다운 추출

이전에 봤던 Remark도 내부적으로는 unified라는 패키지를 사용하고 있다고 한다.

Several ecosystems are built on unified around different kinds of content. Notably, remark (markdown), rehype (HTML), and retext (natural language). These ecosystems can be connected together.

이번에는 이 unified와 remark-parse를 통해서 마크다운을 파싱하여 AST를 생성하고 이를 순회해서 데이터를 뽑아오려고 한다. 이를 바탕으로 DB에 저장할 title과 summary 정보를 가져올 것이다. 또한 이미지 URL을 가져와서 업로드한 후에 지운 이미지를 찾아내 파일 시스템에서 삭제할 수 있도록 했다.

//blog_server
//lib/extract.ts

interface AstNode {
  children?: AstNode[];
  type: string;
  value?: string;
  url?: string;
}

const recursiveExtractText: (root: AstNode) => string = root => {
  if (!root.children) return root.value ?? '';

  return root.children
    .map((child, idx) => recursiveExtractText(child))
    .join('');
};

const recursiveFindBuilder = (type: string) => {
  let imageNode: AstNode[] = [];

  const recursiveFind = (root: AstNode) => {
    if (!root.children) return imageNode;

    for (const child of root.children) {
      if (child.type === type) imageNode.push(child);
      else recursiveFind(child);
    }
    return imageNode;
  };

  return recursiveFind;
};

const removeUnusedImage = (
  relativePath: string,
  imageNodes: AstNode[] | undefined
) => {
  const fileNames = fs.readdirSync(relativePath);
  const imagePaths = imageNodes
    ? imageNodes.map(node => path.resolve(relativePath, node.url as string))
    : [];

  fileNames.map((name, idx) => {
    const filePath = path.resolve('/', relativePath, name);
    if (!imagePaths.some(imagePath => imagePath === filePath)) {
      fs.rmSync(filePath);
    }
  });
};

const extractFromMarkdown = (markdown: string) => {
  const ast = unified().use(remarkParse).parse(markdown);

  const titleNode = ast.children.find(child => child.type === 'heading');
  const title = recursiveExtractText(titleNode as AstNode);

  const summaryNode = ast.children.find(child => child.type === 'paragraph');
  const summary = recursiveExtractText(summaryNode as AstNode);

  const recursiveFind = recursiveFindBuilder('image');
  const imageNodes = recursiveFind(ast);
  const imgUrl = imageNodes[0]?.url ?? '';

  return { title, summary, imgUrl, imageNodes };
};

extractFromMarkdown부터 보면, unified와 remark-parse를 이용해서 마크다운 AST를 생성한다. 그 이후에 recursiveExtractText함수를 통해서 첫 번째 heading으로부터 title을 가져오고 첫 번째 paragraph로 부터 summary를 뽑아온다.

recursiveExtractText 함수는 children 속성이 보이지 않을 때까지 자식 노드를 재귀적으로 순회하면서 텍스트만을 가져온다. 따라서 bold 등의 마크다운 태그가 있더라도 이를 무시하고 텍스트만을 가져올 수 있는 것이다.

removeUnusedImage 함수는 업로드한 후에 사용하지 않는 이미지를 파일 시스템에서 제거한다. 이미지 태그를 삭제하는 등의 변경이 일어났을 때 파일 시스템에 있는 이미지의 URL 리스트와 마크다운에 있는 이미지 URL 리스트가 달라지게 되는데, 이 때 서로를 비교해서 사용하지 않는 파일을 찾아낼 수 있다. 그러기 위해서는 우선 이미지 태그에 있는 URL 목록을 가져와야하는데, 이를 recursiveFindBuilder 함수를 통해 가져올 수 있다.

recursiveFindBuilder 함수는 클로저를 활용해서 내부함수에서 재귀호출하면서 얻은 노드 리스트를 외부함수에 저장해둔다. 해당 함수를 호출할 떄 어떤 타입의 노드를 가져올 것인지 명시할 수 있도록 했는데, 여기서는 image 노드를 가져왔다.

마무리

프로그래밍 언어 수업에서 들었던 AST를 여기서 다시 볼 줄은 몰랐다. 그 때는 미니멀한 문법의 언어를 파싱해서 인터프리터를 만드는 과제를 했었는데 AST를 만드는데 정말 머리가 아팠던 기억이 난다. 그런 것을 생각해보면 파싱 라이브러리를 만든 unified 커뮤니티가 참 존경스럽다.

AST를 재귀적으로 순회하는 것이 성능적으로 괜찮을지는 잘 모르겠지만, 글을 작성하는 사람이 나 혼자뿐이라 아마 문제는 없을 것이라 생각된다.

Next.js에서 에디터 사용은 이전에도 해본적있어서 쉽게 진행했는데, 처음하면 정말 머리가 아픈 부분이다. 까먹지 않도록 기록해두는 것이 좋을 것 같아 이번 포스트에 작성했다.

profile
주니어 프론트엔드 개발자!

0개의 댓글