Cloudflare Pages의 Clean URL(308 Redirect) 처리와 루트 절대 경로 표준화Handling Cloudflare Pages Clean URLs (308 Redirect) and Root-Relative Path Standardization
Cloudflare Pages의 .html 확장자 자동 제거(308 Permanent Redirect) 동작으로 인한 상대 경로 파손과 검색엔진 색인 보류 문제를 해결하는 URL 정규화 기법입니다.Techniques for normalizing URLs on Cloudflare Pages to resolve broken relative paths and search indexing delays caused by automatic .html stripping (308 Permanent Redirect).
/docs/web/example.html로 들어온 요청을 확장자가 없는 /docs/web/example로 308 Permanent Redirect 처리합니다. 이때 내부 링크나 <link rel="canonical">, sitemap.xml에 .html 확장자가 남아 있거나 상대 경로(../)를 혼용하면, 검색엔진 크롤러가 '정식 주소가 리다이렉트됨'으로 판정하여 색인을 보류하고 정적 에셋 경로가 깨지는 문제가 발생합니다./docs/web/example.html to the extensionless /docs/web/example. If internal links, <link rel="canonical">, or sitemap.xml still include .html extensions or rely on relative paths (../), search engine crawlers flag the canonical URLs as redirected, delaying indexing and breaking static asset paths.1. 디스크 파일명과 공개 Clean URL 분리 및 무결성 검증 스크립트1. Separating Disk Filenames from Public Clean URLs and Verification Script
정적 빌드 단계에서 디스크에 저장할 물리 파일 경로(OUTPUT_DIR / f"{slug}.html")와 외부에 노출할 논리 주소(/docs/{cat}/{slug})를 엄격히 분리하고, 빌드 직후 모든 HTML 내의 내부 링크와 canonical 태그를 전수 검사합니다.
During the static build step, strictly separate the physical file path on disk (OUTPUT_DIR / f"{slug}.html") from the public logical URL (/docs/{cat}/{slug}). After building, run an automated check across all generated HTML files to validate internal links and canonical tags.
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SITE_ORIGIN = "https://www.lysc.kr"
HREF_PATTERN = re.compile(r'(?:href|src|content)=["\']([^"\']+)["\']')
CANONICAL_PATTERN = re.compile(r'<link\s+rel="canonical"\s+href="([^"]+)"')
def check_html_file(html_path: Path) -> list[str]:
"""단일 HTML 파일 내 상대 경로 사용 여부와 .html 확장자 노출을 검증합니다."""
errors: list[str] = []
content = html_path.read_text(encoding="utf-8")
# 1. Canonical URL에 .html 확장자가 포함되었는지 검사
canonical_match = CANONICAL_PATTERN.search(content)
if canonical_match:
canon_url = canonical_match.group(1)
if canon_url.endswith(".html"):
errors.append(f"[CANONICAL] .html 확장자 포함 금지: {canon_url}")
if not canon_url.startswith(SITE_ORIGIN):
errors.append(f"[CANONICAL] 도메인 오리진 불일치: {canon_url}")
# 2. 내부 링크에서 상대 경로(../) 및 .html 접미사 검사
for match in HREF_PATTERN.finditer(content):
url = match.group(1)
if url.startswith(("http://", "https://", "mailto:", "#", "data:")):
continue
if url.startswith("./") or url.startswith("../"):
errors.append(f"[REL-PATH] 상대 경로 대신 루트 절대 경로(/) 사용 필요: {url}")
if url.startswith("/blog/posts/") and url.endswith(".html"):
errors.append(f"[308-RISK] 내부 링크에 .html 확장자 노출: {url}")
return errors
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SITE_ORIGIN = "https://www.lysc.kr"
HREF_PATTERN = re.compile(r'(?:href|src|content)=["\']([^"\']+)["\']')
CANONICAL_PATTERN = re.compile(r'<link\s+rel="canonical"\s+href="([^"]+)"')
def check_html_file(html_path: Path) -> list[str]:
"""Verify that a single HTML file avoids relative paths and exposed .html extensions."""
errors: list[str] = []
content = html_path.read_text(encoding="utf-8")
# 1. Check whether the canonical URL contains a .html extension
canonical_match = CANONICAL_PATTERN.search(content)
if canonical_match:
canon_url = canonical_match.group(1)
if canon_url.endswith(".html"):
errors.append(f"[CANONICAL] Must not end with .html: {canon_url}")
if not canon_url.startswith(SITE_ORIGIN):
errors.append(f"[CANONICAL] Domain origin mismatch: {canon_url}")
# 2. Check internal links for relative paths (../) and .html suffixes
for match in HREF_PATTERN.finditer(content):
url = match.group(1)
if url.startswith(("http://", "https://", "mailto:", "#", "data:")):
continue
if url.startswith("./") or url.startswith("../"):
errors.append(f"[REL-PATH] Use root-relative path (/) instead of relative path: {url}")
if url.startswith("/blog/posts/") and url.endswith(".html"):
errors.append(f"[308-RISK] Exposed .html extension in internal link: {url}")
return errors
2. URL 유형별 Cloudflare Pages 응답 및 경로 규칙 비교2. Comparison of Cloudflare Pages Responses by URL Format
| 요청 경로 형태 | 실제 매핑되는 디스크 파일 | Cloudflare Pages 응답 | 권장 표준 표기 |
|---|---|---|---|
/docs/web/slug.html | /docs/web/slug.html | 308 Permanent Redirect → /docs/web/slug | 사용 금지 (canonical 및 내부 링크에서 배제) |
/docs/web/slug | /docs/web/slug.html | 200 OK (즉시 서빙) | 개별 문서 및 포스트의 표준 Clean URL |
/docs/index.html | /docs/index.html | 308 Permanent Redirect → /docs/ | 사용 금지 (디렉터리 루트 슬래시로 통일) |
/docs/ | /docs/index.html | 200 OK (즉시 서빙) | 섹션 허브 및 디렉터리 인덱스의 표준 URL |
| Request Path Pattern | Mapped Disk File | Cloudflare Pages Response | Recommended Standard |
|---|---|---|---|
/docs/web/slug.html | /docs/web/slug.html | 308 Permanent Redirect → /docs/web/slug | Do not use (exclude from canonical and internal links) |
/docs/web/slug | /docs/web/slug.html | 200 OK (Served directly) | Standard Clean URL for individual docs and posts |
/docs/index.html | /docs/index.html | 308 Permanent Redirect → /docs/ | Do not use (normalize to trailing slash directory path) |
/docs/ | /docs/index.html | 200 OK (Served directly) | Standard URL for section hubs and directory indexes |
3. 실무 적용 및 주의사항3. Practical Considerations and Caveats
- 루트 상대 경로(
/) 전면 도입: Clean URL 환경에서는 브라우저가/docs/web/slug를 디렉터리처럼 해석하거나 파일처럼 해석하는 기준이 끝 슬래시(/) 유무에 따라 달라지므로, CSS·JS·이미지는 항상/css/theme.css처럼 루트 기준 경로로 참조해야 합니다. - 로컬 개발 서버와의 동작 일치: Python 기본
http.server는 확장자 없는/docs/web/slug요청을 찾지 못해 404를 반환하므로, 로컬 프리뷰 서버(serve.py)에서도 경로에 대응하는.html파일을 자동으로 찾아 서빙하도록 핸들러를 오버라이드해야 합니다. - 사이트맵과 Canonical의 1:1 일치:
sitemap.xml의<loc>값과 각 페이지의<link rel="canonical">,og:url이 글자 단위까지 동일해야 Google Search Console에서 리다이렉트 경고가 발생하지 않습니다.
- Use Root-Relative Paths (
/): Under Clean URLs, browser resolution of relative paths depends on whether a trailing slash is present, so always reference CSS, JS, and images from the root (e.g.,/css/theme.css). - Parity with Local Preview Server: Python's default
http.serverreturns 404 for extensionless/docs/web/slugpaths, so override the request handler in your localserve.pyto resolve.htmlfiles automatically. - Exact Match Across Sitemap and Canonical Tags: Ensure
<loc>entries insitemap.xmlmatch each page's<link rel="canonical">andog:urlcharacter-for-character to prevent redirect warnings in Google Search Console.
디스크의 물리 파일 확장자와 웹에 공개되는 논리 URL을 빌드 파이프라인에서 분리하면, 정적 호스팅의 308 리다이렉트 오버헤드를 줄이고 검색엔진 색인 효율을 안정적으로 유지할 수 있습니다.
Decoupling physical .html filenames from public extensionless URLs in the build pipeline eliminates unnecessary 308 redirects and keeps search engine indexing consistent.