Skip to content

Commit 90c612a

Browse files
committed
feat: 이미지 포맷변환 및 picture 태그 파싱 플러그인 구현
1 parent eb88c58 commit 90c612a

3 files changed

Lines changed: 184 additions & 1 deletion

File tree

.vitepress/config.mts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { generateSidebar } from "./plugins/sidebar";
55
import markdownItKatex from "markdown-it-katex";
66

77
import { createAutoGeneratePostsPlugin } from "./plugins/posts";
8+
import { createImageOptimizerPlugin } from "./plugins/image-optimizer";
9+
import { markdownPicturePlugin } from "./plugins/markdown-picture";
810

911
const isProduction = process.env.NODE_ENV === "production";
1012

@@ -58,14 +60,22 @@ export default defineConfig({
5860
markdown: {
5961
config: (md) => {
6062
md.use(markdownItKatex);
63+
md.use(markdownPicturePlugin);
6164
},
6265
},
6366

6467
vite: {
6568
resolve: {
6669
alias: [{ find: "@", replacement: "/src" }],
6770
},
68-
plugins: [createAutoGeneratePostsPlugin()],
71+
plugins: [
72+
createAutoGeneratePostsPlugin(),
73+
createImageOptimizerPlugin({
74+
formats: ["webp", "jpeg"],
75+
webpQuality: 90,
76+
jpegQuality: 90,
77+
}),
78+
],
6979
},
7080

7181
head: [
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { Plugin } from "vite";
2+
import sharp from "sharp";
3+
import { existsSync } from "fs";
4+
import { join, dirname, extname, basename } from "path";
5+
import { glob } from "glob";
6+
7+
interface ImageOptimizerOptions {
8+
/**
9+
* 이미지를 찾을 디렉토리 패턴
10+
* @default "contents/posts/** /img/** /*.(jpg|jpeg|png)"
11+
*/
12+
sourcePattern?: string;
13+
14+
/**
15+
* 생성할 이미지 포맷들
16+
* @default ["webp", "jpeg"]
17+
*/
18+
formats?: ("webp" | "jpeg" | "avif")[];
19+
20+
/**
21+
* WebP 품질 (0-100)
22+
* @default 90
23+
*/
24+
webpQuality?: number;
25+
26+
/**
27+
* JPEG 품질 (0-100)
28+
* @default 90
29+
*/
30+
jpegQuality?: number;
31+
32+
/**
33+
* AVIF 품질 (0-100)
34+
* @default 90
35+
*/
36+
avifQuality?: number;
37+
}
38+
39+
/**
40+
* 이미지를 여러 포맷으로 변환합니다
41+
*/
42+
async function convertImage(
43+
sourcePath: string,
44+
formats: ("webp" | "jpeg" | "avif")[],
45+
options: ImageOptimizerOptions,
46+
) {
47+
const ext = extname(sourcePath).toLowerCase();
48+
const dir = dirname(sourcePath);
49+
const name = basename(sourcePath, ext);
50+
51+
// 이미 최적화된 이미지는 스킵
52+
if (formats.includes("webp" as any) && ext === ".webp") return;
53+
if (formats.includes("jpeg" as any) && [".jpg", ".jpeg"].includes(ext)) return;
54+
if (formats.includes("avif" as any) && ext === ".avif") return;
55+
56+
const image = sharp(sourcePath);
57+
58+
for (const format of formats) {
59+
const outputPath = join(dir, `${name}.${format}`);
60+
61+
// 이미 변환된 파일이 있으면 스킵
62+
if (existsSync(outputPath)) continue;
63+
64+
try {
65+
if (format === "webp") {
66+
await image
67+
.clone()
68+
.webp({ quality: options.webpQuality || 80 })
69+
.toFile(outputPath);
70+
console.log(`✅ WebP 생성: ${outputPath}`);
71+
} else if (format === "jpeg") {
72+
await image
73+
.clone()
74+
.jpeg({ quality: options.jpegQuality || 80 })
75+
.toFile(outputPath);
76+
console.log(`✅ JPEG 생성: ${outputPath}`);
77+
} else if (format === "avif") {
78+
await image
79+
.clone()
80+
.avif({ quality: options.avifQuality || 70 })
81+
.toFile(outputPath);
82+
console.log(`✅ AVIF 생성: ${outputPath}`);
83+
}
84+
} catch (error) {
85+
console.warn(`⚠️ 이미지 변환 실패 (${sourcePath}):`, error);
86+
}
87+
}
88+
}
89+
90+
/**
91+
* 빌드 시점에 이미지를 최적화하는 Vite 플러그인
92+
*/
93+
export function createImageOptimizerPlugin(options: ImageOptimizerOptions = {}): Plugin {
94+
const {
95+
sourcePattern = "contents/posts/**/img/**/*.{jpg,jpeg,png}",
96+
formats = ["webp", "jpeg"],
97+
} = options;
98+
99+
return {
100+
name: "vitepress-image-optimizer",
101+
102+
async buildStart() {
103+
console.log("🖼️ 이미지 최적화 시작...");
104+
105+
try {
106+
const imagePaths = await glob(sourcePattern, {
107+
cwd: process.cwd(),
108+
absolute: true,
109+
});
110+
111+
console.log(`📁 발견된 이미지: ${imagePaths.length}개`);
112+
113+
for (const imagePath of imagePaths) {
114+
await convertImage(imagePath, formats, options);
115+
}
116+
117+
console.log("✅ 이미지 최적화 완료!");
118+
} catch (error) {
119+
console.error("❌ 이미지 최적화 실패:", error);
120+
}
121+
},
122+
};
123+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type MarkdownIt from "markdown-it";
2+
import type { RenderRule } from "markdown-it/lib/renderer.mjs";
3+
4+
/**
5+
* 이미지 경로에서 확장자를 변경합니다
6+
*/
7+
function changeExtension(src: string, newExt: string): string {
8+
const lastDotIndex = src.lastIndexOf(".");
9+
if (lastDotIndex === -1) return src;
10+
return src.substring(0, lastDotIndex) + "." + newExt;
11+
}
12+
13+
/**
14+
* 마크다운의 이미지를 picture 태그로 변환하는 플러그인
15+
*/
16+
export function markdownPicturePlugin(md: MarkdownIt) {
17+
const defaultRender: RenderRule =
18+
md.renderer.rules.image ||
19+
((tokens, idx, options, env, self) => {
20+
return self.renderToken(tokens, idx, options);
21+
});
22+
23+
md.renderer.rules.image = (tokens, idx, options, env, self) => {
24+
const token = tokens[idx];
25+
const srcIndex = token.attrIndex("src");
26+
27+
if (srcIndex < 0) {
28+
return defaultRender(tokens, idx, options, env, self);
29+
}
30+
31+
const src = token.attrs![srcIndex][1];
32+
const alt = token.content;
33+
34+
// 외부 URL이거나 svg, gif는 picture 태그로 변환하지 않음
35+
if (src.startsWith("http") || src.endsWith(".svg") || src.endsWith(".gif")) {
36+
return defaultRender(tokens, idx, options, env, self);
37+
}
38+
39+
// 이미지 포맷별 경로 생성
40+
const srcWebp = changeExtension(src, "webp");
41+
const srcJpeg = changeExtension(src, "jpeg");
42+
43+
// picture 태그 생성
44+
return `<picture>
45+
<source srcset="${srcWebp}" type="image/webp" />
46+
<source srcset="${srcJpeg}" type="image/jpeg" />
47+
<img src="${srcJpeg}" alt="${alt}" loading="lazy" />
48+
</picture>`;
49+
};
50+
}

0 commit comments

Comments
 (0)