Skip to content

Commit 7d16d6a

Browse files
authored
Merge pull request #294 from DguFarmSystem/fix/#292_2
fix: 블로그 프로젝트 페이지 트러블 슈팅
2 parents 5284a85 + 3ff2240 commit 7d16d6a

File tree

9 files changed

+122
-137
lines changed

9 files changed

+122
-137
lines changed

apps/website/api/og.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export default async function handler(req, res) {
2+
const { url } = req.query;
3+
if (!url || typeof url !== 'string') {
4+
res.status(400).json({ error: 'url query parameter is required' });
5+
return;
6+
}
7+
8+
try {
9+
const response = await fetch(url);
10+
if (!response.ok) {
11+
throw new Error(`Failed to fetch: ${response.status}`);
12+
}
13+
const html = await response.text();
14+
15+
const extract = (property) => {
16+
const metaTag = new RegExp(`<meta[^>]+(?:property|name)=\"${property}\"[^>]*content=\"([^\"]+)\"`, 'i');
17+
const match = html.match(metaTag);
18+
return match ? match[1] : null;
19+
};
20+
21+
const title = extract('og:title') || extract('title');
22+
const description = extract('og:description') || extract('description');
23+
const image = extract('og:image');
24+
25+
res.setHeader('Access-Control-Allow-Origin', '*');
26+
res.status(200).json({ title, description, image });
27+
} catch (error) {
28+
res.status(500).json({ error: 'Failed to fetch og tags' });
29+
}
30+
}

apps/website/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
"scripts": {
77
"dev": "vite --host",
88
"type-check": "tsc --noEmit",
9-
"build": "tsc -b && vite build",
9+
"build": "tsc -p tsconfig.server.json && vite build",
10+
"build:api": "tsc -p tsconfig.server.json",
11+
"build:client": "vite build",
1012
"lint": "eslint .",
1113
"preview": "vite preview"
1214
},
30 KB
Loading
Lines changed: 69 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,166 +1,85 @@
1-
import { useState, useEffect, useRef } from 'react';
2-
import { handleApiError } from '@/utils/handleApiError';
1+
import { useState, useEffect, useRef } from "react";
2+
import { handleApiError } from "@/utils/handleApiError";
33

44
export interface APIResponse {
55
title: string;
66
description: string;
77
image: string;
8-
siteName: string;
9-
hostname: string;
8+
siteName?: string;
9+
hostname?: string;
1010
}
1111

1212
/**
13-
* isValidResponse
14-
* APIResponse가 유효한지를 체크합니다.
15-
* (필수 필드가 빈 문자열이면 유효하지 않다고 간주)
13+
* 필수 필드(title·image)가 채워져 있는지 확인
1614
*/
1715
export const isValidResponse = (res: APIResponse | null): boolean => {
18-
if (!res) return false;
19-
return (
20-
res.title !== '' &&
21-
// description은 빈 문자열이어도 허용할 경우 주석 처리
22-
res.image !== ''
23-
);
24-
};
25-
26-
// 프록시 서버 URL (CORS 헤더가 추가되어야 합니다)
27-
// corsproxy.io 는 요청 URL을 쿼리스트링으로 전달합니다.
28-
// const proxyUrl = 'https://corsproxy.io/?key=****&url=';
29-
const proxyUrl = 'https://corsproxy.io/?url='; // localhost용 프록시
30-
31-
/**
32-
* extractMetaContent
33-
* 정규표현식을 사용하여 HTML에서 meta 태그의 content 값을 추출합니다.
34-
* property 또는 name 속성을 모두 고려합니다.
35-
*/
36-
const extractMetaContent = (html: string, key: string): string => {
37-
const regex = new RegExp(
38-
`<meta[^>]*(?:property|name)=["']${key}["'][^>]*content=["']([^"']*)["'][^>]*>`,
39-
'i'
40-
);
41-
const match = html.match(regex);
42-
return match ? match[1] : '';
43-
};
44-
45-
/**
46-
* cleanHTML
47-
* HTML 문자열에서 주석을 제거합니다.
48-
* (주석 안에 있는 OG 태그도 파싱할 수 있도록)
49-
*/
50-
const cleanHTML = (html: string): string => {
51-
return html.replace(/<!--[\s\S]*?-->/g, ''); // 주석 제거
16+
return !!(res && res.title && res.image);
5217
};
5318

19+
/* ------------------------------------------------------------------ */
20+
/* 환경별 API ORIGIN 결정 */
21+
/* ------------------------------------------------------------------ */
5422
/**
55-
* parseHTML
56-
* HTML 문자열을 파싱하여 OG 메타 태그 정보를 추출합니다.
57-
* DOMParser와 정규표현식 fallback을 함께 사용합니다.
23+
* dev : https://dev.farmsystem.kr
24+
* prod : https://farmsystem.kr
25+
* SSR : NEXT_PUBLIC_API_ORIGIN 환경변수 우선
5826
*/
59-
const parseHTML = (html: string, originalUrl: string): APIResponse => {
60-
// 먼저 주석 제거 (OG 태그가 주석에 있을 경우 보완)
61-
const clean = cleanHTML(html);
62-
const parser = new DOMParser();
63-
const doc = parser.parseFromString(clean, 'text/html');
64-
65-
// OG 메타 태그 추출
66-
let title =
67-
doc.querySelector('meta[property="og:title"], meta[name="og:title"]')?.getAttribute('content') || '';
68-
let description =
69-
doc.querySelector('meta[property="og:description"], meta[name="og:description"]')?.getAttribute('content') || '';
70-
let image =
71-
doc.querySelector('meta[property="og:image"], meta[name="og:image"]')?.getAttribute('content') || '';
72-
let siteName =
73-
doc.querySelector('meta[property="og:site_name"], meta[name="og:site_name"]')?.getAttribute('content') || '';
74-
let ogUrl =
75-
doc.querySelector('meta[property="og:url"], meta[name="og:url"]')?.getAttribute('content') || originalUrl;
76-
77-
// fallback: 정규표현식으로 추출 (값이 비어있으면)
78-
if (!title) {
79-
title = extractMetaContent(clean, 'og:title');
80-
}
81-
if (!description) {
82-
description = extractMetaContent(clean, 'og:description');
27+
const resolveApiOrigin = (): string => {
28+
if (typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_ORIGIN) {
29+
return process.env.NEXT_PUBLIC_API_ORIGIN;
8330
}
84-
if (!image) {
85-
image = extractMetaContent(clean, 'og:image');
31+
if (typeof window !== "undefined") {
32+
const host = window.location.hostname;
33+
// localhost 나 *.local → 동일 오리진 프록시 사용
34+
if (host === "localhost" || host.endsWith(".local")) return "";
35+
// dev 스테이징 도메인
36+
if (host.startsWith("dev.")) return "https://dev.farmsystem.kr";
8637
}
87-
if (!siteName) {
88-
siteName = extractMetaContent(clean, 'og:site_name');
89-
}
90-
if (!ogUrl) {
91-
ogUrl = originalUrl;
92-
}
93-
94-
// ogUrl을 이용해 hostname 추출
95-
let hostname = '';
96-
try {
97-
hostname = new URL(ogUrl).hostname;
98-
} catch {
99-
hostname = ''; // URL 파싱 실패 시 빈 문자열
100-
}
101-
102-
return { title, description, image, siteName, hostname };
38+
// 기본: 프로덕션 도메인
39+
return "https://farmsystem.kr";
10340
};
10441

42+
/* ------------------------------------------------------------------ */
43+
/* useLinkPreview – Serverless JSON 응답 전용 */
44+
/* ------------------------------------------------------------------ */
10545
export const useLinkPreview = (
10646
url: string,
107-
fetcher?: (url: string) => Promise<APIResponse | null>
47+
fetcher?: (endpoint: string) => Promise<APIResponse | null>
10848
) => {
10949
const [metadata, setMetadata] = useState<APIResponse | null>(null);
11050
const [loading, setLoading] = useState<boolean>(true);
111-
const isMounted = useRef(true);
11251
const [error, setError] = useState<Error | null>(null);
52+
const isMounted = useRef(true);
11353

11454
useEffect(() => {
55+
if (!url) {
56+
setMetadata(null);
57+
setLoading(false);
58+
return;
59+
}
60+
11561
isMounted.current = true;
11662
setLoading(true);
11763

118-
// 프록시를 통한 요청 URL 구성
119-
const proxyFetchUrl = proxyUrl + encodeURIComponent(url);
64+
const apiOrigin = resolveApiOrigin();
65+
const base = apiOrigin ? `${apiOrigin}/api/og?url=` : "/api/og?url="; // 로컬(dev 서버)일 때 동일 오리진 프록시 사용
66+
const endpoint = `${base}${encodeURIComponent(url)}`;
12067

121-
if (fetcher) {
122-
fetcher(proxyFetchUrl)
123-
.then((res) => {
124-
if (isMounted.current) {
125-
if (isValidResponse(res)) {
126-
setMetadata(res);
127-
} else {
128-
setMetadata(null); // 유효하지 않으면 null 처리
129-
}
130-
setLoading(false);
131-
}
132-
})
133-
.catch((err) => {
134-
setError(handleApiError(err));
135-
if (isMounted.current) {
136-
setMetadata(null);
137-
setLoading(false);
138-
}
139-
});
140-
} else {
141-
// 기본 fetcher: 프록시 서버를 통해 HTML 텍스트를 가져와 파싱
142-
fetch(proxyFetchUrl)
143-
.then((res) => res.text())
144-
.then((html) => {
145-
if (isMounted.current) {
146-
try {
147-
const parsedData = parseHTML(html, url);
148-
setMetadata(parsedData); // OG 메타 태그 파싱한 결과를 상태로 저장
149-
} catch (parseError) {
150-
setError(handleApiError(parseError));
151-
setMetadata(null);
152-
}
153-
setLoading(false);
154-
}
155-
})
156-
.catch((err) => {
157-
setError(handleApiError(err));
158-
if (isMounted.current) {
159-
setMetadata(null);
160-
setLoading(false);
161-
}
162-
});
163-
}
68+
const doFetch = async () => {
69+
try {
70+
const res = fetcher ? await fetcher(endpoint) : await defaultFetcher(endpoint);
71+
if (!isMounted.current) return;
72+
setMetadata(isValidResponse(res) ? res : null);
73+
} catch (err) {
74+
if (!isMounted.current) return;
75+
setError(handleApiError(err));
76+
setMetadata(null);
77+
} finally {
78+
if (isMounted.current) setLoading(false);
79+
}
80+
};
81+
82+
doFetch();
16483

16584
return () => {
16685
isMounted.current = false;
@@ -169,3 +88,20 @@ export const useLinkPreview = (
16988

17089
return { metadata, loading, error };
17190
};
91+
92+
/* ------------------------------------------------------------------ */
93+
/* 기본 fetcher – serverless JSON */
94+
/* ------------------------------------------------------------------ */
95+
const defaultFetcher = async (endpoint: string): Promise<APIResponse | null> => {
96+
const res = await fetch(endpoint, { credentials: "omit" });
97+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
98+
const data: APIResponse = await res.json();
99+
100+
// hostname 보완(없을 경우)
101+
if (!data.hostname) {
102+
try {
103+
data.hostname = new URL(endpoint.split("url=")[1]).hostname;
104+
} catch {/* ignore */}
105+
}
106+
return data;
107+
};

apps/website/src/layouts/DetailLayout/ProjectDetailLayout.styled.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export const Introduction = styled.p<LayoutProps>`
151151
text-overflow: ellipsis;
152152
white-space: nowrap;
153153
154-
width: 50%;
154+
width: 66%;
155155
max-width: 800px;
156156
`;
157157

apps/website/src/layouts/DetailLayout/ProjectDetailLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as S from "./ProjectDetailLayout.styled";
22
import GoBackArrow from "@/assets/LeftArrow.png";
33
import useMediaQueries from "@/hooks/useMediaQueries";
44
import GithubIcon from "@/assets/githubLogo.png";
5-
import DeploymentIcon from "@/assets/black_link.png";
5+
import DeploymentIcon from "@/assets/Icons/deploy_Icon.png";
66
import ResourceIcon from "@/assets/pink_link.png";
77

88
interface ProjectDetailLayoutProps {

apps/website/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"files": [],
33
"references": [
44
{ "path": "./tsconfig.app.json" },
5-
{ "path": "./tsconfig.node.json" }
5+
{ "path": "./tsconfig.node.json" },
6+
{ "path": "./tsconfig.server.json" }
67
]
78
}

apps/website/tsconfig.server.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"extends": "./tsconfig.json", // 기존 웹사이트 tsconfig가 있다면 이어받기
3+
"compilerOptions": {
4+
"module": "NodeNext", // ESM + CJS 자동 판별
5+
"moduleResolution": "NodeNext",
6+
"target": "ES2022",
7+
"outDir": "dist", // 선택
8+
"declaration": false
9+
},
10+
"include": ["api/**/*.ts", "api/og.js"]
11+
}
12+

apps/website/vercel.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
{
22
"rewrites": [
3+
{
4+
"source": "/api/og",
5+
"destination": "/api/$1"
6+
},
37
{
48
"source": "/(.*)",
59
"destination": "/index.html"

0 commit comments

Comments
 (0)