diff --git a/.gitignore b/.gitignore
index 8e202b1..2d394da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,9 @@ solution/backend/openapi.json
# 여기 생긴다. 어느 쪽이든 payload 로 다시 굽는 재생성물이라 git 이 관리할 대상이 아니다.
solution/site/out/
solution/site/dist/
+# React Router 프레임워크 모드(solution/frontend)의 산출물
+build/
+.react-router/
admin/dist/
# nginx 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다.
diff --git a/nginx/Dockerfile b/nginx/Dockerfile
index f370b72..0913c69 100644
--- a/nginx/Dockerfile
+++ b/nginx/Dockerfile
@@ -43,4 +43,6 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
RUN npm run build -w @o2o/frontend
FROM nginx:alpine
-COPY --from=build /app/solution/frontend/dist /srv/app
+# ★ 프레임워크 모드(React Router)의 산출물은 `build/client` 다 — 예전 `dist` 가 아니다.
+# 경로가 어긋나면 COPY 가 조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다.
+COPY --from=build /app/solution/frontend/build/client /srv/app
diff --git a/nginx/site.conf.example b/nginx/site.conf.example
index 9f31085..16b79e3 100644
--- a/nginx/site.conf.example
+++ b/nginx/site.conf.example
@@ -121,12 +121,16 @@ server {
try_files $uri =404;
}
- # SPA 다. 없는 경로는 index.html 로 넘겨 클라이언트 라우터가 받게 한다.
- # ★ index.html 은 캐시하지 않는다 — 여기에 번들 해시가 박혀 있어서, 캐시되면
- # 새로 배포해도 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면).
+ # ★ 폴백은 `/index.html` 이 아니라 `__spa-fallback.html` 이다.
+ # 프리렌더를 켠 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이다 — 여기로 넘기면
+ # `/builder` 를 열었는데 랜딩 HTML 이 내려가고, 클라이언트 라우터는 다른 주소로
+ # 하이드레이트한다. 화면은 뜨는데 한 번 깜빡이고 마크업이 어긋나는 종류다.
+ # 구워진 경로(`/` `/pricing` `/showcase`)는 그 앞의 `$uri/index.html` 이 먼저 잡는다.
+ # ★ HTML 은 캐시하지 않는다 — 번들 해시가 박혀 있어서, 캐시되면 새로 배포해도
+ # 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면).
location / {
root /srv/app;
- try_files $uri $uri/index.html /index.html;
+ try_files $uri $uri/index.html /__spa-fallback.html;
add_header Cache-Control "no-cache";
}
diff --git a/package-lock.json b/package-lock.json
index 032438f..d6075fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -215,6 +215,19 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz",
+ "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
@@ -232,6 +245,28 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz",
+ "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-member-expression-to-functions": "^7.29.7",
+ "@babel/helper-optimise-call-expression": "^7.29.7",
+ "@babel/helper-replace-supers": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
@@ -242,6 +277,20 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz",
+ "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
@@ -274,6 +323,19 @@
"@babel/core": "^7.0.0"
}
},
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz",
+ "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-plugin-utils": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
@@ -284,6 +346,38 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz",
+ "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-member-expression-to-functions": "^7.29.7",
+ "@babel/helper-optimise-call-expression": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz",
+ "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -344,6 +438,55 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz",
+ "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-typescript": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz",
+ "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz",
+ "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
@@ -376,6 +519,46 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/plugin-transform-typescript": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
+ "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+ "@babel/plugin-syntax-typescript": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-typescript": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz",
+ "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "@babel/plugin-syntax-jsx": "^7.29.7",
+ "@babel/plugin-transform-modules-commonjs": "^7.29.7",
+ "@babel/plugin-transform-typescript": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1425,6 +1608,12 @@
"jsep": "^0.4.0||^1.0.0"
}
},
+ "node_modules/@mjackson/node-fetch-server": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz",
+ "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==",
+ "license": "MIT"
+ },
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1631,6 +1820,153 @@
"openapi3-ts": "4.5.0"
}
},
+ "node_modules/@react-router/dev": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.18.3.tgz",
+ "integrity": "sha512-smLBdktEcLw1BgjaeWZG+TDRpmK9Mry4DzgNv4q556/Kq9qDo9Lfxu9Gp4/4BGOctFQG2UuyvxOnOhz0tEyzWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.27.7",
+ "@babel/generator": "^7.27.5",
+ "@babel/parser": "^7.27.7",
+ "@babel/plugin-syntax-jsx": "^7.27.1",
+ "@babel/preset-typescript": "^7.27.1",
+ "@babel/traverse": "^7.27.7",
+ "@babel/types": "^7.27.7",
+ "@react-router/node": "7.18.3",
+ "@remix-run/node-fetch-server": "^0.13.0",
+ "arg": "^5.0.1",
+ "babel-dead-code-elimination": "^1.0.6",
+ "chokidar": "^4.0.0",
+ "dedent": "^1.5.3",
+ "es-module-lexer": "^1.3.1",
+ "exit-hook": "2.2.1",
+ "isbot": "^5.1.11",
+ "jsesc": "3.0.2",
+ "lodash": "^4.17.21",
+ "p-map": "^7.0.3",
+ "pathe": "^1.1.2",
+ "picocolors": "^1.1.1",
+ "pkg-types": "^2.3.0",
+ "prettier": "^3.6.2",
+ "react-refresh": "^0.14.0",
+ "semver": "^7.3.7",
+ "tinyglobby": "^0.2.14",
+ "valibot": "^1.2.0",
+ "vite-node": "^3.2.2"
+ },
+ "bin": {
+ "react-router": "bin.js"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@react-router/serve": "^7.18.3",
+ "@vitejs/plugin-rsc": "~0.5.21",
+ "react-router": "^7.18.3",
+ "react-server-dom-webpack": "^19.2.3",
+ "typescript": "^5.1.0 || ^6.0.0",
+ "vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+ "wrangler": "^3.28.2 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@react-router/serve": {
+ "optional": true
+ },
+ "@vitejs/plugin-rsc": {
+ "optional": true
+ },
+ "react-server-dom-webpack": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ },
+ "wrangler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-router/dev/node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@react-router/dev/node_modules/jsesc": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
+ "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@react-router/dev/node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@react-router/dev/node_modules/react-refresh": {
+ "version": "0.14.2",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
+ "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@react-router/dev/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@react-router/node": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.18.3.tgz",
+ "integrity": "sha512-wIBFSsmp+uA/F2MEHN1BxFoWAhh9rdIH/Zd39KYyLhZSeb35YlRi8ARtOMDYj7EqhhZfkoetf/SEmYywK3nUkA==",
+ "license": "MIT",
+ "dependencies": {
+ "@mjackson/node-fetch-server": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react-router": "7.18.3",
+ "typescript": "^5.1.0 || ^6.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@remix-run/node-fetch-server": {
+ "version": "0.13.3",
+ "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.13.3.tgz",
+ "integrity": "sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -3376,6 +3712,13 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -3478,6 +3821,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/babel-dead-code-elimination": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz",
+ "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.23.7",
+ "@babel/parser": "^7.23.6",
+ "@babel/traverse": "^7.23.7",
+ "@babel/types": "^7.23.6"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3556,6 +3912,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -3767,6 +4133,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/confbox": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
+ "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -3881,6 +4254,21 @@
}
}
},
+ "node_modules/dedent": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
+ "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "babel-plugin-macros": "^3.1.0"
+ },
+ "peerDependenciesMeta": {
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4545,6 +4933,19 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
+ "node_modules/exit-hook": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
+ "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -4565,6 +4966,13 @@
"node": ">=16.9.0"
}
},
+ "node_modules/exsolve": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
+ "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5640,6 +6048,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/isbot": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz",
+ "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -6826,6 +7243,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-map": {
+ "version": "7.0.7",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz",
+ "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6895,6 +7325,18 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pkg-types": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.2.tgz",
+ "integrity": "sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.3.0",
+ "exsolve": "^1.1.1",
+ "pathe": "^2.0.3"
+ }
+ },
"node_modules/pony-cause": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz",
@@ -6953,6 +7395,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7042,9 +7500,9 @@
}
},
"node_modules/react-router": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
- "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
+ "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -8137,7 +8595,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -8272,6 +8730,21 @@
"node": ">= 4"
}
},
+ "node_modules/valibot": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
+ "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
+ "devOptional": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/validator": {
"version": "13.15.23",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz",
@@ -8356,6 +8829,36 @@
}
}
},
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite-node/node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -8791,10 +9294,12 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@o2o/shared": "*",
+ "@react-router/node": "^7.18.3",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "isbot": "^5",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
@@ -8808,6 +9313,7 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@react-router/dev": "^7.18.3",
"@types/node": "^22.14.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
diff --git a/solution/frontend/index.html b/solution/frontend/index.html
deleted file mode 100644
index 6c3f519..0000000
--- a/solution/frontend/index.html
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Web4Ai · AI 웹 빌더
-
-
-
-
-
-
diff --git a/solution/frontend/package.json b/solution/frontend/package.json
index 0ffa08b..01b234a 100644
--- a/solution/frontend/package.json
+++ b/solution/frontend/package.json
@@ -4,11 +4,11 @@
"version": "0.0.0",
"type": "module",
"scripts": {
- "dev": "vite --port=3000 --host=0.0.0.0",
- "build": "tsc --noEmit && eslint src && vite build",
+ "dev": "react-router dev --port=3000 --host=0.0.0.0",
+ "build": "react-router typegen && tsc --noEmit && eslint src && react-router build",
"preview": "vite preview",
- "clean": "rm -rf dist",
- "lint": "tsc --noEmit && eslint src",
+ "clean": "rm -rf build .react-router",
+ "lint": "react-router typegen && tsc --noEmit && eslint src",
"orval": "orval --config ./orval.config.ts"
},
"dependencies": {
@@ -17,10 +17,12 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@o2o/shared": "*",
+ "@react-router/node": "^7.18.3",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "isbot": "^5",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
@@ -34,6 +36,7 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@react-router/dev": "^7.18.3",
"@types/node": "^22.14.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
diff --git a/solution/frontend/react-router.config.ts b/solution/frontend/react-router.config.ts
new file mode 100644
index 0000000..0273023
--- /dev/null
+++ b/solution/frontend/react-router.config.ts
@@ -0,0 +1,24 @@
+import type {Config} from '@react-router/dev/config';
+
+/**
+ * 프리렌더 설정.
+ *
+ * ★ 왜 하나 — 랜딩은 `` 만 내보내는 CSR 이었다. 실측(2026-09-07):
+ * `curl /` 는 3,021바이트에 `` 0개·본문 0자. 같은 호스트의 발행본은 48,072바이트다.
+ * 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규 도메인은 뒤로 밀린다 —
+ * 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다.
+ *
+ * ★ `ssr: false` 와 함께 쓴다. 런타임 Node 서버를 두지 않는다는 뜻이다 —
+ * 아래 목록만 HTML 로 굽고, 나머지(`/builder` `/login` `/sites` `/account`)는
+ * 지금까지처럼 SPA 폴백으로 나간다. nginx 설정도 배포 구조도 그대로다.
+ *
+ * ★ 로그인 뒤에만 의미가 있는 화면은 굽지 않는다. 구울 내용이 없고(데이터가 사용자별),
+ * robots.txt 로 막을 대상이기도 하다.
+ */
+export default {
+ // 이 레포는 소스가 `src/` 밑이다(기본값 `app/` 이 아니다).
+ // root.tsx · routes.ts 를 여기서 찾는다.
+ appDirectory: 'src',
+ ssr: false,
+ prerender: ['/', '/pricing', '/showcase'],
+} satisfies Config;
diff --git a/solution/frontend/src/app/main.tsx b/solution/frontend/src/app/main.tsx
deleted file mode 100644
index 867493b..0000000
--- a/solution/frontend/src/app/main.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import {StrictMode} from 'react';
-import {createRoot} from 'react-dom/client';
-import {RouterProvider} from 'react-router';
-import {Providers} from './provider';
-import {router} from './router';
-import '../index.css';
-
-createRoot(document.getElementById('root')!).render(
-
-
-
-
- ,
-);
diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx
deleted file mode 100644
index 2621c72..0000000
--- a/solution/frontend/src/app/router.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import {createBrowserRouter, Navigate} from 'react-router';
-import {Loader2} from 'lucide-react';
-import {AccountPage} from '@/pages/AccountPage';
-import {BuilderPage} from '@/pages/BuilderPage';
-import {DevShowcasePage} from '@/pages/DevShowcasePage';
-import {LandingPage} from '@/pages/LandingPage';
-import {LoginPage} from '@/pages/LoginPage';
-import {NotFoundPage} from '@/pages/NotFoundPage';
-import {PricingPage} from '@/pages/PricingPage';
-import {ShowcasePage} from '@/pages/ShowcasePage';
-import {SignupPage} from '@/pages/SignupPage';
-import {SitesPage} from '@/pages/SitesPage';
-import {RequireAuth} from '@/components/layout/RequireAuth';
-import {useAuthStore} from '@/stores/auth';
-
-/**
- * 첫 화면은 로그인 여부로 갈린다 — 이미 사이트를 가진 사장님의 용건은 "새로 만들기"가 아니라
- * "내 것 고치기"다. 비로그인은 **랜딩**을 본다.
- *
- * ★ 예전엔 비로그인을 곧장 위저드로 보냈다. 그러면 이 제품이 무엇을 파는 물건인지 말할 자리가
- * 한 곳도 없다 — 처음 온 사람이 업종 선택 화면부터 만난다.
- * ★ 복구가 끝나기 전에 판단하면 새로고침할 때마다 화면이 한 번 번쩍이고 목록으로 튄다.
- */
-function Home() {
- const isRestoring = useAuthStore((s) => s.isRestoring);
- const user = useAuthStore((s) => s.user);
-
- if (isRestoring) {
- return (
-
-
-
- );
- }
- /*
- * ★ 로그인해도 랜딩을 보여 준다 (2026-09-04, 사장님 지적)
- * 예전엔 여기서 /sites 로 튕겼다. 그러면 **로그인한 사장님은 랜딩·요금에 갈 길이 없다** —
- * 로고가 '/' 를 걸고 있어도 눌리는 순간 목록으로 돌아온다.
- * "로그인 직후엔 내 사이트로" 는 로그인·가입 화면이 직접 보낸다(LoginPage homePath).
- */
- void user;
- return ;
-}
-
-export const router = createBrowserRouter([
- {path: '/login', element: },
- // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
- {path: '/signup', element: },
-
- // 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다.
- {path: '/', element: },
-
- // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
- {path: '/pricing', element: },
- {path: '/showcase', element: },
-
- // 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
- {
- path: '/sites',
- element: (
-
-
-
- ),
- },
- {
- path: '/account',
- element: (
-
-
-
- ),
- },
-
- /**
- * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
- * 만들어 보기도 전에 막힌다.
- *
- * 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이
- * 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다.
- *
- * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
- */
- {path: '/builder', element: },
-
- /**
- * ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다.
- * 라우트 가드는 화면을 가리지 **번들은 못 가린다**: 한 앱이면 내부 라우트 이름과 코드가
- * 사장님 브라우저에 그대로 내려간다. `UserRole.DEVELOPER` 주석의
- * "고객사에 존재를 노출하지 않는다"를 지키려면 번들 자체가 갈라져야 한다.
- * (ARCHITECTURE.md 4절)
- */
-
- // 개발 빌드에서만 열리는 컴포넌트 쇼케이스. 운영 번들에서는 라우트 자체가 없다.
- ...(import.meta.env.DEV ? [{path: '/dev/showcase', element: }] : []),
-
- {path: '*', element: },
-]);
diff --git a/solution/frontend/src/components/layout/RequireAuthLayout.tsx b/solution/frontend/src/components/layout/RequireAuthLayout.tsx
new file mode 100644
index 0000000..75eb24f
--- /dev/null
+++ b/solution/frontend/src/components/layout/RequireAuthLayout.tsx
@@ -0,0 +1,14 @@
+import {Outlet} from 'react-router';
+import {RequireAuth} from './RequireAuth';
+
+/**
+ * 인증이 필요한 라우트들의 부모. 예전 `router.tsx` 가 페이지마다 로
+ * 감싸던 것을 레이아웃 라우트 하나로 모았다 — 가드가 한 자리에 있어야 빠뜨리지 않는다.
+ */
+export default function RequireAuthLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx
index 4ed1789..a41eee3 100644
--- a/solution/frontend/src/features/builder/CanvasView.tsx
+++ b/solution/frontend/src/features/builder/CanvasView.tsx
@@ -6,11 +6,14 @@ import {deriveSurfaces} from '@/lib/color';
import {cn} from '@/lib/utils';
import {useBuilderStore, useCurrentTemplate} from '@/stores/builder';
import {resolveVariant} from './canvas/registry';
+import {PUBLISH_HOST} from '@/lib/site';
// ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은
// 미리보기에서 본 주소와 발행 후 안내받는 주소가 달라 어느 쪽이 진짜인지 알 수 없다.
// 같은 규칙(VITE_PUBLISH_HOST → 없으면 현재 호스트)을 쓴다.
-const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
+// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
+// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
+// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
const VIEWPORT_FRAME: Record = {
pc: 'w-full max-w-5xl shadow-md',
diff --git a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx
index 9405014..3ec3925 100644
--- a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx
+++ b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx
@@ -1,6 +1,7 @@
import {useEffect, useState, type CSSProperties} from 'react';
import {Building2, Coffee, ImageOff, Stethoscope, UtensilsCrossed} from 'lucide-react';
import {PlaceCategory} from '@o2o/shared';
+import {ORIGIN} from '@/lib/site';
import {fetchShowcase, type ShowcaseItem} from './showcaseApi';
const CATEGORY_ICON: Record = {
@@ -17,11 +18,14 @@ const CATEGORY_LABEL: Record = {
[PlaceCategory.CLINIC]: '피부과 · 성형외과',
};
-/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/`). 발행 호스트는 번들에 구워진 값이다. */
-const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
-
+/**
+ * 발행 사이트 주소는 루트 상대경로로 온다(`/s/`).
+ *
+ * ★ window 로 떨어지지 않는다. 이 컴포넌트는 랜딩(`/`)과 `/showcase` 에 있고 그 둘은
+ * 프리렌더 대상이다 — 빌드 때 브라우저 없이 한 번 그려지므로 window 를 만지면 죽는다.
+ */
function siteHref(url: string): string {
- return `${window.location.protocol}//${PUBLISH_HOST}${url}`;
+ return `${ORIGIN}${url}`;
}
/**
diff --git a/solution/frontend/src/features/publish/PublishModal.tsx b/solution/frontend/src/features/publish/PublishModal.tsx
index 0e8aaac..3b66929 100644
--- a/solution/frontend/src/features/publish/PublishModal.tsx
+++ b/solution/frontend/src/features/publish/PublishModal.tsx
@@ -19,6 +19,7 @@ import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder';
import {runPublishGate, type GateFinding} from './publishGate';
import {checkSiteSlug, reserveSiteSlug} from './siteSlug';
+import {PUBLISH_HOST} from '@/lib/site';
import {localValidate, SlugField, type SlugStatus} from './SlugField';
import {
GATE_REASON_LABEL,
@@ -29,7 +30,9 @@ import {
// 개발에서는 admin 과 같은 :3000을 공개 주소로 쓴다. `/s` 요청은 Vite가 정적 사이트
// 서버(:3001)로 프록시한다. 운영에서는 VITE_PUBLISH_HOST로 공개 호스트를 명시할 수 있다.
-const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
+// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
+// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
+// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
export function PublishModal() {
const isOpen = useBuilderStore((s) => s.isPublishModalOpen);
diff --git a/solution/frontend/src/lib/site.ts b/solution/frontend/src/lib/site.ts
new file mode 100644
index 0000000..eedc0d1
--- /dev/null
+++ b/solution/frontend/src/lib/site.ts
@@ -0,0 +1,15 @@
+/**
+ * 이 앱이 서비스되는 공개 주소.
+ *
+ * ★ 호스트를 문자열로 두 곳에 적지 않는다 — compose 가 루트 `SITE_PUBLIC_HOST` 를
+ * `VITE_PUBLISH_HOST` 로 흘려보낸다(AGENTS.md). 여기가 프론트에서의 유일한 자리다.
+ * ★ `window.location` 으로 떨어지지 않는다. 프리렌더는 브라우저 없이 도는데,
+ * 그때 window 를 만지면 빌드가 죽는다.
+ */
+const HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai';
+
+/** `https://web4ai.o2osolution.ai` — 끝 슬래시 없음. */
+export const ORIGIN = `https://${HOST}`;
+
+/** 발행 사이트 주소를 만들 때 쓰는 호스트. */
+export const PUBLISH_HOST = HOST;
diff --git a/solution/frontend/src/pages/AccountPage.tsx b/solution/frontend/src/pages/AccountPage.tsx
index 79fa550..6c2b9fb 100644
--- a/solution/frontend/src/pages/AccountPage.tsx
+++ b/solution/frontend/src/pages/AccountPage.tsx
@@ -142,3 +142,6 @@ function Field({label, children}: {label: string; children: React.ReactNode}) {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default AccountPage;
diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx
index bedb166..9f596bc 100644
--- a/solution/frontend/src/pages/BuilderPage.tsx
+++ b/solution/frontend/src/pages/BuilderPage.tsx
@@ -18,9 +18,12 @@ import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync';
import {userLabel, useAuthStore} from '@/stores/auth';
import {useBuilderStore} from '@/stores/builder';
+import {ORIGIN} from '@/lib/site';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
-const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin;
+// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
+// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
+const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL || ORIGIN;
/** 랜딩이 `?industry=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */
const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
@@ -307,3 +310,6 @@ function BuilderNotice({
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default BuilderPage;
diff --git a/solution/frontend/src/pages/DevShowcasePage.tsx b/solution/frontend/src/pages/DevShowcasePage.tsx
index 1a0dfc0..1be8b83 100644
--- a/solution/frontend/src/pages/DevShowcasePage.tsx
+++ b/solution/frontend/src/pages/DevShowcasePage.tsx
@@ -701,3 +701,6 @@ function GroupList({
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default DevShowcasePage;
diff --git a/solution/frontend/src/pages/LandingPage.tsx b/solution/frontend/src/pages/LandingPage.tsx
index 0698917..8352693 100644
--- a/solution/frontend/src/pages/LandingPage.tsx
+++ b/solution/frontend/src/pages/LandingPage.tsx
@@ -4,6 +4,44 @@ import {ArrowRight, ArrowUp, Check} from 'lucide-react';
import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell';
import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button';
+import type {MetaFunction} from 'react-router';
+import {ORIGIN} from '@/lib/site';
+/**
+ * ★ 제목은 브랜드가 아니라 **사람이 검색창에 치는 말**로 시작한다. "Web4Ai" 는 검색량이
+ * 없는 신규 이름이고, 예전 제목("Web4Ai · AI 웹 빌더")에는 검색어가 한 단어도 없었다.
+ * seo 규칙: 핵심 키워드 앞, 브랜드 뒤, 50~60자.
+ */
+export const meta: MetaFunction = () => [
+ {title: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로 | Web4Ai'},
+ {
+ name: 'description',
+ content:
+ '네이버 플레이스만 있으면 우리 가게 홈페이지가 만들어집니다. 주소·영업시간·메뉴가 구조화 데이터로 함께 나가 검색엔진과 AI 답변이 함께 읽습니다. 사업자가 확인한 정보만 발행합니다.',
+ },
+ {name: 'robots', content: 'index, follow, max-snippet:-1, max-image-preview:large'},
+ {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/`},
+ {property: 'og:title', content: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로'},
+ {
+ property: 'og:description',
+ content: '네이버 플레이스만 있으면 검색·AI 답변이 함께 읽는 우리 가게 홈페이지가 만들어집니다.',
+ },
+ {property: 'og:url', content: `${ORIGIN}/`},
+ // 이 앱이 무엇인지 기계에게 말하는 유일한 자리다. 발행본에는 LodgingBusiness 가 나가는데
+ // 정작 랜딩에는 구조화 데이터가 하나도 없었다.
+ {
+ 'script:ld+json': {
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ '@id': `${ORIGIN}/#org`,
+ name: 'Web4Ai',
+ url: `${ORIGIN}/`,
+ logo: `${ORIGIN}/brand/favicon-w4a.svg`,
+ description:
+ '네이버 플레이스 정보를 사업자가 확인해 가게 공식 홈페이지로 발행하는 서비스. 검색엔진과 AI 답변엔진이 함께 읽는 형식으로 내보냅니다.',
+ },
+ },
+];
+
/**
* 히어로 제목의 앞말. '홈페이지' 는 고정이고 이 여섯이 돌아간다.
@@ -279,3 +317,6 @@ export function LandingPage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default LandingPage;
diff --git a/solution/frontend/src/pages/LoginPage.tsx b/solution/frontend/src/pages/LoginPage.tsx
index 08478b3..2733f7a 100644
--- a/solution/frontend/src/pages/LoginPage.tsx
+++ b/solution/frontend/src/pages/LoginPage.tsx
@@ -19,7 +19,8 @@ import {useAuthStore} from '@/stores/auth';
*/
export function LoginPage({
selfServe = true,
- homePath = '/',
+ // 로그인 직후엔 내 사이트로. 예전엔 router.tsx 가 넘기던 값이다.
+ homePath = '/sites',
}: {
selfServe?: boolean;
/**
@@ -165,3 +166,6 @@ export function LoginPage({
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default LoginPage;
diff --git a/solution/frontend/src/pages/NotFoundPage.tsx b/solution/frontend/src/pages/NotFoundPage.tsx
index 9170b7b..e103633 100644
--- a/solution/frontend/src/pages/NotFoundPage.tsx
+++ b/solution/frontend/src/pages/NotFoundPage.tsx
@@ -14,3 +14,6 @@ export function NotFoundPage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default NotFoundPage;
diff --git a/solution/frontend/src/pages/PricingPage.tsx b/solution/frontend/src/pages/PricingPage.tsx
index 0388cb7..98f7d8b 100644
--- a/solution/frontend/src/pages/PricingPage.tsx
+++ b/solution/frontend/src/pages/PricingPage.tsx
@@ -2,6 +2,20 @@ import {Link} from 'react-router';
import {Check} from 'lucide-react';
import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {Button} from '@/components/ui/button';
+import type {MetaFunction} from 'react-router';
+import {ORIGIN} from '@/lib/site';
+export const meta: MetaFunction = () => [
+ {title: '요금 · 가게 홈페이지 제작 비용 | Web4Ai'},
+ {
+ name: 'description',
+ content: 'Web4Ai 로 가게 홈페이지를 만들고 발행하는 데 드는 비용입니다. 발행 후에도 AI 인용 상태를 계속 확인합니다.',
+ },
+ {name: 'robots', content: 'index, follow'},
+ {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/pricing`},
+ {property: 'og:title', content: '요금 · 가게 홈페이지 제작 비용'},
+ {property: 'og:url', content: `${ORIGIN}/pricing`},
+];
+
/**
* 요금 — 플랜 하나다.
@@ -107,3 +121,6 @@ export function PricingPage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default PricingPage;
diff --git a/solution/frontend/src/pages/ShowcasePage.tsx b/solution/frontend/src/pages/ShowcasePage.tsx
index 7dd3fed..fcafe95 100644
--- a/solution/frontend/src/pages/ShowcasePage.tsx
+++ b/solution/frontend/src/pages/ShowcasePage.tsx
@@ -2,6 +2,20 @@ import {Link} from 'react-router';
import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {ShowcaseGrid} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button';
+import type {MetaFunction} from 'react-router';
+import {ORIGIN} from '@/lib/site';
+export const meta: MetaFunction = () => [
+ {title: '먼저 시작한 가게들 · 발행 사례 | Web4Ai'},
+ {
+ name: 'description',
+ content: 'Web4Ai 로 실제 발행된 가게 홈페이지 사례입니다. 모든 정보는 사업자가 확인한 것만 담겨 있습니다.',
+ },
+ {name: 'robots', content: 'index, follow'},
+ {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/showcase`},
+ {property: 'og:title', content: '먼저 시작한 가게들 · 발행 사례'},
+ {property: 'og:url', content: `${ORIGIN}/showcase`},
+];
+
/**
* 이렇게 나옵니다 — 실제로 발행된 홈페이지 목록.
@@ -35,3 +49,6 @@ export function ShowcasePage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default ShowcasePage;
diff --git a/solution/frontend/src/pages/SignupPage.tsx b/solution/frontend/src/pages/SignupPage.tsx
index 0ec2026..6048fa1 100644
--- a/solution/frontend/src/pages/SignupPage.tsx
+++ b/solution/frontend/src/pages/SignupPage.tsx
@@ -219,3 +219,6 @@ export function SignupPage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default SignupPage;
diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx
index f6f10e7..7c70a0f 100644
--- a/solution/frontend/src/pages/SitesPage.tsx
+++ b/solution/frontend/src/pages/SitesPage.tsx
@@ -18,9 +18,12 @@ import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {notify, notifyApiError} from '@/lib/notify';
+import {PUBLISH_HOST} from '@/lib/site';
// 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다.
-const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
+// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
+// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
+// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
const CATEGORY_ICON: Record = {
[PlaceCategory.LODGING]: Building2,
@@ -211,3 +214,6 @@ export function SitesPage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default SitesPage;
diff --git a/solution/frontend/src/root.tsx b/solution/frontend/src/root.tsx
new file mode 100644
index 0000000..cece0f4
--- /dev/null
+++ b/solution/frontend/src/root.tsx
@@ -0,0 +1,82 @@
+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 이 없고, 이 컴포넌트가 부터 그린다.
+ *
+ * ★ 발행 호스트를 여기 문자열로 적지 않는다. `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 (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export default function Root() {
+ return (
+
+
+
+ );
+}
+
+export function ErrorBoundary({error}: {error: unknown}) {
+ const is404 = isRouteErrorResponse(error) && error.status === 404;
+ return (
+
+
{is404 ? '404' : '오류'}
+
+ {is404 ? '없는 주소입니다.' : '화면을 그리는 중 문제가 생겼습니다.'}
+
+
+ 홈으로
+
+
+ );
+}
diff --git a/solution/frontend/src/routes.ts b/solution/frontend/src/routes.ts
new file mode 100644
index 0000000..dac2bb8
--- /dev/null
+++ b/solution/frontend/src/routes.ts
@@ -0,0 +1,42 @@
+import {index, layout, route, type RouteConfig} from '@react-router/dev/routes';
+
+/**
+ * 라우트 표. 예전 `app/router.tsx` 의 배열이 여기로 왔다.
+ *
+ * ★ 순서·경로는 그대로다. 바뀐 건 선언 방식뿐이고, 페이지 컴포넌트는 손대지 않았다.
+ * ★ 프리렌더 대상은 여기가 아니라 `react-router.config.ts` 가 정한다.
+ * 목록에 없는 경로는 지금까지처럼 SPA 폴백으로 나간다.
+ */
+export default [
+ // 비로그인 = 랜딩. ★ 예전엔 로그인 여부로 갈랐는데 지금은 둘 다 랜딩이다
+ // (2026-09-04, 사장님 지적: 로그인하면 랜딩·요금에 갈 길이 없었다).
+ index('pages/LandingPage.tsx'),
+
+ route('login', 'pages/LoginPage.tsx'),
+ // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
+ route('signup', 'pages/SignupPage.tsx'),
+
+ // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
+ route('pricing', 'pages/PricingPage.tsx'),
+ route('showcase', 'pages/ShowcasePage.tsx'),
+
+ // 로그인한 사장님의 홈. 예전엔 페이지마다 로 감쌌는데,
+ // 가드는 한 자리에 있어야 빠뜨리지 않아서 레이아웃 라우트로 모았다.
+ layout('components/layout/RequireAuthLayout.tsx', [
+ route('sites', 'pages/SitesPage.tsx'),
+ route('account', 'pages/AccountPage.tsx'),
+ ]),
+
+ /**
+ * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
+ * 만들어 보기도 전에 막힌다. 세션은 useAutoLogin() 이 조용히 확보한다.
+ */
+ route('builder', 'pages/BuilderPage.tsx'),
+
+ // 개발 빌드에서만 열린다. 운영 번들에는 라우트 자체가 없다.
+ ...(process.env.NODE_ENV !== 'production'
+ ? [route('dev/showcase', 'pages/DevShowcasePage.tsx')]
+ : []),
+
+ route('*', 'pages/NotFoundPage.tsx'),
+] satisfies RouteConfig;
diff --git a/solution/frontend/vite.config.ts b/solution/frontend/vite.config.ts
index 66f41dd..738d6bb 100644
--- a/solution/frontend/vite.config.ts
+++ b/solution/frontend/vite.config.ts
@@ -1,10 +1,12 @@
+import {reactRouter} from '@react-router/dev/vite';
import tailwindcss from '@tailwindcss/vite';
-import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig({
- plugins: [react(), tailwindcss()],
+ // ★ @vitejs/plugin-react 를 따로 넣지 않는다 — reactRouter() 가 안에서 켠다.
+ // 둘 다 넣으면 리프레시 런타임이 두 번 주입돼 HMR 이 깨진다.
+ plugins: [reactRouter(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
@@ -18,6 +20,9 @@ export default defineConfig({
'@o2o/shared': path.resolve(__dirname, '../shared/src'),
},
},
+ // ★ 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값).
+ // nginx/Dockerfile 의 COPY 경로가 이 값과 맞아야 한다 — 어긋나면 빈 이미지가 구워지고
+ // 컨테이너는 정상으로 뜬다.
build: {
// ★ 발행본과 **같은 오리진**을 쓰므로 `/assets/` 를 서로 뺏는다. 빌더 번들만 다른
// 디렉토리로 뺀다 — 안 그러면 nginx 의 `/assets/` 규칙이 발행본 것만 주고
diff --git a/solution/site/src/seo/robots.ts b/solution/site/src/seo/robots.ts
index fd6fd81..a75d653 100644
--- a/solution/site/src/seo/robots.ts
+++ b/solution/site/src/seo/robots.ts
@@ -33,11 +33,22 @@ const SEARCH_CRAWLERS = [
'Daumoa', // 다음
];
+/**
+ * 앱 전용 경로 — 크롤러가 볼 것이 없는 자리.
+ *
+ * ★ 로그인해야 내용이 생기거나(`/sites` `/account`), 도구 화면이라(`/builder`) 색인할
+ * 내용이 아예 없다. 프리렌더를 켠 뒤 이 경로들은 빈 SPA 폴백을 받는데, 그걸 긁히면
+ * 호스트 전체에 "내용 없는 페이지" 신호가 쌓인다.
+ * ★ `/showcase` 는 막지 않는다 — 프리렌더 대상이고 발행 사례를 담는다.
+ */
+const APP_ONLY_PATHS = ['/builder', '/login', '/signup', '/sites', '/account'];
+
/** 크롤러 허용 블록 — 루트용과 사이트용이 같은 목록을 쓴다. */
function allowBlocks(): string[] {
- const blocks: string[] = ['User-agent: *', 'Allow: /', ''];
+ const deny = APP_ONLY_PATHS.map((path) => `Disallow: ${path}`);
+ const blocks: string[] = ['User-agent: *', 'Allow: /', ...deny, ''];
for (const bot of [...SEARCH_CRAWLERS, ...AI_CRAWLERS]) {
- blocks.push(`User-agent: ${bot}`, 'Allow: /', '');
+ blocks.push(`User-agent: ${bot}`, 'Allow: /', ...deny, '');
}
return blocks;
}