import {deriveSurfaces, SiteStatus, type SitePayload} from '@o2o/shared'; import {collectJsonLd} from './jsonld'; import type {PageMeta} from './meta'; /** * 를 문자열로 만든다. * * 리액트 컴포넌트가 아니라 문자열인 이유: 프리렌더가 정적 HTML 파일을 직접 쓰기 때문이다. * 헬멧류 라이브러리를 끼우면 하이드레이션 이후에 head 가 바뀌는데, * 그 시점엔 크롤러가 이미 원본 HTML 을 읽고 떠난 뒤다. */ const ESCAPE_MAP: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; export function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => ESCAPE_MAP[char]); } /** * JSON-LD 안의 `/g, '\\u003e') .replace(/&/g, '\\u0026'); } function tag(name: string, attrs: Record): 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 변수로. 사장님이 고른 색이 여기서 실제 페이지 색이 된다. */ /** * 템플릿 토큰을 에 심는다. * * ★ 색만 내려보내던 자리다. 그래서 사장님이 레트로(간판체·2px 테두리·갱지)를 골라도 * 발행 페이지는 늘 같은 고딕으로 나갔다 — 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다. * 이제 `theme.look` 의 서체·모서리·테두리·여백까지 같이 심는다. * ★ 면 토큰(surface/-alt/inverse/border)은 캔버스와 **같은 식**으로 유도한다(shared/lib/color). * 식이 두 벌이면 미리보기와 발행본의 바탕색이 갈린다. */ /** * 템플릿 토큰 — **값**으로 돌려준다. * * ★ 발행본은 이걸 `` 의 `:root` 에 굽고(themeStyle), 빌더 미리보기는 같은 값을 * 미리보기 요소의 inline style 로 얹는다(`builder/SitePreview`). 두 곳이 각자 만들면 * 에디터에서 본 색·서체와 발행된 색·서체가 조용히 갈라진다 — 실제로 그 자리였다. */ export function themeVars(payload: SitePayload): Record { const {colors, look} = payload.theme; const surfaces = deriveSurfaces(colors); const vars: Record = { '--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 에 들어간다. `<`·`}` 이 섞이면 '].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'], ]; /** 템플릿이 요구하는 웹폰트 주소. 발행본 와 빌더 미리보기가 같은 것을 쓴다. */ 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/` 로 놓이므로, 정적 파일 링크도 그 아래를 가리켜야 한다. const base = site.basePath.replace(/\/+$/, ''); const jsonLd = collectJsonLd(payload, {title: meta.title, description: meta.description}); const lines: string[] = [ ' ', ' ', ` ${escapeHtml(meta.title)}`, tag('meta', {name: 'description', content: meta.description}), /* * SiteOntology 키워드. 구글은 이 태그를 순위에 쓰지 않는다 — 비용이 없어 싣는 자리이고, * 순위에 닿는 자리는 위의 title 이다(meta.ts homeTitle). * ★ 조건부로 넣는다. tag() 는 빈 **속성**만 빼고 태그는 만든다 — 그대로 두면 키워드가 없는 * 모든 사이트에 `` 빈 태그가 박힌다(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( ' ', ' ', ` `, // 기계용 파일을 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( ` `, ); }); if (scriptSrc) { lines.push(` `); } return lines.filter(Boolean).join('\n'); }