실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`<a>` 0개였다. 같은 호스트의
발행본은 48,072바이트다. 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규
도메인은 뒤로 밀린다 — 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다.
서치콘솔이 "URL이 Google에 등록되어 있음"이라고 답하면서도 브랜드명 검색에조차 안
걸리던 이유다.
스크립트를 새로 짜지 않았다. react-router 7.17 에 프리렌더가 내장돼 있고
`ssr: false` 와 함께 쓰면 런타임 Node 서버 없이 지정한 경로만 HTML 로 굽는다 —
나머지는 지금까지처럼 SPA 폴백이다. 배포 구조가 그대로다.
- react-router.config.ts: `ssr:false` + `prerender: ['/', '/pricing', '/showcase']`.
로그인 뒤에만 의미가 있는 화면은 굽지 않는다(구울 내용이 사용자별이다)
- src/root.tsx · src/routes.ts: 예전 index.html + app/router.tsx 가 하던 일.
가드는 페이지마다 감싸지 않고 RequireAuthLayout 레이아웃 라우트 하나로 모았다
- 랜딩·요금·사례에 meta export: 제목을 브랜드가 아니라 **검색어**로 시작하게 바꿨다.
예전 제목("Web4Ai · AI 웹 빌더")에는 사람이 치는 말이 한 단어도 없었다.
랜딩에 Organization JSON-LD 추가 — 발행본에는 있는데 정작 랜딩엔 없었다
- src/lib/site.ts: 발행 호스트의 단일 출처. 모듈 최상위의 `window.location` 폴백을
전부 걷었다 — 서버 번들은 라우트를 한 파일로 묶어서 프리렌더 대상이 아닌 화면의
최상위 코드도 빌드 때 실행된다(실측: BuilderPage 에서 빌드가 죽었다)
- LoginPage: homePath 기본값 `/` → `/sites`. 예전엔 router.tsx 가 넘기던 값이라
라우트 모듈로 옮기면서 그대로 두면 로그인 후 랜딩으로 갔다
- nginx: SPA 폴백을 `/index.html` → `/__spa-fallback.html`. 프리렌더 뒤로
`/index.html` 은 **랜딩이 구워진 파일**이라, 그리로 넘기면 `/builder` 에 랜딩
HTML 이 내려가고 클라이언트가 다른 주소로 하이드레이트한다
- nginx/Dockerfile: 산출물이 `dist` → `build/client`. 경로가 어긋나면 COPY 가
조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다
- site/seo/robots.ts: `/builder` `/login` `/signup` `/sites` `/account` Disallow.
이 경로들은 빈 SPA 폴백을 받는다 — 긁히면 호스트 전체에 저품질 신호가 쌓인다
검증: tsc·eslint·react-router build 통과.
랜딩 3,021B → 21,799B, 본문 1,278자, 링크 6개.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
83 lines
3.2 KiB
TypeScript
83 lines
3.2 KiB
TypeScript
import {isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration} from 'react-router';
|
|
import type {LinksFunction, MetaFunction} from 'react-router';
|
|
import {Providers} from '@/app/provider';
|
|
import './index.css';
|
|
|
|
/**
|
|
* 문서 껍데기. 예전 `index.html` 이 하던 일을 여기서 한다 —
|
|
* 프레임워크 모드에는 index.html 이 없고, 이 컴포넌트가 <html> 부터 그린다.
|
|
*
|
|
* ★ 발행 호스트를 여기 문자열로 적지 않는다. `VITE_PUBLISH_HOST` 는 compose 가 루트의
|
|
* `SITE_PUBLIC_HOST` 를 흘려보내는 값이다 — 두 곳에 적으면 canonical 과 화면 주소가
|
|
* 조용히 갈라진다(AGENTS.md).
|
|
*/
|
|
const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai';
|
|
export const ORIGIN = `https://${PUBLISH_HOST}`;
|
|
|
|
export const links: LinksFunction = () => [
|
|
{rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon-w4a.svg?v=14'},
|
|
// self-host Pretendard 가 어떤 환경에서든 못 뜰 때 한글이 굴림/맑은고딕으로 떨어지지 않게 하는 안전망
|
|
{rel: 'preconnect', href: 'https://fonts.googleapis.com'},
|
|
{rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous'},
|
|
{
|
|
rel: 'stylesheet',
|
|
href: 'https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&family=Noto+Serif+KR:wght@400;600;700&display=swap',
|
|
},
|
|
// 7080 아이템(가요 다방·일력·승차권) 전용. 이 셋이 없으면 간판/명조가 고딕으로 떨어져 감성이 사라진다
|
|
{
|
|
rel: 'stylesheet',
|
|
href: 'https://fonts.googleapis.com/css2?family=Gugi&family=Gowun+Batang:wght@400;700&family=Nanum+Pen+Script&display=swap',
|
|
},
|
|
];
|
|
|
|
/** 라우트가 자기 것을 안 내놓을 때의 기본값. 각 페이지는 `meta` 를 export 해 덮어쓴다. */
|
|
export const meta: MetaFunction = () => [
|
|
{title: 'Web4Ai'},
|
|
{property: 'og:site_name', content: 'Web4Ai'},
|
|
{property: 'og:locale', content: 'ko_KR'},
|
|
{property: 'og:type', content: 'website'},
|
|
{name: 'twitter:card', content: 'summary_large_image'},
|
|
];
|
|
|
|
export function Layout({children}: {children: React.ReactNode}) {
|
|
return (
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charSet="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
<meta name="theme-color" content="#1463ff" />
|
|
<Meta />
|
|
<Links />
|
|
</head>
|
|
<body>
|
|
{children}
|
|
<ScrollRestoration />
|
|
<Scripts />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
export default function Root() {
|
|
return (
|
|
<Providers>
|
|
<Outlet />
|
|
</Providers>
|
|
);
|
|
}
|
|
|
|
export function ErrorBoundary({error}: {error: unknown}) {
|
|
const is404 = isRouteErrorResponse(error) && error.status === 404;
|
|
return (
|
|
<div className="flex min-h-screen flex-col items-center justify-center gap-3 px-6 text-center">
|
|
<p className="text-5xl font-extrabold tracking-tight">{is404 ? '404' : '오류'}</p>
|
|
<p className="text-muted-foreground">
|
|
{is404 ? '없는 주소입니다.' : '화면을 그리는 중 문제가 생겼습니다.'}
|
|
</p>
|
|
<a href="/" className="underline underline-offset-4">
|
|
홈으로
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|