이 블로그는 Next.js
와 contentlayer
로 만들었습니다.
Next.js
에서 정적 이미지 파일을 참조하기 위해서는 이미지를 /public
폴더 안에 위치시켜야 합니다.
제가 적용한 게시글 경로 방식으로는 마크다운 파일에 이미지를 제대로 제공할 수 없었습니다.
게시글은 프로젝트 루트의 /posts
경로에 저장하고 있습니다.
/posts
하위 구조를 보면, 게시글마다 폴더가 있고 내부엔 index.mdx
파일과 이미지 파일이 함께 존재합니다.
이런식으로 예를 들 수 있습니다.
posts/
┣ post-one/
┃ ┣ image1.png
┃ ┣ image2.png
┃ ┣ index.md
┣ post-two/
┃ ┣ image.png
┃ ┣ index.md
이런 경로로 만든 이유는 이미지와 마크다운 파일을 같은 경로에 위치시키고 싶었기 때문입니다.
게시글에 사용된 이미지를 바로 파악할 수 있는 점이 좋았습니다.
물론 /public
하위에도 똑같은 경로를 만들면 되겠지만 두벌 관리되는 느낌이었습니다.
따라서 위 구조를 그대로 가져가고 마크다운 파일에선 이런식으로 이미지를 사용하고 싶었습니다.
![ image1.png ]( ./image1.png )
구글링 결과 저와 같은 고민을 하고 해결하신 분들이 있었고 간단하게 정리해보았습니다.
Next.js에서는 정적 리소스는 /public
을 통해 제공한다고 말씀드렸습니다.
빌드 전에 기존 게시글 경로에 있던 이미지들을 /public
으로 복사하도록 해봅시다.
이를 위해 파일을 쉽게 다룰 수 있게 하는 패키지를 사용할겁니다. 패키지를 설치해줍니다.
npm i -D fs-extra
빌드 전에 이미지 복사하는 특정 스크립트를 실행하게 합니다.
npm run dev
혹은 npm run build
실행 전 특정 스크립트를 실행 시키는 스크립트를 추가해봅시다.
package.json " scripts " : {
" predev " : " node ./bin/copy-images.mjs " ,
" prebuild " : " node ./bin/copy-images.mjs " ,
" dev " : " next dev " ,
" build " : " next build " ,
// ...
}
사전 준비는 마쳤습니다.
copy-images.mjs
파일을 만들어봅시다.
mjs 확장자인 이유는 ECMAScript modules과 최상위 await를 이용하기 위함입니다.
수행하는 기능별로 코드를 나눠서 보겠습니다.
import fs from ' fs ' ;
import path from ' path ' ;
import fsExtra from ' fs-extra ' ;
const fsPromises = fs . promises ;
/** 이미지를 복사할 위치 */
const targetDir = ' ./public/images/posts ' ;
// 대상 폴더가 없으면 폴더를 생성합니다. 폴더에 무언가 존재한다면 모두 비워줍니다.
await fsExtra . emptyDir ( targetDir );
/** 게시글이 있는 위치 */
const postsDir = ' ./posts ' ;
const copyImagesToPublic = async ( images , slug ) => {
for ( const image of images ) {
/** 특정 이미지의 기존 경로 */
const sourcePath = ` ${ postsDir } / ${ slug } / ${ image } ` ;
/** 특정 이미지를 복사할 경로 */
const targetPath = ` ${ targetDir } / ${ slug } / ${ image } ` ;
await fsPromises . copyFile ( sourcePath , targetPath );
}
};
/** 허용하는 이미지 확장자 리스트 */
const allowedImageExtensionList = [ ' .png ' , ' .jpg ' , ' .jpeg ' , ' .gif ' ];
const createPostImageFoldersForCopy = async () => {
/** 모든 게시글 폴더 */
const postDirentList = (
await fsPromises . readdir ( postsDir , { withFileTypes : true })
). filter (( file ) => file . isDirectory ());
for ( const dirent of postDirentList ) {
const slug = dirent . name ;
/** 게시글 폴더에 존재하는 파일 */
const postsDirFiles = await fsPromises . readdir ( ` ${ postsDir } / ${ slug } ` );
/** 게시글 폴더에 존재하는 이미지 */
const images = postsDirFiles . filter (( file ) =>
allowedImageExtensionList . includes ( path . extname ( file )),
);
if ( ! images . length ) {
continue ;
}
/** 게시글 폴더 만들고 복사 */
await fsPromises . mkdir ( ` ${ targetDir } / ${ slug } ` );
await copyImagesToPublic ( images , slug );
}
};
postDirentList
를 만들때 필터링 하는 로직이 있습니다.
이와 관련된 사항은 여기 글 에 자세히 설명되어 있습니다.
bin/copy-images.mjs import fs from ' fs ' ;
import path from ' path ' ;
import fsExtra from ' fs-extra ' ;
const fsPromises = fs . promises ;
/** 이미지를 복사할 위치 */
const targetDir = ' ./public/images/posts ' ;
/** 게시글이 있는 위치 */
const postsDir = ' ./posts ' ;
/** 허용하는 이미지 확장자 리스트 */
const allowedImageExtensionList = [ ' .png ' , ' .jpg ' , ' .jpeg ' , ' .gif ' ];
const createPostImageFoldersForCopy = async () => {
/** 모든 게시글 폴더 */
const postDirentList = (
await fsPromises . readdir ( postsDir , { withFileTypes : true })
). filter (( file ) => file . isDirectory ());
for ( const dirent of postDirentList ) {
const slug = dirent . name ;
/** 게시글 폴더에 존재하는 파일 */
const postsDirFiles = await fsPromises . readdir ( ` ${ postsDir } / ${ slug } ` );
/** 게시글 폴더에 존재하는 이미지 */
const images = postsDirFiles . filter (( file ) =>
allowedImageExtensionList . includes ( path . extname ( file )),
);
if ( ! images . length ) {
continue ;
}
/** 게시글 폴더 만들고 복사 */
await fsPromises . mkdir ( ` ${ targetDir } / ${ slug } ` );
await copyImagesToPublic ( images , slug );
}
};
const copyImagesToPublic = async ( images , slug ) => {
for ( const image of images ) {
const sourcePath = ` ${ postsDir } / ${ slug } / ${ image } ` ;
const targetPath = ` ${ targetDir } / ${ slug } / ${ image } ` ;
await fsPromises . copyFile ( sourcePath , targetPath );
}
};
// 대상 폴더가 없으면 폴더를 생성합니다. 폴더에 무언가 존재한다면 모두 비워줍니다.
await fsExtra . emptyDir ( targetDir );
await createPostImageFoldersForCopy ();
일부 변수 값은 본인 프로젝트의 환경에 맞게 변경하여 사용하시면 됩니다.
코드를 모두 작성했다면 실제 빌드 스크립트를 실행하고 /public
경로로 복사되는지 확인해봅시다.
npm run build
posts/
┣ post-one/
┃ ┣ image1.png
┃ ┣ image2.png
┃ ┣ index.md
┣ post-two/
┃ ┣ image.png
┃ ┣ index.md
public
┣ images
┃ ┣ posts
┃ ┃ ┣ post-one
┃ ┃ ┃ ┣ image1.png
┃ ┃ ┃ ┗ image2.png
┃ ┃ ┣ post-two
┃ ┃ ┃ ┗ image.png
/public
경로에 생긴 이미지는 복사된 이미지이므로 github에 업로드 될 필요가 없습니다.
.gitignore
에 복사된 이미지가 존재하는 경로를 추가해줍시다.
.gitignore /public/images
여기까지 작업했다면, 마크다운 파일에서 /public
으로 복사된 이미지를 가져오기 위해 절대경로를 사용하면 됩니다. 하지만 절대 경로 대신 상대 경로로 편하게 사용하시고 싶을겁니다.
<!-- 절대 경로 - 이미지가 정상적으로 나옴. -->
![ image ]( /images/posts/post-one/image1.png )
<!-- 상대 경로 - 이미지가 안나옴. -->
![ image ]( ./image1.png )
이제 그 작업을 해봅시다.
remark 플러그인을 사용할 수 있는 블로그라면, 자체 플러그인을 제작하여 이 문제를 해결할 수 있습니다.
마크다운 파일은 contentlayer를 통해 HTML 혹은 코드로 변환하는 작업을 거칩니다.
변환 작업에서 플러그인을 사용할 수 있고, 이 플러그인에서 이미지에서 사용된 상대 경로를 절대 경로로 변환하는 작업을 수행할겁니다.
필요한 패키지부터 설치합니다.
npm i -D unist-util-visit
unist-util-visit
는 remark에서 사용하는 syntax tree를 순회할 수 있게 하는 라이브러리입니다.
간단히 말해 작성한 마크다운 파일에서 특정 노드를 찾고 속성을 변환하게 할 수 있습니다.
이미지를 참조할 때 ./image.png
처럼 작성한 상대 경로를 /images/posts/post-one/image1.png
절대 경로로 변환하는 작업을 할테니 제게 부합한 라이브러리입니다.
해당 포스팅에서는 목적 달성을 위한 내용만 있으므로 자세한건 unified , remark , mdast 키워드를 중심으로 검색하여 확인하시면 됩니다.
transform-image.src.mjs
파일을 만듭니다.
plugins/transform-image.src.mjs import { visit } from ' unist-util-visit ' ;
const imgDirInsidePublic = ' images/posts ' ;
export default function transformImgSrc () {
return ( tree , file ) => {
visit ( tree , ' paragraph ' , ( node ) => {
const filePath = file . data . rawDocumentData . flattenedPath ;
const image = node . children . find (( child ) => child . type === ' image ' );
if ( image ) {
const fileName = image . url . replace ( ' ./ ' , '' );
image . url = ` / ${ imgDirInsidePublic } / ${ filePath } / ${ fileName } ` ;
}
});
};
}
마크다운의 이미지는 변환을 거치면 p > img
로 표현됩니다.
img의 url에서 상대 경로를 제거합니다.
public 경로
+ 게시글 경로
+ 이미지 파일명
을 합쳐 img의 url을 절대 경로로 수정합니다.
이 플러그인을 변환 과정에 적용하는 작업이 남았습니다. contentlayer.config.ts
파일을 수정해줍시다.
export default makeSource ({
// ...
mdx : { remarkPlugins : [ transformImgSrc ] },
// or
markdown : { remarkPlugins : [ transformImgSrc ] },
// ...
});
이렇게 되면 모든 작업이 끝났습니다.
마크다운과 같은 경로에 이미지를 위치시키고, 상대 경로로 이미지를 참조하여 마크다운을 작성해봅시다.
빌드 후 확인해보면 정상적으로 이미지가 나오는 걸 확인할 수 있습니다.