주신 32x32 PNG 를 브랜드 자산으로 넣고 앱과 발행본이 같이 쓴다. SVG 는 뒤에 남겨 둔다 — PNG 를 못 읽는 자리는 없지만, 고해상도 탭에서는 SVG 가 낫다. 브라우저는 앞의 것부터 보고 처리할 수 있는 것을 고른다. - public/brand/favicon-w4a.png (신규, 32x32) - site/seo/head.ts · frontend/root.tsx: PNG 를 먼저, SVG 를 뒤에 검증: site vitest 86건 통과 · tsc(site·frontend) 통과. 발행본 반영에는 전체 재굽기가 필요하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
259 lines
13 KiB
TypeScript
259 lines
13 KiB
TypeScript
import {deriveSurfaces, SiteStatus, type SitePayload} from '@o2o/shared';
|
|
import {collectJsonLd} from './jsonld';
|
|
import type {PageMeta} from './meta';
|
|
|
|
/**
|
|
* <head> 를 문자열로 만든다.
|
|
*
|
|
* 리액트 컴포넌트가 아니라 문자열인 이유: 프리렌더가 정적 HTML 파일을 직접 쓰기 때문이다.
|
|
* 헬멧류 라이브러리를 끼우면 하이드레이션 이후에 head 가 바뀌는데,
|
|
* 그 시점엔 크롤러가 이미 원본 HTML 을 읽고 떠난 뒤다.
|
|
*/
|
|
|
|
const ESCAPE_MAP: Record<string, string> = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
};
|
|
|
|
export function escapeHtml(value: string): string {
|
|
return value.replace(/[&<>"']/g, (char) => ESCAPE_MAP[char]);
|
|
}
|
|
|
|
/**
|
|
* JSON-LD 안의 `</script`, `<!--` 은 스크립트 블록을 조기 종료시킨다.
|
|
* HTML 이스케이프가 아니라 유니코드 이스케이프로 막는다 — JSON 값 자체는 그대로 유지된다.
|
|
*/
|
|
function serializeJsonLd(node: unknown): string {
|
|
return JSON.stringify(node)
|
|
.replace(/</g, '\\u003c')
|
|
.replace(/>/g, '\\u003e')
|
|
.replace(/&/g, '\\u0026');
|
|
}
|
|
|
|
function tag(name: string, attrs: Record<string, string | undefined>): string {
|
|
const rendered = Object.entries(attrs)
|
|
.filter(([, value]) => value != null && value !== '')
|
|
.map(([key, value]) => `${key}="${escapeHtml(String(value))}"`)
|
|
.join(' ');
|
|
return ` <${name} ${rendered} />`;
|
|
}
|
|
|
|
export interface HeadOptions {
|
|
payload: SitePayload;
|
|
meta: PageMeta;
|
|
/** 클라이언트 번들 경로(하이드레이션용). manifest 에서 읽어 넘긴다. */
|
|
scriptSrc?: string;
|
|
cssHrefs?: string[];
|
|
}
|
|
|
|
/** 템플릿 색을 CSS 변수로. 사장님이 고른 색이 여기서 실제 페이지 색이 된다. */
|
|
/**
|
|
* 템플릿 토큰을 <head> 에 심는다.
|
|
*
|
|
* ★ 색만 내려보내던 자리다. 그래서 사장님이 레트로(간판체·2px 테두리·갱지)를 골라도
|
|
* 발행 페이지는 늘 같은 고딕으로 나갔다 — 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다.
|
|
* 이제 `theme.look` 의 서체·모서리·테두리·여백까지 같이 심는다.
|
|
* ★ 면 토큰(surface/-alt/inverse/border)은 캔버스와 **같은 식**으로 유도한다(shared/lib/color).
|
|
* 식이 두 벌이면 미리보기와 발행본의 바탕색이 갈린다.
|
|
*/
|
|
/**
|
|
* 템플릿 토큰 — **값**으로 돌려준다.
|
|
*
|
|
* ★ 발행본은 이걸 `<head>` 의 `:root` 에 굽고(themeStyle), 빌더 미리보기는 같은 값을
|
|
* 미리보기 요소의 inline style 로 얹는다(`builder/SitePreview`). 두 곳이 각자 만들면
|
|
* 에디터에서 본 색·서체와 발행된 색·서체가 조용히 갈라진다 — 실제로 그 자리였다.
|
|
*/
|
|
export function themeVars(payload: SitePayload): Record<string, string> {
|
|
const {colors, look} = payload.theme;
|
|
const surfaces = deriveSurfaces(colors);
|
|
const vars: Record<string, string> = {
|
|
'--tpl-primary': colors.primary,
|
|
'--tpl-secondary': colors.secondary,
|
|
'--tpl-bg': colors.bg,
|
|
'--tpl-card': colors.card,
|
|
'--tpl-text': colors.text,
|
|
'--tpl-accent': colors.accent,
|
|
'--tpl-surface': surfaces.surface,
|
|
'--tpl-surface-alt': surfaces.surfaceAlt,
|
|
'--tpl-inverse': surfaces.inverse,
|
|
'--tpl-border': surfaces.border,
|
|
};
|
|
if (look) {
|
|
// ★ 값은 그대로 CSS 에 들어간다. `<`·`}` 이 섞이면 <style> 을 깨뜨릴 수 있어 걸러 낸다 —
|
|
// 서버가 해석하지 않고 실어 보내는 값이라 여기서 한 번은 봐야 한다.
|
|
const safe = (value?: string) => (value && !/[<>{};]/.test(value) ? value : undefined);
|
|
const pairs: [string, string | undefined][] = [
|
|
['--tpl-font-heading', safe(look.fontHeading)],
|
|
['--tpl-font-body', safe(look.fontBody)],
|
|
['--tpl-heading-tracking', safe(look.headingTracking)],
|
|
['--tpl-heading-weight', safe(look.headingWeight)],
|
|
['--tpl-section-space', safe(look.sectionSpace)],
|
|
['--tpl-border-width', safe(look.borderWidth)],
|
|
['--tpl-radius', safe(look.radius)],
|
|
['--tpl-shadow', safe(look.shadow)],
|
|
// ★ 종이 질감. `.paper` 가 이 값을 읽는다 — 없으면 섹션 바탕이 평평한 색면으로만 남는다.
|
|
// 토큰이 비어 있으면 아무것도 깔지 않는다(질감을 원하지 않는 템플릿이 있다).
|
|
['--tpl-texture', safe(look.texture)],
|
|
];
|
|
for (const [name, value] of pairs) if (value) vars[name] = value;
|
|
}
|
|
return vars;
|
|
}
|
|
|
|
export function themeStyle(payload: SitePayload): string {
|
|
const lines = Object.entries(themeVars(payload)).map(([name, value]) => `${name}: ${value};`);
|
|
return [' <style>', ' :root {', ...lines.map((line) => ` ${line}`), ' }', ' </style>'].join('\n');
|
|
}
|
|
|
|
/**
|
|
* 템플릿이 요구하는 웹폰트만 골라 한 번에 받아 온다.
|
|
*
|
|
* ★ 서체 스택 문자열에서 이름을 훑어 아는 것만 붙인다. 전부 항상 실으면 쓰지도 않는 서체가
|
|
* 모든 발행 사이트의 첫 렌더를 늦춘다 — 서체 하나가 그럴 이유가 없다.
|
|
*/
|
|
const WEB_FONTS: [RegExp, string][] = [
|
|
[/Noto Sans KR/i, 'family=Noto+Sans+KR:wght@300..900'],
|
|
[/Noto Serif KR/i, 'family=Noto+Serif+KR:wght@300..700'],
|
|
[/Gugi/i, 'family=Gugi'],
|
|
[/Gowun Batang/i, 'family=Gowun+Batang:wght@400;700'],
|
|
[/Nanum Pen Script/i, 'family=Nanum+Pen+Script'],
|
|
];
|
|
|
|
/** 템플릿이 요구하는 웹폰트 주소. 발행본 <head> 와 빌더 미리보기가 같은 것을 쓴다. */
|
|
export function fontHref(payload: SitePayload): string {
|
|
const stacks = [
|
|
'Noto Sans KR',
|
|
'Noto Serif KR',
|
|
payload.theme.look?.fontHeading ?? '',
|
|
payload.theme.look?.fontBody ?? '',
|
|
].join(' ');
|
|
const families = WEB_FONTS.filter(([pattern]) => pattern.test(stacks)).map(([, param]) => param);
|
|
return `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap`;
|
|
}
|
|
|
|
export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOptions): string {
|
|
const {place, site} = payload;
|
|
// 사이트가 호스트 하나 아래 `/s/<slug>` 로 놓이므로, 정적 파일 링크도 그 아래를 가리켜야 한다.
|
|
const base = site.basePath.replace(/\/+$/, '');
|
|
const jsonLd = collectJsonLd(payload, {title: meta.title, description: meta.description});
|
|
|
|
const lines: string[] = [
|
|
' <meta charset="UTF-8" />',
|
|
' <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />',
|
|
` <title>${escapeHtml(meta.title)}</title>`,
|
|
tag('meta', {name: 'description', content: meta.description}),
|
|
/*
|
|
* SiteOntology 키워드. 구글은 이 태그를 순위에 쓰지 않는다 — 비용이 없어 싣는 자리이고,
|
|
* 순위에 닿는 자리는 위의 title 이다(meta.ts homeTitle).
|
|
* ★ 조건부로 넣는다. tag() 는 빈 **속성**만 빼고 태그는 만든다 — 그대로 두면 키워드가 없는
|
|
* 모든 사이트에 `<meta name="keywords" />` 빈 태그가 박힌다(meta.test.ts 가 잡았다).
|
|
*/
|
|
...(meta.keywords ? [tag('meta', {name: 'keywords', content: meta.keywords})] : []),
|
|
tag('link', {rel: 'canonical', href: meta.canonical}),
|
|
/*
|
|
* ★ **발행된 사이트만** 색인된다.
|
|
*
|
|
* 예전엔 `index, follow` 를 박아 두고 "색인을 막을 이유가 없다"고 적어 뒀는데, 그건
|
|
* 굽는 것이 곧 발행이던 시절의 말이다. 지금은 사장님이 빌더에서 미리보기를 누르면
|
|
* draft 상태로도 구워진다 — 그 결과가 그대로 검색에 나갔다.
|
|
*
|
|
* 실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고
|
|
* 사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가,
|
|
* 짓다 만 상태로 구글에 실려 있었다는 뜻이다.
|
|
*
|
|
* ★ 여기만 고치면 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 —
|
|
* 그쪽은 구운 HTML 의 robots 를 읽어 거른다(seo/directory.ts readBakedNoindex).
|
|
* 두 자리에 규칙을 두지 않기 위해 판정을 이 한 곳에 둔다.
|
|
*/
|
|
tag('meta', {
|
|
name: 'robots',
|
|
content:
|
|
site.status === SiteStatus.PUBLISHED
|
|
? 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'
|
|
: // follow 는 남긴다 — 색인은 막되 링크는 타게 둔다(자산·하위 경로 발견용).
|
|
'noindex, follow',
|
|
}),
|
|
/*
|
|
* ★ 파비콘. 발행본에는 아예 없어서 브라우저 탭에 기본 아이콘이 떴다(실측 2026-09-15).
|
|
* 파일은 오리진 루트의 공용 자산이라 사이트마다 복사하지 않는다 — `basePath` 가 아니라
|
|
* 루트 절대경로로 가리킨다(robots·sitemap 과 같은 자리다).
|
|
*/
|
|
tag('link', {rel: 'icon', type: 'image/png', sizes: '32x32', href: '/brand/favicon-w4a.png'}),
|
|
tag('link', {rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon-w4a.svg'}),
|
|
tag('link', {rel: 'apple-touch-icon', href: '/apple-touch-icon.png'}),
|
|
tag('meta', {'http-equiv': 'content-language', content: 'ko-KR'}),
|
|
/*
|
|
* ★ 노치·상태바를 템플릿 색으로 채운다 (2026-09-09, 사장님: "모바일에서 노치가 투명으로 되던데")
|
|
* `viewport-fit=cover` 로 화면 끝까지 쓰는데 `theme-color` 가 없어서, 아이폰의 상태바
|
|
* 자리와 안드로이드 크롬의 상단 띠가 **브라우저 기본색(흰색·검은색)** 으로 남았다.
|
|
* 갱지 바탕 위에 흰 띠가 얹히면 페이지가 화면에 안 붙은 것처럼 보인다.
|
|
* ★ 색은 헤더가 쓰는 것과 같은 `surface` 다 — 화면 맨 위에 실제로 깔리는 면이 그것이다.
|
|
* `bg` 를 쓰면 헤더와 한 칸 어긋난다.
|
|
* ★ 사이트마다 색이 다르므로 payload 에서 유도한다(themeStyle 과 같은 식).
|
|
*/
|
|
tag('meta', {name: 'theme-color', content: deriveSurfaces(payload.theme.colors).surface}),
|
|
|
|
// 신선도 — AI 검색이 오래된 페이지를 뒤로 미룬다.
|
|
tag('meta', {name: 'dateModified', content: site.updatedAt}),
|
|
tag('meta', {name: 'datePublished', content: site.publishedAt}),
|
|
|
|
// Open Graph
|
|
tag('meta', {property: 'og:type', content: 'website'}),
|
|
tag('meta', {property: 'og:site_name', content: place.name}),
|
|
tag('meta', {property: 'og:locale', content: 'ko_KR'}),
|
|
tag('meta', {property: 'og:title', content: meta.title}),
|
|
tag('meta', {property: 'og:description', content: meta.description}),
|
|
tag('meta', {property: 'og:url', content: meta.canonical}),
|
|
tag('meta', {property: 'og:image', content: meta.ogImage}),
|
|
tag('meta', {property: 'og:image:alt', content: meta.ogImageAlt}),
|
|
|
|
// Twitter
|
|
tag('meta', {name: 'twitter:card', content: meta.ogImage ? 'summary_large_image' : 'summary'}),
|
|
tag('meta', {name: 'twitter:title', content: meta.title}),
|
|
tag('meta', {name: 'twitter:description', content: meta.description}),
|
|
tag('meta', {name: 'twitter:image', content: meta.ogImage}),
|
|
];
|
|
|
|
// 지역 메타 — 네이버·다음이 읽고, 지역 질의 매칭에 쓰인다.
|
|
if (place.latitude != null && place.longitude != null) {
|
|
lines.push(
|
|
tag('meta', {name: 'geo.position', content: `${place.latitude};${place.longitude}`}),
|
|
tag('meta', {name: 'ICBM', content: `${place.latitude}, ${place.longitude}`}),
|
|
);
|
|
}
|
|
// geo.region 은 ISO 3166-2 코드를 기대한다 — 지역명을 그대로 넣으면(`KR-경기`) 아무도 못 읽는다.
|
|
if (place.addressRegionCode) {
|
|
lines.push(tag('meta', {name: 'geo.region', content: place.addressRegionCode}));
|
|
}
|
|
if (place.addressLocality || place.addressRegion) {
|
|
lines.push(tag('meta', {name: 'geo.placename', content: place.name}));
|
|
}
|
|
|
|
lines.push(
|
|
' <link rel="preconnect" href="https://fonts.googleapis.com" />',
|
|
' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
|
|
` <link href="${escapeHtml(fontHref(payload))}" rel="stylesheet" />`,
|
|
// 기계용 파일을 head 에서도 가리킨다 — llms.txt 는 아직 표준이 아니라 링크로 힌트를 준다.
|
|
tag('link', {rel: 'sitemap', type: 'application/xml', href: `${base}/sitemap.xml`}),
|
|
tag('link', {rel: 'alternate', type: 'text/plain', href: `${base}/llms.txt`, title: 'LLM 요약'}),
|
|
);
|
|
|
|
cssHrefs.forEach((href) => lines.push(tag('link', {rel: 'stylesheet', href})));
|
|
lines.push(themeStyle(payload));
|
|
|
|
jsonLd.forEach((node) => {
|
|
lines.push(
|
|
` <script type="application/ld+json">${serializeJsonLd(node)}</script>`,
|
|
);
|
|
});
|
|
|
|
if (scriptSrc) {
|
|
lines.push(` <script type="module" src="${escapeHtml(scriptSrc)}"></script>`);
|
|
}
|
|
|
|
return lines.filter(Boolean).join('\n');
|
|
}
|