[fix] site,deploy: 발행본 목록의 정본 주소를 /s 로 — 끝 슬래시 제거
`/s` 는 nginx `location ^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어진다. 그래서 404 가 아니라 **빌더 SPA 셸이 200 으로** 나가고 있었다 — 실측 2026-09-08: `/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록. 404 도 목록도 아닌 세 번째 페이지가 오리진에 있었던 셈이다. 목록만 슬래시가 붙어 있던 이유도 이것 하나였다. 색인 요청·사이트맵이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한 표준 태그가 있음)" 로 분류한다 — 슬러그 쪽에서 이미 밟은 함정인데(prerender.ts 주석) 목록만 반대 형태로 남아 있었다. - nginx/site.conf.example: `location = /s` 로 목록 index.html 직접 서빙, `/s/` 는 301. `^~ /s/` 의 `index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다 - 같은 파일: `absolute_redirect off`. TLS 를 앞단 Apache 가 끊어 nginx 의 `$scheme` 는 늘 `http` 다 — 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려간다 - prerender.ts: `indexUrl` 의 `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가 이 값 하나를 쓴다 - directory.ts · AGENTS.md · DEVLOG.md: 슬래시 규칙과 근거 갱신 검증: `nginx -t` 통과 · `tsc --noEmit` 통과 · 컨테이너 실측 `/s`→200 목록 · `/s/`→301 `Location: /s`(상대) · `/s/<slug>`→200 · `/s/<slug>/`→200 · `/nope`→200 앱 셸(변화 없음) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0129XqVdjDk9JmMNFAepBJvs
This commit is contained in:
parent
9f16c3224b
commit
f2dad65792
11
AGENTS.md
11
AGENTS.md
@ -73,8 +73,15 @@
|
||||
- **`AZURE_STORAGE_CONTAINER=$web`** — 셸에서 export 할 땐 반드시 작은따옴표(`'$web'`).
|
||||
- **슬러그 규칙은 두 곳에 있고 같아야 한다**: `site_payload.publish_slug()` ↔
|
||||
`solution/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다.
|
||||
- **디렉토리 요청 → `index.html`.** `/s/<slug>` 가 **끝 슬래시 없이** 열려야 한다.
|
||||
정적 서버를 바꾸든 nginx 설정을 만지든 이 규칙부터 확인한다.
|
||||
- **★ 발행본 주소는 끝 슬래시가 없다 — 목록 페이지 `/s` 도 마찬가지다.**
|
||||
canonical · 사이트맵 · llms.txt · 서치콘솔 색인 요청이 전부 이 형태여야 한다. 어긋나면
|
||||
구글이 제출분을 **"대체 페이지(적절한 표준 태그가 있음)"** 로 분류한다 — 색인은 되는데
|
||||
제출 URL 은 0건으로 보이는, 눈으로 원인을 못 찾는 종류다.
|
||||
→ nginx 는 `location = /s` 로 목록 index.html 을 **직접** 주고 `/s/` 는 거기로 301 한다.
|
||||
이 블록을 지우면 `/s` 가 맨 아래 `location /` 로 떨어져 **빌더 SPA 셸이 200 으로 나간다**
|
||||
— 404 도 목록도 아닌 세 번째 페이지가 크롤러에 잡힌다(실측 2026-09-08).
|
||||
→ 리다이렉트는 `absolute_redirect off` 로 **상대 Location** 이어야 한다. TLS 를 앞단
|
||||
Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다.
|
||||
|
||||
## 코드 규약
|
||||
|
||||
|
||||
@ -5,6 +5,37 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다
|
||||
|
||||
**무슨 일**
|
||||
사이트맵에서 끝 슬래시가 붙은 줄이 무엇이냐는 물음에서 시작했다. 슬러그 페이지
|
||||
(`/s/<slug>`)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(`/`)와 목록 페이지(`/s/`) 둘뿐이다.
|
||||
목록만 형태가 다른 이유는 nginx 였다 — `location ^~ /s/` 는 **슬래시로 시작하는 것만** 잡고,
|
||||
`/s` 는 맨 아래 `location /` 로 떨어진다.
|
||||
|
||||
**그런데 그게 404 가 아니었다.** `/s` 는 200 을 주고 있었고 내용이 **빌더 SPA 셸**이다
|
||||
(실측: `/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록). 크롤러 입장에서는 404 도
|
||||
목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다.
|
||||
|
||||
**바꾼 것**
|
||||
- `nginx/site.conf(.example)`: `location = /s` 로 목록 index.html 을 직접 준다. `/s/` 는
|
||||
거기로 301. `^~ /s/` 의 `index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다
|
||||
- `absolute_redirect off`: TLS 를 앞단 Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다.
|
||||
기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려가는 301 이 나간다
|
||||
- `prerender.ts` `indexUrl`: `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가 이 값 하나를
|
||||
쓰므로 전부 같이 따라온다
|
||||
|
||||
**왜 형태를 맞추나**
|
||||
색인 요청·사이트맵 URL 이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한 표준
|
||||
태그가 있음)" 로 분류한다 — 색인은 되는데 제출 URL 은 0건으로 보인다. 슬러그 쪽에서 한 번
|
||||
밟은 함정이고(`prerender.ts` 주석), 목록만 반대 형태로 남아 있었다.
|
||||
|
||||
**남은 것**
|
||||
`/s/<slug>/` 는 여전히 200 이다(canonical 로만 접힌다). 목록과 달리 사이트맵에 없어서
|
||||
크롤러가 스스로 만들어낼 주소는 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다
|
||||
|
||||
**무슨 일**
|
||||
|
||||
@ -44,10 +44,30 @@ server {
|
||||
image/svg+xml;
|
||||
|
||||
# ── 발행 사이트 ────────────────────────────────────────────
|
||||
# ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
|
||||
# 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
|
||||
# `/s/` 를 접으면 https 페이지가 http 로 내려가는 리다이렉트가 나간다.
|
||||
absolute_redirect off;
|
||||
|
||||
# ★ 발행본 목록의 정본 주소는 **`/s`** 다 — 슬러그 페이지(`/s/<slug>`)와 형태를 맞춘다.
|
||||
# 이 블록이 없으면 `/s` 는 `^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어지고
|
||||
# **빌더 SPA 셸이 200 으로 나간다.** 404 도 목록도 아닌 세 번째 페이지가 크롤러에
|
||||
# 잡힌다(실측 2026-09-08: `/s` 3.1KB 앱 셸 · `/s/` 6.7KB 목록).
|
||||
location = /s {
|
||||
root /srv/sites;
|
||||
try_files /s/index.html =404;
|
||||
add_header Cache-Control "public, max-age=300, must-revalidate";
|
||||
}
|
||||
|
||||
# 옛 주소. 사이트맵·서치콘솔에 `/s/` 로 제출된 것이 남아 있다.
|
||||
location = /s/ {
|
||||
return 301 /s;
|
||||
}
|
||||
|
||||
# ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다.
|
||||
location ^~ /s/ {
|
||||
# ★ `/s/` 자체(발행본 목록 페이지)를 위해 필요하다. try_files 의 첫 인자 `$uri` 가
|
||||
# 끝 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
|
||||
# ★ `/s/<slug>/`(끝 슬래시) 를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 끝
|
||||
# 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
|
||||
# index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다).
|
||||
index index.html;
|
||||
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
|
||||
|
||||
@ -320,8 +320,20 @@ export function Step3DataReview() {
|
||||
가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면
|
||||
주소 붙여넣기와 자동 찾기가 모두 열립니다.
|
||||
</p>
|
||||
{/* ★ 이 길로 나가면 **발행까지 못 간다** — 서버에 사업장이 없어서 구울 대상이
|
||||
없고, 발행 모달은 [내 가게 확인하러 가기] 만 내놓는다(PublishModal
|
||||
PlaceFirstPanel). 예전에는 아무 말 없이 통과시켜서, 사장님은 사이트를 다
|
||||
만든 뒤 발행을 누르는 자리에서야 그 사실을 알았다.
|
||||
버튼은 남긴다 — 검증을 못 통과한 분이 화면을 구경할 길까지 막지는 않는다. */}
|
||||
<p className="rounded-xl border border-border bg-muted/40 p-3 text-[11px] leading-relaxed text-muted-foreground">
|
||||
<strong className="text-foreground">
|
||||
이대로 진행하면 화면은 만들 수 있지만 발행은 되지 않습니다.
|
||||
</strong>{' '}
|
||||
발행본은 확인된 가게 한 곳에 묶여서, 가게 확인 전에는 만들어 둘 페이지가
|
||||
없습니다. 둘러보신 뒤 언제든 가게를 확인하시면 그때 발행이 열립니다.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => goToStep('template')}>
|
||||
<span>수집 없이 다음 단계로</span>
|
||||
<span>발행 없이 화면만 둘러보기</span>
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@ -1,21 +1,29 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {useNavigate} from 'react-router';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ServerCrash,
|
||||
ShieldAlert,
|
||||
Store,
|
||||
} from 'lucide-react';
|
||||
import {publishUrlString, toSlug} from '@o2o/shared';
|
||||
import {getAccessToken} from '@/api';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Dialog} from '@/components/ui/dialog';
|
||||
import {SignInForm} from '@/features/auth/SignInForm';
|
||||
import type {WizardStep} from '@/features/onboarding/wizardUrl';
|
||||
import {notify, notifyApiError} from '@/lib/notify';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {useAuthStore} from '@/stores/auth';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {runPublishGate, type GateFinding} from './publishGate';
|
||||
import {checkSiteSlug, reserveSiteSlug} from './siteSlug';
|
||||
@ -34,6 +42,27 @@ import {
|
||||
// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
|
||||
// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
|
||||
|
||||
/**
|
||||
* 가게 확인이 안 끝난 사장님을 돌려보낼 곳.
|
||||
*
|
||||
* ★ 단계 이름은 wizardUrl 이 소유한다 — 문자열을 그냥 박아 두면 이름이 바뀌는 날
|
||||
* 주소창의 step 을 아무도 못 알아보고 조용히 첫 화면이 열린다(defaultStep).
|
||||
*/
|
||||
const PLACE_SEARCH_STEP: WizardStep = 'search';
|
||||
const PLACE_SEARCH_URL = `/builder?step=${PLACE_SEARCH_STEP}`;
|
||||
|
||||
/**
|
||||
* 발행을 **시작조차 할 수 없는** 상태. 서버를 부를 수 없으므로 발행 버튼을 그리지 않는다.
|
||||
*
|
||||
* ★ 예전에는 이 두 경우가 [지금 발행하기] 로 그대로 흘러 들어와, 서버를 한 번도 부르지 않고
|
||||
* 성공 토스트를 띄운 뒤 [사이트 열기] 까지 그렸다. 그 주소는 아무것도 굽지 않은 주소라
|
||||
* 404 였고 내 사이트 목록에도 없었다 — 사장님만 발행됐다고 믿는 상태가 남았다.
|
||||
* ★ 'place' 가 진짜 함정이다. 로그인은 했는데(에디터 관문을 지났다) 3단계의
|
||||
* [발행 없이 화면만 둘러보기] 로 나오면 서버에 사업장이 없다 — 화면은 끝까지 도는데
|
||||
* 발행만 안 된다.
|
||||
*/
|
||||
type PublishBlocker = 'signin' | 'place';
|
||||
|
||||
export function PublishModal() {
|
||||
const isOpen = useBuilderStore((s) => s.isPublishModalOpen);
|
||||
const close = useBuilderStore((s) => s.closePublishModal);
|
||||
@ -57,11 +86,28 @@ export function PublishModal() {
|
||||
|
||||
/**
|
||||
* 실사업장이면 진짜 빌드를 태운다(`POST /site/build {publish:true}` → 잡 폴링).
|
||||
* ★ placeId 가 없는 데모 경로는 예전처럼 주소만 확정한다 — 없는 사업장을 서버에 빌드시킬 수 없다.
|
||||
* ★ 그럴 수 없는 상태(로그인 전 · 사업장 미확정)에서는 발행 버튼 자체를 그리지 않는다.
|
||||
* PublishBlocker 주석 참고.
|
||||
*/
|
||||
const publisher = usePublishSite(placeId);
|
||||
const {state, reset} = publisher;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
/*
|
||||
* ★ 토큰은 스토어 밖(custom-fetch)에 있어 구독할 수 없다. 로그인이 auth 스토어의 user 도
|
||||
* 함께 심으므로(lib/session.establishSession) 그걸 **재렌더 신호로만** 구독한다 —
|
||||
* 없으면 아래 로그인 폼으로 로그인을 마쳐도 모달은 계속 "로그인해 주세요" 로 남는다.
|
||||
*/
|
||||
useAuthStore((s) => s.user);
|
||||
|
||||
/** 판정 기준은 usePublishSite.isLive 와 같다(placeId + 토큰) — 갈리면 버튼과 실제가 어긋난다. */
|
||||
const blocker: PublishBlocker | null = publisher.isLive
|
||||
? null
|
||||
: getAccessToken()
|
||||
? 'place'
|
||||
: 'signin';
|
||||
|
||||
const gate = useMemo(
|
||||
() =>
|
||||
runPublishGate({
|
||||
@ -110,7 +156,12 @@ export function PublishModal() {
|
||||
if (!isOpen) reset();
|
||||
}, [isOpen, reset]);
|
||||
|
||||
const isDone = state.phase === 'published' || (!publisher.isLive && Boolean(publishedUrl));
|
||||
/**
|
||||
* ★ 서버가 발행을 확정한 것만 '발행됨'이다.
|
||||
* 예전에는 `!isLive && publishedUrl` 도 완료로 쳤는데, 그 publishedUrl 을 채우던 것이
|
||||
* 서버를 한 번도 부르지 않는 가짜 경로였다 — 굽지도 않은 주소에 [사이트 열기] 가 붙었다.
|
||||
*/
|
||||
const isDone = state.phase === 'published';
|
||||
|
||||
/**
|
||||
* 이미 한 번 발행한 사이트인가 — 문구를 '발행'과 '재발행'으로 가른다.
|
||||
@ -149,9 +200,10 @@ export function PublishModal() {
|
||||
}, [slug, placeId]);
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!gate.canPublish || publisher.isPublishing || !slugReady) return;
|
||||
// ★ blocker 가 있으면 이 버튼은 그려지지도 않는다. 그래도 한 번 더 막는다 —
|
||||
// 서버를 못 부르는 상태로 여기를 지나가는 것이 곧 '가짜 발행'이다.
|
||||
if (blocker || !gate.canPublish || publisher.isPublishing || !slugReady) return;
|
||||
|
||||
if (publisher.isLive) {
|
||||
// ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고,
|
||||
// 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다.
|
||||
if (!isSlugLocked) {
|
||||
@ -164,10 +216,6 @@ export function PublishModal() {
|
||||
}
|
||||
// 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다.
|
||||
publisher.publish();
|
||||
return;
|
||||
}
|
||||
setPublishedUrl(url);
|
||||
notify.success('발행 준비가 끝났습니다', '확인된 정보만 담긴 정적 페이지가 생성됩니다.');
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
@ -184,9 +232,23 @@ export function PublishModal() {
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={close}
|
||||
title={isDone ? '발행 완료' : isRepublish ? '재발행 전 점검' : '발행 전 점검'}
|
||||
title={
|
||||
blocker === 'signin'
|
||||
? '지금 화면은 미리보기입니다'
|
||||
: blocker === 'place'
|
||||
? '가게 확인이 먼저입니다'
|
||||
: isDone
|
||||
? '발행 완료'
|
||||
: isRepublish
|
||||
? '재발행 전 점검'
|
||||
: '발행 전 점검'
|
||||
}
|
||||
description={
|
||||
isDone
|
||||
blocker === 'signin'
|
||||
? '만드신 화면은 아직 어디에도 올라가 있지 않습니다.'
|
||||
: blocker === 'place'
|
||||
? '발행본은 확인된 가게 한 곳에 묶입니다.'
|
||||
: isDone
|
||||
? '확인된 정보만 담긴 정적 페이지가 생성되었습니다.'
|
||||
: '검색·AI 노출에 직접 영향을 주는 항목부터 확인합니다.'
|
||||
}
|
||||
@ -197,7 +259,23 @@ export function PublishModal() {
|
||||
계속 편집하기
|
||||
</Button>
|
||||
|
||||
{isDone ? (
|
||||
{/* ★ 로그인 전에는 여기에 아무 버튼도 두지 않는다 — 다음 행동(로그인)은 본문의
|
||||
폼이고, 옆에 [발행하기] 를 세워 두면 누를 수 있는 것처럼 보인다. */}
|
||||
{blocker === 'signin' ? null : blocker === 'place' ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
// 모달을 먼저 닫는다 — 같은 화면(BuilderPage) 안에서 단계만 바뀌는 이동이라
|
||||
// 열어 둔 채로 가면 가게 찾기 위에 이 모달이 그대로 떠 있는다.
|
||||
close();
|
||||
navigate(PLACE_SEARCH_URL);
|
||||
}}
|
||||
>
|
||||
<Search />
|
||||
<span>내 가게 확인하러 가기</span>
|
||||
</Button>
|
||||
) : isDone ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
@ -236,8 +314,11 @@ export function PublishModal() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{blocker === 'signin' && <SignInFirstPanel />}
|
||||
{blocker === 'place' && <PlaceFirstPanel />}
|
||||
|
||||
{/* 서버가 보는 현재 상태. 편집 중 상태만 보고 판단하지 않게 맨 위에 둔다. */}
|
||||
{publisher.isLive && !isDone && (
|
||||
{!blocker && !isDone && (
|
||||
<SiteStatusRow
|
||||
version={publisher.currentVersion?.version}
|
||||
needsRebuild={publisher.needsRebuild}
|
||||
@ -245,8 +326,10 @@ export function PublishModal() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다. */}
|
||||
{!isDone && (
|
||||
{/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다.
|
||||
★ blocker 상태에서는 그리지 않는다. 주소 중복 확인이 사업장·토큰을 요구하므로
|
||||
입력칸만 열어 두면 확인 버튼이 매번 실패한다. */}
|
||||
{!blocker && !isDone && (
|
||||
<SlugField
|
||||
value={slug}
|
||||
host={PUBLISH_HOST}
|
||||
@ -261,7 +344,7 @@ export function PublishModal() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isDone && gate.blockers.length > 0 && (
|
||||
{!blocker && !isDone && gate.blockers.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-bold text-destructive">
|
||||
<ShieldAlert className="size-3.5" />
|
||||
@ -273,7 +356,7 @@ export function PublishModal() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isDone && gate.warnings.length > 0 && (
|
||||
{!blocker && !isDone && gate.warnings.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-bold text-warning">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
@ -285,7 +368,7 @@ export function PublishModal() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isDone && gate.findings.length === 0 && state.phase === 'idle' && (
|
||||
{!blocker && !isDone && gate.findings.length === 0 && state.phase === 'idle' && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-success/30 bg-success/8 p-3 text-xs text-success">
|
||||
<Check className="size-4 shrink-0" />
|
||||
<span>
|
||||
@ -319,6 +402,71 @@ export function PublishModal() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 전 — **미리보기라고 말하고, 그 자리에서 로그인을 받는다.**
|
||||
*
|
||||
* ★ /login 으로 튕기지 않는다. 위저드·에디터 상태는 브라우저에 저장하지 않으므로
|
||||
* (stores/builder 주석) 화면을 떠나는 순간 지금까지 만든 것이 통째로 사라진다.
|
||||
* 에디터 관문(EditorSignInGate)이 같은 이유로 같은 폼을 그 자리에 놓는다.
|
||||
* ★ 로그인이 끝나면 폼이 심은 토큰으로 blocker 가 저절로 다시 계산된다 — 이 패널이 사라지고
|
||||
* 발행 점검 화면이 뜬다(사업장이 아직 없으면 아래 PlaceFirstPanel 로 넘어간다).
|
||||
*/
|
||||
function SignInFirstPanel() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs">
|
||||
<div className="mb-1 flex items-center gap-1.5 font-bold text-warning">
|
||||
<Eye className="size-3.5" />
|
||||
<span>여기까지는 미리보기입니다</span>
|
||||
</div>
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
발행하면 검색·AI 가 읽는 정적 페이지(HTML)가 구워집니다. 그 페이지는 계정과 실제
|
||||
가게에 묶이므로, 로그인과 내 가게 확인이 끝나야 시작할 수 있습니다.
|
||||
</p>
|
||||
<p className="mt-1.5 text-[11px] font-medium">
|
||||
→ 여기서 로그인하시면 지금까지 만든 내용은 그대로 둔 채 이어집니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<SignInForm
|
||||
submitLabel="로그인하고 발행 준비"
|
||||
header={
|
||||
<p className="text-center text-xs leading-relaxed text-muted-foreground">
|
||||
발행본은 나중에 고치고 내릴 수 있어야 해서 계정에 묶어 둡니다.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인은 했는데 서버에 사업장이 없다 — 3단계에서 수집을 건너뛰고 나온 경로다.
|
||||
*
|
||||
* ★ 여기서 사업장을 몰래 만들지 않는다. 생성과 네이버 검증의 순서는
|
||||
* features/onboarding/ensureServerPlace 한 곳이 소유한다 — 검증을 건너뛰고 만든 사업장은
|
||||
* 수집도 발행도 못 하는 껍데기로 남고, 그 사실은 화면에 안 나온다.
|
||||
*/
|
||||
function PlaceFirstPanel() {
|
||||
return (
|
||||
<div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs">
|
||||
<div className="mb-1 flex items-center gap-1.5 font-bold text-warning">
|
||||
<Store className="size-3.5" />
|
||||
<span>내 가게가 아직 확인되지 않았습니다</span>
|
||||
</div>
|
||||
<p className="leading-relaxed text-muted-foreground">
|
||||
발행본은 실제 가게 한 곳에 묶입니다 — 주소·전화·영업시간이 그 가게의 것이라는 근거가
|
||||
있어야 검색·AI 가 이 페이지를 출처로 씁니다. 직접 입력으로만 진행하셨다면 그 가게가
|
||||
아직 서버에 없어서, 발행을 눌러도 구울 대상이 없습니다.
|
||||
</p>
|
||||
<p className="mt-1.5 text-[11px] font-medium">
|
||||
→ 상호로 내 가게를 찾아 확인하시면 그때부터 발행이 열립니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 서버가 보는 사이트 상태 한 줄. 편집 중 상태(gate)와 저장된 상태를 구분해 준다. */
|
||||
/**
|
||||
* 서버가 보는 현재 발행 상태 한 줄.
|
||||
|
||||
@ -690,10 +690,12 @@ function writeRootMachineFiles(outRoot: string, origin: string) {
|
||||
})
|
||||
.sort((a, b) => a.loc.localeCompare(b.loc));
|
||||
|
||||
// ★ `/s/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
|
||||
// ★ `/s` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
|
||||
// 사이트맵만 있을 때 서치콘솔은 "참조 페이지: 감지된 페이지 없음" 이라고 답했다.
|
||||
// ★ 주소에 끝 슬래시가 있어야 한다 — nginx 의 `location ^~ /s/` 가 슬래시로만 잡는다.
|
||||
const indexUrl = joinUrl(origin, SITE_DIR) + '/';
|
||||
// ★ 끝 슬래시를 붙이지 않는다 — 슬러그 페이지(`/s/<slug>`)와 같은 형태여야 한다.
|
||||
// nginx 가 `location = /s` 로 이 파일을 직접 주고 `/s/` 는 여기로 301 한다
|
||||
// (nginx/site.conf). 그 블록이 없으면 `/s` 는 빌더 SPA 셸을 200 으로 내준다.
|
||||
const indexUrl = joinUrl(origin, SITE_DIR);
|
||||
writeFileSync(join(sitesDir, 'index.html'), renderSiteIndex(origin, indexUrl, sites), 'utf-8');
|
||||
|
||||
// 사이트맵에는 랜딩·목록 페이지도 담는다. 랜딩은 이 호스트의 첫 페이지이고,
|
||||
|
||||
@ -55,8 +55,8 @@ export function readBakedLastmod(html: string): string | undefined {
|
||||
*/
|
||||
export function renderSiteIndex(
|
||||
origin: string,
|
||||
/** 목록 페이지 자신의 주소. **끝 슬래시가 있어야 한다** — nginx 가 `^~ /s/` 로만 발행본
|
||||
* 디렉토리를 잡고, 슬래시 없는 `/s` 는 사장님 앱(SPA)으로 떨어진다(nginx/site.conf). */
|
||||
/** 목록 페이지 자신의 주소(`<origin>/s`). 발행본 주소와 **같은 형태**여야 한다 —
|
||||
* 끝 슬래시 없음. nginx 가 `location = /s` 로 이 파일을 직접 준다(nginx/site.conf). */
|
||||
indexUrl: string,
|
||||
entries: DirectoryEntry[],
|
||||
): string {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user