vidi's blog

02. 블로그 컨텐츠 배포방식에 대해.md

astro custom loader를 생성해서 내가 원하는 컨텐츠만 배포하기

블로그 웹개발 astro

현재 블로그 컨텐츠는 obsidian-content 레포에서 관리되고있다 블로그는 obsidian-blog로 관리되고있다.

obsidian-content 가 배포될때마다 obsidian-blog는 배포되는것을 확인했는데. src/content/blog 에 컨텐츠를 올리고 배포하다보니, git submodule을 이용해 배포하니 커밋이 꼬여버릴수 있을 가능성을 확인했다.

서브모듈은 메인모듈에서 서브모듈도 수정할수있기 때문에, 주의할필요가있다. obsidian-blog에서 서브모듈을 수정할수있는건 각자의 역할에 맞지 않기떄문에, 확실하게 분리할 필요가있다.

외부에 content라는 폴더를 추가하고 app-content폴더는 git ignore처리, collection에서 app-content에 있는 내용들을 불러오게할 생각이다.

일단 기존 submodule을 제거하고, 컨텐츠도 제거하자

submodule 제거하기

git submodule --help 를 하면 deinit 이라는 명령어가 있는것을 확인할수있다

deinit [-f|--force] (--all|[--] <path>...)
           Unregister the given submodules, i.e. remove
           the whole submodule.$name section from
           .git/config together with their work tree.
           Further calls to git submodule update, git
           submodule foreach and git submodule sync will
           skip any unregistered submodules until they
           are initialized again, so use this command if
           you don’t want to have a local checkout of
           the submodule in your working tree anymore.

           When the command is run without pathspec, it
           errors out, instead of deinit-ing everything,
           to prevent mistakes.

           If --force is specified, the submodule’s
           working tree will be removed even if it
           contains local modifications.

           If you really want to remove a submodule from
           the repository and commit that use git-rm(1)
           instead. See gitsubmodules(7) for removal
           options.

deinit으로 submodule을 unregister 할수있다. .git/config에서 서브모듈정보와 등록된 서브모듈도 삭제된다.

서브모듈은 사용하지 않을것이기 때문에 .gitmodules도 삭제한다

블로그 컨텐츠 경로변경

blog.ts 파일에 path를 블로그컨텐츠에 맞게 불러온다

블로그 컨텐츠 파서 변경

블로그가 is_published될 컨텐츠만 보이면 되고, 제목은 파일이름, slug는 property에 slug로 정의된 것만 보여주면된다.

이런 특정한 조건이있는 컨텐츠배포 방식은 자체 로더를 생성해야한다.

경로의 마크다운 파일들을 모두 인식하고, 파싱해서 보여주는형태로 처리해야한다.

파일경로를 하나 받는 로더를 만들고, glob 으로 마크다운 파일들을 읽어들인다

실제 astro:content의 globLodaer는 어떻게 동작하는지 미리 확인해보았다.

glob 파일파서 핵심로직 git

여기서는 tinyglobby 라이브러리를 활용해서 파일을 파싱한것으로 확인했다. watch등 여러 옵션들이있는데, 나는 컨텐츠는 분리해두었기 때문에 워치동작은 제외해두고 간단하게 현재파일을 파싱해서 스토어에 저장하는 방식으로 진행할 예정이다.

https://docs.astro.build/en/reference/content-loader-reference/#the-loader-object

커스텀 로더 구현

커스텀로더로 경로의 md파일 전체를 읽어들일것이며, 그중 md파일의 front matter값만 읽어들일것이다.

1. grap으로 파일들 불러오기

import { glob } from 'glob';
/**
 * 블로그 컨텐츠를 로드합니다. is_published가 true인 것만 로드합니다
 * @param path 
 */
export const contentLoader = async (path: string) => {

  const contents = await glob(`${path}/**/*.md`);

  console.log(contents);
}

loader에 path값이 /blog/ 이런식으로 넘어오기 떄문에 glob을 활용해 path에 포함된 모든 블로그글을 읽어올수 있도록 glob함수를 사용한다

contents에는 모든 md파일을 가져올수있다. Pasted image 20251028182448.png

이제 front matter을 읽어들여보자

2. front matter 라이브러리로 각 파일 정보 불러오기

파일이 많고, 모든파일들을 읽어들여서 front-matter 부분만 처리하길 원했다.

front matter 라이브러리는 어떤종류가 있는지 찾아보았을때 가장 많이쓰는건 두가지로 확인되었다.

  1. front-matter
  2. gray-matter

각 라이브러리 코드를 확인해보았다.

front-matter

front-matter는 문자열에서 front-matter만 regex로 처리하는것으로 확인했다.

gray-matter

내부코드도 front-matter와 동일하게 regex로 파싱하며, yaml, json등 다양한 유형의 문자열 파싱이 가능하다

둘중 하나로 front-matter을 선택하기로 했다. gray-matter의 다양한기능을 요구하지 않기도 하고, 단순하게 front-matter부분만 사용하수있게 파싱만 할수있으면된다.

다만 둘다 파일의 front-matter만 파싱하는 기능은 없기 때문에, 직접 front-matter까지 파일을 읽어들이고 처리해야한다.

3. front-matter부분만 파일 읽어들이기

파일을 읽어들이기 위해 fs/promise 의 readfile 함수를 확인했다. fspromise-readfile.png

지원하는 파라미터를 확인했을때, 함수는 전체파일내용을 읽어들이고 비동기로 응답을 보내주기 떄문에 파일이 너무 클경우에 문제가 생길 여지가 있었다.

마침 문세 하단에도 Performance Considerations에도 동일한 내용이 있었다.

빠르게 파일을 읽는것이 필요하다면 fs.read을 고려해보는것을 권장했기 떄문에 해당문서로 확인해보았다.

fsread.png

버퍼에 내가 원하는만큼 데이터를 넣을수있다. positiond으로 파일을 읽기 시작할 위치를 지정해 연속적으로도 읽어올수있다.

front-matter는 시작이 파일 가장첫번째 ---\n 로 해서 ---\n 로 끝나기 때문에, 이부분을 인식하고 연속적으로 읽어들이면 될것같다.

file handler를 이용해 쉽게 읽어들이기

문서를 더 찾아보니, fs/promise의 open() 함수의 리턴값이 FileHandler

버퍼기반 read()함수를 promise로 지원하기때문에 따로 read()만 쓸필요는 없고 FileHandler.read() 로 충분히 구현이 가능하다.

파일 읽어들여 파싱하기

read함수를 이용해 4KB씩 버퍼에 읽어들여 앞부분의 프론트메터만 분석해볼 생각이다.

4KB는 파일시스템 기본 블록크기가 4KB이기 때문에 효율적이라고 가정했다.

const getFrontMatter = async (path: string) => {
  const fileHandler = await open(path, 'r');
  const buffer = Buffer.alloc(4096);
  

  let position = 0;
  let value = ''
  while (true) {
    const result = await fileHandler.read(buffer, 0, 4096, position)

    if (result.bytesRead === 0) break;

    value += buffer.toString('utf-8', 0, result.bytesRead);
    position += result.bytesRead;

    if (isFrontMatter(value)) {
      fileHandler.close();
      return fm(value);
    }
  }
  fileHandler.close();
  return null;
}

경로의 파싱결과, 아래처럼 front-matter들을 가져올수있었다.

Pasted image 20251028190700.png

이제 is_published가 true인것만 보여주도록 필터해서 컨텐츠를 로드할것이다.

3. Loader를 반환하는 함수 만들기

이제 astro에서 사용할 Loader를 만든다.

loader는 아래 3가지로 이루어져있다

  • name: string
  • load: (context: LoaderContext) => Promise<void>
  • schema?: ZodSchema | Promise<ZodSchema> | (() => ZodSchema | Promise<ZodSchema>)

이중 LoaderContext의 store가 astro가 빌드할때 생기는 컨텐츠를 접근할때 사용하는 데이터스토어다.

getCollection, getEntry를 사용해서 가져오는 데이터는 DataStore에서 조회해서 가져오는것이다.

DataStore는 KV(Key Value) 저장소이며, 컬렉션 범위에 속하므로 로더는 자신의 컬렉션 데이터에만 접근할 수 있다. DataEntry를 저장하며, DataEntry의 id가 key가 된다.

import type { Loader, LoaderContext } from "astro/loaders";
import { contentLoader } from "./glob";

interface GlobLoaderOptions {
  pattern: string;
}

export const globLoader = ({ pattern }: GlobLoaderOptions): Loader => {
  return {
    name: 'vidi-glob-loader',
    load: async ({ collection, store, parseData, logger }: LoaderContext) => {

      store.clear();
      const contents = await contentLoader(pattern);

      for (const content of contents) {
        const id = content.meta.slug;
        const parsedContent = await parseData({
          id,
          data: {
            title: content.title,
            ...content.meta,
          },
          filePath: content.path,
        });

        store.set({
          id,
          data: parsedContent,
        });
      }

      logger.info(`Loaded ${contents.length} contents for collection ${collection}`);
    }
  }
}

LoaderContext의 collection, store, parseData, logger를 활용했다

  • collection: 로더를 사용할때 collection이름값, string
  • store: DataStore 객체
  • parseData: scheme설정시 데이터 파싱
  • logger: 어떤 로더에서 발생했는지 console.log대신 볼수있는 로거

id값의 경우, obsidian 용 블로그이기 때문에 slug로 지정해두었다. 같은 slug가 존재하면 저장되지 않고 빌드가 되지않는다.

이대로 npm run build 를 실행해서 static 컨텐츠를 생성하면 정상적으로 페이지가 생성되는것은 볼수있는데, 안에 내용이 하나도 안보이는것을 볼수있다.

이는 store.set에서 renderer 값을 전달하지 않아서 생긴문제이다.


export const globLoader = ({ pattern }: GlobLoaderOptions): Loader => {
  return {
    name: 'vidi-glob-loader',
    load: async ({ collection, store, parseData, logger, generateDigest, renderMarkdown}: LoaderContext) => {

      store.clear();
      const contents = await contentLoader(pattern);

      for (const content of contents) {
        const id = content.meta.slug;
        const parsedContent = await parseData({
          id,
          data: {
            title: content.title,
            ...content.meta,
          },
          filePath: content.path,
        });

        const digest = generateDigest(parsedContent);

        store.set({
          id,
          data: parsedContent,
          rendered: await renderMarkdown(content.body),
          digest,
        });
      }

      logger.info(`Loaded ${contents.length} contents for collection ${collection}`);
    }
  }
}

RenderedContent타입을 리턴하는 renderMarkdown을 사용하면 astro의 <Content/> 를 정상적으로 그려낼수있다.

옵시디언 블로그의 [[]] 분석문제

막상 페이지를 열어보니 이미지쪽에 아래와같이 표기되고있다 Pasted image 20251028211013.png

옵시디언은 [[]] 로 링크를 건다. 이미지파일이면 이미지를 보여주고, 내부파일이면 내부파일링크를 걸어준다. 이 처리방법은 다음포스트에서 처리해보겠다.

참고

store의 rendered


        store.set({
          id,
          data,
          // If the data source provides HTML, it can be set in the `rendered` property
          // This will allow users to use the `<Content />` component in their pages to render the HTML.
          rendered: {
            html: data.description ?? '',
          },
          digest,
        });

html을 넣어 Content영역에 그려낼수있다. 생각해보면 html로 변환만 한다면 그려낼수 있다는뜻