diff --git a/confluence-mdx/README.md b/confluence-mdx/README.md index 3db5ef58c..122f9f02f 100644 --- a/confluence-mdx/README.md +++ b/confluence-mdx/README.md @@ -197,7 +197,7 @@ bin/convert_all.py --verify-translations - `target/public/` 디렉토리에 첨부파일이 저장됩니다. - 한국어 제목의 번역이 누락된 경우, 오류와 함께 누락 목록을 출력합니다. - `etc/korean-titles-translations.txt`에 번역을 추가한 후 재실행합니다. -- 표시용 영어 제목과 다른 기존 public route segment를 유지해야 하는 경우 `etc/content-slug-overrides.yaml`에 Confluence content ID와 canonical slug를 추가합니다. +- Public route는 현재 영어 제목 번역을 slugify하여 생성합니다. 제목 변경으로 route가 바뀌면 conversion manifest가 이전 route를 감지해 `src/content-route-redirects.yaml`에 기본 8주 임시 redirect를 기록합니다. ## Confluence xhtml 을 Markdown 으로 변환하기 diff --git a/confluence-mdx/bin/content_redirects.py b/confluence-mdx/bin/content_redirects.py new file mode 100644 index 000000000..137aad34c --- /dev/null +++ b/confluence-mdx/bin/content_redirects.py @@ -0,0 +1,233 @@ +"""Lifecycle management for title-derived public content redirects.""" + +import os +import tempfile +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Mapping, Sequence + +import yaml + + +REDIRECT_RETENTION_DAYS = 56 + + +class ContentRedirectError(RuntimeError): + """Raised when the content redirect registry is invalid.""" + + +def _parse_iso_date(value: Any, field: str) -> date: + if not isinstance(value, str): + raise ContentRedirectError(f"{field} must be an ISO date string") + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise ContentRedirectError( + f"{field} must use YYYY-MM-DD: {value!r}" + ) from exc + if parsed.isoformat() != value: + raise ContentRedirectError( + f"{field} must use canonical YYYY-MM-DD: {value!r}" + ) + return parsed + + +def _validate_route(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.startswith("/"): + raise ContentRedirectError(f"{field} must be a root-relative route") + if value == "/" or value.endswith("/") or "//" in value: + raise ContentRedirectError(f"{field} is not a canonical content route: {value!r}") + if any(part in ("", ".", "..") for part in value.split("/")[1:]): + raise ContentRedirectError(f"{field} contains an unsafe segment: {value!r}") + return value + + +def _validate_redirects(data: Any) -> list[Dict[str, str]]: + if data is None: + return [] + if not isinstance(data, list): + raise ContentRedirectError("Content redirect registry must be a list") + + validated: list[Dict[str, str]] = [] + seen_sources: set[str] = set() + for index, item in enumerate(data): + if not isinstance(item, dict): + raise ContentRedirectError( + f"Content redirect at index {index} must be a mapping" + ) + source = _validate_route(item.get("source"), "source") + destination = _validate_route(item.get("destination"), "destination") + if source == destination: + raise ContentRedirectError( + f"Content redirect source equals destination: {source}" + ) + if source in seen_sources: + raise ContentRedirectError( + f"Duplicate content redirect source: {source}" + ) + created_on = _parse_iso_date(item.get("created_on"), "created_on") + expires_on = _parse_iso_date(item.get("expires_on"), "expires_on") + if expires_on <= created_on: + raise ContentRedirectError( + f"expires_on must be later than created_on for {source}" + ) + seen_sources.add(source) + validated.append({ + "source": source, + "destination": destination, + "created_on": created_on.isoformat(), + "expires_on": expires_on.isoformat(), + }) + return validated + + +def load_content_redirects(path: Path) -> list[Dict[str, str]]: + """Load and validate the persisted redirect registry.""" + if not path.exists(): + return [] + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise ContentRedirectError( + f"Invalid YAML in content redirect registry {path}: {exc}" + ) from exc + return _validate_redirects(data) + + +def _mdx_routes_by_content_id( + outputs: Sequence[Mapping[str, str]], +) -> Dict[str, str]: + routes: Dict[str, str] = {} + for entry in outputs: + if entry.get("kind") != "mdx": + continue + content_id = str(entry.get("page_id") or "").strip() + relative_path = entry.get("path") + if not content_id or not isinstance(relative_path, str): + raise ContentRedirectError( + f"Invalid MDX manifest entry: {entry!r}" + ) + if not relative_path.endswith(".mdx"): + raise ContentRedirectError( + f"MDX manifest path must end with .mdx: {relative_path}" + ) + route = _validate_route(f"/{relative_path[:-4]}", "manifest route") + if content_id in routes: + raise ContentRedirectError( + f"Duplicate MDX output for content ID: {content_id}" + ) + routes[content_id] = route + return routes + + +def reconcile_content_redirects( + existing: Sequence[Mapping[str, str]], + previous_outputs: Sequence[Mapping[str, str]], + current_outputs: Sequence[Mapping[str, str]], + current_date: date, +) -> list[Dict[str, str]]: + """Prune expired redirects and apply current content route moves.""" + redirects = [ + dict(item) + for item in _validate_redirects(list(existing)) + if _parse_iso_date(item["expires_on"], "expires_on") > current_date + ] + previous_routes = _mdx_routes_by_content_id(previous_outputs) + current_routes = _mdx_routes_by_content_id(current_outputs) + live_routes = set(current_routes.values()) + + redirects = [ + item for item in redirects + if item["source"] not in live_routes + ] + preexisting_redirects = list(redirects) + + moves = sorted( + ( + content_id, + previous_routes[content_id], + current_routes[content_id], + ) + for content_id in previous_routes.keys() & current_routes.keys() + if previous_routes[content_id] != current_routes[content_id] + ) + + for _, old_route, new_route in moves: + for item in preexisting_redirects: + if item["destination"] == old_route: + item["destination"] = new_route + + existing_rule = next( + (item for item in redirects if item["source"] == old_route), + None, + ) + if existing_rule is None: + redirects.append({ + "source": old_route, + "destination": new_route, + "created_on": current_date.isoformat(), + "expires_on": ( + current_date + timedelta(days=REDIRECT_RETENTION_DAYS) + ).isoformat(), + }) + else: + existing_rule["destination"] = new_route + + redirects = [ + item for item in redirects + if item["source"] not in live_routes + and item["source"] != item["destination"] + ] + return sorted(redirects, key=lambda item: item["source"]) + + +def _dump_redirects(redirects: Sequence[Mapping[str, str]]) -> str: + return yaml.safe_dump( + list(redirects), + allow_unicode=True, + sort_keys=False, + ) + + +def update_content_redirects( + path: Path, + previous_outputs: Sequence[Mapping[str, str]], + current_outputs: Sequence[Mapping[str, str]], + current_date: date | None = None, +) -> list[Dict[str, str]]: + """Atomically update the redirect registry for a successful conversion.""" + effective_date = current_date or datetime.now(timezone.utc).date() + resolved_path = path.resolve() + existing = load_content_redirects(resolved_path) + redirects = reconcile_content_redirects( + existing, + previous_outputs, + current_outputs, + effective_date, + ) + serialized = _dump_redirects(redirects) + if ( + resolved_path.exists() + and resolved_path.read_text(encoding="utf-8") == serialized + ): + return redirects + + resolved_path.parent.mkdir(parents=True, exist_ok=True) + temp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=resolved_path.parent, + prefix=f".{resolved_path.name}.", + suffix=".tmp", + delete=False, + ) as temp_file: + temp_file.write(serialized) + temp_path = Path(temp_file.name) + temp_path.chmod(0o644) + os.replace(temp_path, resolved_path) + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink() + return redirects diff --git a/confluence-mdx/bin/convert_all.py b/confluence-mdx/bin/convert_all.py index 4168dda80..e41a64ec8 100755 --- a/confluence-mdx/bin/convert_all.py +++ b/confluence-mdx/bin/convert_all.py @@ -17,6 +17,7 @@ import subprocess import sys import tempfile +from datetime import date from pathlib import Path from typing import Any, Dict, List, Mapping, Sequence from urllib.parse import quote, urlsplit @@ -36,6 +37,7 @@ sys.path.insert(0, str(_SCRIPT_DIR)) from fetch.sync_profiles import SYNC_PROFILES +from content_redirects import update_content_redirects def _resolve(rel: str) -> str: @@ -555,8 +557,10 @@ def finalize_manifest( sync_code: str, current_outputs: Sequence[Mapping[str, str]], output_base_dir: Path, + redirects_path: Path | None = None, + redirect_date: date | None = None, ) -> None: - """Remove exclusively owned stale files and atomically replace the manifest.""" + """Update route redirects, remove stale files, and replace the manifest.""" output_root = output_base_dir.resolve() previous_outputs = _manifest_outputs(manifest_path, sync_code) @@ -581,6 +585,14 @@ def finalize_manifest( sync_code, output_root, ) + if redirects_path is not None: + update_content_redirects( + redirects_path, + previous_outputs, + current_outputs, + redirect_date, + ) + for stale_relative_path in sorted( set(previous_by_path) - set(current_by_path) - other_profile_paths, reverse=True, @@ -623,7 +635,8 @@ def convert_all(pages: List[Dict], var_dir: str, output_base_dir: str, public_di log_level: str, pages_yaml: str = '', manifest_path: str = '', sync_code: str = 'qm', base_url: str = _DEFAULT_CONFLUENCE_BASE_URL, - space_key: str = '') -> int: + space_key: str = '', redirects_path: str = '', + redirect_date: date | None = None) -> int: """Convert typed catalog nodes and return the number of failures.""" # Skip the root page root_page_id = pages[0]['page_id'] if pages else None @@ -724,6 +737,8 @@ def convert_all(pages: List[Dict], var_dir: str, output_base_dir: str, public_di sync_code, generated_outputs, output_base_path, + Path(redirects_path) if redirects_path else None, + redirect_date, ) except Exception as exc: failures += 1 @@ -748,6 +763,11 @@ def main(): help='Public assets directory (default: target/public)') parser.add_argument('--translations', default='etc/korean-titles-translations.txt', help='Path to translations file') + parser.add_argument( + '--redirects-file', + default='target/content-route-redirects.yaml', + help='Path to temporary content route redirects registry', + ) parser.add_argument('--base-url', default=_DEFAULT_CONFLUENCE_BASE_URL, help='Confluence base URL for generated folder links') parser.add_argument('--space-key', default=None, @@ -769,6 +789,7 @@ def main(): args.output_dir = _resolve(args.output_dir) args.public_dir = _resolve(args.public_dir) args.translations = _resolve(args.translations) + args.redirects_file = _resolve(args.redirects_file) manifest_path = os.path.join( args.var_dir, "convert-manifests", @@ -805,7 +826,8 @@ def main(): manifest_path=manifest_path, sync_code=args.sync_code, base_url=args.base_url, - space_key=space_key) + space_key=space_key, + redirects_path=args.redirects_file) if failures: print(f"\nCompleted with {failures} failure(s) out of {len(pages)} pages", file=sys.stderr) diff --git a/confluence-mdx/bin/fetch/config.py b/confluence-mdx/bin/fetch/config.py index c8730d3e5..76e4062b3 100644 --- a/confluence-mdx/bin/fetch/config.py +++ b/confluence-mdx/bin/fetch/config.py @@ -29,7 +29,6 @@ class Config: default_output_dir: str = "var" cache_dir: str = "cache" translations_file: str = "etc/korean-titles-translations.txt" - slug_overrides_file: str = "etc/content-slug-overrides.yaml" email: Optional[str] = None api_token: Optional[str] = None download_attachments: bool = False @@ -51,7 +50,6 @@ def __post_init__(self): 'default_output_dir', 'cache_dir', 'translations_file', - 'slug_overrides_file', ): value = getattr(self, field) if not os.path.isabs(value): diff --git a/confluence-mdx/bin/fetch/processor.py b/confluence-mdx/bin/fetch/processor.py index a79f57db1..950ac5195 100644 --- a/confluence-mdx/bin/fetch/processor.py +++ b/confluence-mdx/bin/fetch/processor.py @@ -27,7 +27,6 @@ def __init__(self, config: Config, logger: logging.Logger): self.file_manager = FileManager(logger) self.translation_service = TranslationService( config.translations_file, - config.slug_overrides_file, logger, ) @@ -39,7 +38,6 @@ def __init__(self, config: Config, logger: logging.Logger): # Load translations self.translation_service.load_translations() - self.translation_service.load_slug_overrides() def process_page_complete( self, diff --git a/confluence-mdx/bin/fetch/translation.py b/confluence-mdx/bin/fetch/translation.py index c11a8dd94..57e5840f1 100644 --- a/confluence-mdx/bin/fetch/translation.py +++ b/confluence-mdx/bin/fetch/translation.py @@ -4,8 +4,6 @@ import os from typing import List, Optional, Protocol -import yaml - from fetch.exceptions import TranslationError from fetch.models import Page from text_utils import slugify @@ -17,9 +15,6 @@ class TranslationServiceProtocol(Protocol): def load_translations(self) -> None: ... - def load_slug_overrides(self) -> None: - ... - def translate(self, content: str) -> str: ... @@ -37,14 +32,11 @@ class TranslationService: def __init__( self, translations_file: str, - slug_overrides_file: str, logger: logging.Logger, ): self.translations_file = translations_file - self.slug_overrides_file = slug_overrides_file self.logger = logger self.translations = {} - self.slug_overrides = {} def load_translations(self) -> None: """Load translations from the translations file""" @@ -71,52 +63,6 @@ def load_translations(self) -> None: self.logger.error(f"Error loading translations from {self.translations_file}: {str(e)}") raise TranslationError(f"Failed to load translations: {str(e)}") - def load_slug_overrides(self) -> None: - """Load content ID to canonical slug overrides.""" - if not os.path.exists(self.slug_overrides_file): - self.logger.warning( - f"Slug overrides file not found: {self.slug_overrides_file}" - ) - return - - try: - with open(self.slug_overrides_file, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) - if data is None: - return - if not isinstance(data, dict): - raise TranslationError( - "Slug overrides must be a content ID to slug mapping" - ) - - for content_id_value, slug_value in data.items(): - content_id = str(content_id_value).strip() - if not content_id or not isinstance(slug_value, str): - raise TranslationError( - f"Invalid slug override: {content_id_value!r}: {slug_value!r}" - ) - slug = slug_value.strip() - if not slug or slugify(slug) != slug: - raise TranslationError( - f"Slug override must be a canonical slug: {slug_value!r}" - ) - self.slug_overrides[content_id] = slug - - self.logger.info( - f"Loaded {len(self.slug_overrides)} slug overrides " - f"from {self.slug_overrides_file}" - ) - except TranslationError: - raise - except Exception as e: - self.logger.error( - f"Error loading slug overrides from " - f"{self.slug_overrides_file}: {str(e)}" - ) - raise TranslationError( - f"Failed to load slug overrides: {str(e)}" - ) from e - def translate(self, content: str) -> str: """Translate Korean titles in content to English""" if not self.translations: @@ -159,12 +105,3 @@ def translate_page( ] else: page.path = list(parent_path) - - slug_override = self.slug_overrides.get(str(page.page_id)) - if slug_override: - if not page.path: - raise TranslationError( - f"Cannot apply slug override to content without a path: " - f"{page.page_id}" - ) - page.path[-1] = slug_override diff --git a/confluence-mdx/bin/skeleton/ignore_rules.yaml b/confluence-mdx/bin/skeleton/ignore_rules.yaml index 5294e66d8..355e73c0f 100644 --- a/confluence-mdx/bin/skeleton/ignore_rules.yaml +++ b/confluence-mdx/bin/skeleton/ignore_rules.yaml @@ -79,10 +79,10 @@ ignores: # 사용자가 복사하는 checklist template을 target locale로 번역한 경우 # Lines 326-345: 한국어 template을 영어로 번역 - - file: target/en/support/querypie-acp-operational-log-collection-guide.mdx + - file: target/en/support/operational-log-collection-guide.mdx line_numbers: [326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345] # 사용자가 복사하는 checklist template을 target locale로 번역한 경우 # Lines 326-345: 한국어 template을 일본어로 번역 - - file: target/ja/support/querypie-acp-operational-log-collection-guide.mdx + - file: target/ja/support/operational-log-collection-guide.mdx line_numbers: [326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345] diff --git a/confluence-mdx/compose.yml b/confluence-mdx/compose.yml index 8549941bf..c7d817c71 100644 --- a/confluence-mdx/compose.yml +++ b/confluence-mdx/compose.yml @@ -58,6 +58,8 @@ services: # Persist generated-output ownership across `docker compose run --rm` # while allowing atomic replacement of manifest files. - ./var/convert-manifests:/workdir/var/convert-manifests + # Persist temporary content route redirects and their expiration metadata. + - ../src/content-route-redirects.yaml:/workdir/target/content-route-redirects.yaml # Mount output directories to host (matching symlink structure in target/) # target/ko -> ../../src/content/ko - ../src/content/ko:/workdir/target/ko diff --git a/confluence-mdx/etc/content-slug-overrides.yaml b/confluence-mdx/etc/content-slug-overrides.yaml deleted file mode 100644 index f9c73ac3a..000000000 --- a/confluence-mdx/etc/content-slug-overrides.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Confluence content ID to stable public route segment. -'2262630428': web-client diff --git a/confluence-mdx/etc/korean-titles-translations.txt b/confluence-mdx/etc/korean-titles-translations.txt index c21713ff4..a812f09f1 100644 --- a/confluence-mdx/etc/korean-titles-translations.txt +++ b/confluence-mdx/etc/korean-titles-translations.txt @@ -186,4 +186,4 @@ MCP 설정 가이드 | MCP Configuration Guide 기술지원 | Technical Support 프리미엄 지원 | Premium Support Standard Edition 라이선스 정책 | Standard Edition License Policy -QueryPie ACP 운영 로그 수집 가이드 | QueryPie ACP Operational Log Collection Guide +운영 로그 수집 가이드 | Operational Log Collection Guide diff --git a/confluence-mdx/target/content-route-redirects.yaml b/confluence-mdx/target/content-route-redirects.yaml new file mode 120000 index 000000000..f30163d6c --- /dev/null +++ b/confluence-mdx/target/content-route-redirects.yaml @@ -0,0 +1 @@ +../../src/content-route-redirects.yaml \ No newline at end of file diff --git a/confluence-mdx/tests/test_convert_all_folders.py b/confluence-mdx/tests/test_convert_all_folders.py index 41d28a5e5..bd829afc0 100644 --- a/confluence-mdx/tests/test_convert_all_folders.py +++ b/confluence-mdx/tests/test_convert_all_folders.py @@ -1,5 +1,6 @@ import subprocess from argparse import Namespace +from datetime import date from pathlib import Path import pytest @@ -12,6 +13,7 @@ generate_folder_mdx, generate_navigation, ) +from content_redirects import reconcile_content_redirects def _write_yaml(path: Path, data) -> None: @@ -247,6 +249,196 @@ def test_manifest_removes_only_previous_owned_outputs(tmp_path): assert manifest_path.stat().st_mode & 0o777 == 0o644 +def test_manifest_records_route_move_with_eight_week_redirect(tmp_path): + output_dir = tmp_path / "output" + manifest_path = ( + tmp_path / "var" / "convert-manifests" / "convert-manifest.qm.yaml" + ) + redirects_path = tmp_path / "content-redirects.yaml" + old_output = output_dir / "support" / "old-title.mdx" + new_output = output_dir / "support" / "new-title.mdx" + old_output.parent.mkdir(parents=True) + old_output.write_text("old", encoding="utf-8") + new_output.write_text("new", encoding="utf-8") + _write_yaml(manifest_path, { + "version": 1, + "sync_code": "qm", + "outputs": [{ + "page_id": "page-1", + "type": "page", + "kind": "mdx", + "path": "support/old-title.mdx", + }], + }) + _write_yaml(redirects_path, []) + current_outputs = [{ + "page_id": "page-1", + "type": "page", + "kind": "mdx", + "path": "support/new-title.mdx", + }] + + finalize_manifest( + manifest_path, + "qm", + current_outputs, + output_dir, + redirects_path, + date(2026, 7, 28), + ) + + assert not old_output.exists() + assert yaml.safe_load(redirects_path.read_text()) == [{ + "source": "/support/old-title", + "destination": "/support/new-title", + "created_on": "2026-07-28", + "expires_on": "2026-09-22", + }] + + +def test_manifest_prunes_expired_redirects(tmp_path): + output_dir = tmp_path / "output" + manifest_path = ( + tmp_path / "var" / "convert-manifests" / "convert-manifest.qm.yaml" + ) + redirects_path = tmp_path / "content-redirects.yaml" + current_output = output_dir / "current.mdx" + current_output.parent.mkdir(parents=True) + current_output.write_text("current", encoding="utf-8") + output = { + "page_id": "page-1", + "type": "page", + "kind": "mdx", + "path": "current.mdx", + } + _write_yaml(manifest_path, { + "version": 1, + "sync_code": "qm", + "outputs": [output], + }) + _write_yaml(redirects_path, [ + { + "source": "/expired", + "destination": "/current", + "created_on": "2026-05-01", + "expires_on": "2026-06-26", + }, + { + "source": "/active", + "destination": "/current", + "created_on": "2026-07-01", + "expires_on": "2026-08-26", + }, + ]) + + finalize_manifest( + manifest_path, + "qm", + [output], + output_dir, + redirects_path, + date(2026, 7, 28), + ) + + assert yaml.safe_load(redirects_path.read_text()) == [{ + "source": "/active", + "destination": "/current", + "created_on": "2026-07-01", + "expires_on": "2026-08-26", + }] + + +def test_consecutive_route_moves_collapse_redirect_chain(): + redirects = reconcile_content_redirects( + [{ + "source": "/title-a", + "destination": "/title-b", + "created_on": "2026-07-01", + "expires_on": "2026-08-26", + }], + [{ + "page_id": "page-1", + "type": "page", + "kind": "mdx", + "path": "title-b.mdx", + }], + [{ + "page_id": "page-1", + "type": "page", + "kind": "mdx", + "path": "title-c.mdx", + }], + date(2026, 7, 28), + ) + + assert redirects == [ + { + "source": "/title-a", + "destination": "/title-c", + "created_on": "2026-07-01", + "expires_on": "2026-08-26", + }, + { + "source": "/title-b", + "destination": "/title-c", + "created_on": "2026-07-28", + "expires_on": "2026-09-22", + }, + ] + + +@pytest.mark.parametrize( + ("content_a_id", "content_b_id"), + [ + ("page-1", "page-2"), + ("page-2", "page-1"), + ], +) +def test_route_reuse_preserves_redirect_created_in_same_pass( + content_a_id, + content_b_id, +): + redirects = reconcile_content_redirects( + [], + [ + { + "page_id": content_a_id, + "type": "page", + "kind": "mdx", + "path": "title-a.mdx", + }, + { + "page_id": content_b_id, + "type": "page", + "kind": "mdx", + "path": "title-b.mdx", + }, + ], + [ + { + "page_id": content_a_id, + "type": "page", + "kind": "mdx", + "path": "title-b.mdx", + }, + { + "page_id": content_b_id, + "type": "page", + "kind": "mdx", + "path": "title-c.mdx", + }, + ], + date(2026, 7, 28), + ) + + assert redirects == [{ + "source": "/title-a", + "destination": "/title-b", + "created_on": "2026-07-28", + "expires_on": "2026-09-22", + }] + + def test_manifest_preserves_stale_output_owned_by_another_profile(tmp_path): output_dir = tmp_path / "output" manifest_dir = tmp_path / "var" / "convert-manifests" diff --git a/confluence-mdx/tests/test_fetch_folders.py b/confluence-mdx/tests/test_fetch_folders.py index 1c9d5fee6..53e8e5099 100644 --- a/confluence-mdx/tests/test_fetch_folders.py +++ b/confluence-mdx/tests/test_fetch_folders.py @@ -20,7 +20,6 @@ def _config(tmp_path: Path, *, mode: str = "local", root_type: str = "page") -> default_output_dir=str(tmp_path / "var"), cache_dir=str(tmp_path / "cache"), translations_file=str(tmp_path / "translations.txt"), - slug_overrides_file=str(tmp_path / "content-slug-overrides.yaml"), default_start_page_id="root", root_content_type=root_type, mode=mode, @@ -351,7 +350,7 @@ def test_local_mixed_tree_preserves_types_paths_order_and_warns( ) in caplog.text -def test_local_tree_separates_display_translation_from_canonical_slug( +def test_local_tree_uses_display_translation_for_canonical_slug( tmp_path, ): config = _config(tmp_path, mode="local") @@ -361,11 +360,6 @@ def test_local_tree_separates_display_translation_from_canonical_slug( "자식 문서 | Child Document\n", encoding="utf-8", ) - _write_yaml( - Path(config.slug_overrides_file), - {"parent": "stable-parent"}, - ) - _write_yaml(var_dir / "root" / "page.v1.yaml", _page_data("root", "Root")) _write_yaml(var_dir / "root" / "page.v2.yaml", {"id": "root", "title": "Root"}) _write_yaml(var_dir / "root" / "children.v2.yaml", { @@ -411,23 +405,24 @@ def test_local_tree_separates_display_translation_from_canonical_slug( )) assert nodes[1].breadcrumbs_en == ["Descriptive Parent Title"] - assert nodes[1].path == ["stable-parent"] + assert nodes[1].path == ["descriptive-parent-title"] assert nodes[2].breadcrumbs_en == [ "Descriptive Parent Title", "Child Document", ] - assert nodes[2].path == ["stable-parent", "child-document"] + assert nodes[2].path == [ + "descriptive-parent-title", + "child-document", + ] -def test_web_client_uses_full_translation_and_stable_repository_slug(): +def test_web_client_uses_full_translation_for_canonical_slug(): project_dir = Path(__file__).resolve().parents[1] service = TranslationService( str(project_dir / "etc" / "korean-titles-translations.txt"), - str(project_dir / "etc" / "content-slug-overrides.yaml"), logging.getLogger(__name__), ) service.load_translations() - service.load_slug_overrides() page = ContentNode( page_id="2262630428", title="Web Client로 쿠버네티스 클러스터 접속하기", @@ -449,7 +444,7 @@ def test_web_client_uses_full_translation_and_stable_repository_slug(): assert page.path == [ "user-manual", "kubernetes-access-control", - "web-client", + "connecting-to-kubernetes-clusters-with-web-client", ] diff --git a/confluence-mdx/var/convert-manifests/convert-manifest.qm.yaml b/confluence-mdx/var/convert-manifests/convert-manifest.qm.yaml index 8ec349591..13bf4e9e1 100644 --- a/confluence-mdx/var/convert-manifests/convert-manifest.qm.yaml +++ b/confluence-mdx/var/convert-manifests/convert-manifest.qm.yaml @@ -1365,14 +1365,14 @@ outputs: type: page kind: navigation path: support/_meta.ts -- page_id: '1853358081' +- page_id: '2288353307' type: page kind: mdx - path: support/premium-support.mdx -- page_id: '2288353307' + path: support/operational-log-collection-guide.mdx +- page_id: '1853358081' type: page kind: mdx - path: support/querypie-acp-operational-log-collection-guide.mdx + path: support/premium-support.mdx - page_id: '1923285023' type: page kind: mdx @@ -1448,7 +1448,7 @@ outputs: - page_id: '2262630428' type: page kind: mdx - path: user-manual/kubernetes-access-control/web-client.mdx + path: user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client.mdx - page_id: '2168455203' type: folder kind: mdx diff --git a/confluence-mdx/var/pages.qm.yaml b/confluence-mdx/var/pages.qm.yaml index 8d182a50b..a301a21d4 100644 --- a/confluence-mdx/var/pages.qm.yaml +++ b/confluence-mdx/var/pages.qm.yaml @@ -505,7 +505,7 @@ "path": - "user-manual" - "kubernetes-access-control" - - "web-client" + - "connecting-to-kubernetes-clusters-with-web-client" - "page_id": "1064829218" "type": "page" "title": "Web Access Control" @@ -5489,17 +5489,17 @@ - "standard-edition-license-policy" - "page_id": "2288353307" "type": "page" - "title": "QueryPie ACP 운영 로그 수집 가이드" - "title_orig": "QueryPie ACP 운영 로그 수집 가이드" + "title": "운영 로그 수집 가이드" + "title_orig": "운영 로그 수집 가이드" "breadcrumbs": - "지원" - - "QueryPie ACP 운영 로그 수집 가이드" + - "운영 로그 수집 가이드" "breadcrumbs_en": - "Support" - - "QueryPie ACP Operational Log Collection Guide" + - "Operational Log Collection Guide" "path": - "support" - - "querypie-acp-operational-log-collection-guide" + - "operational-log-collection-guide" - "page_id": "1911423023" "type": "page" "title": "Unreleased" diff --git a/next.config.ts b/next.config.ts index ca2920989..33e333ddc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,9 @@ import nextra from 'nextra'; import { releaseNotesRedirects } from './src/content/release-notes/_redirects'; +import { + expandContentRouteRedirects, + loadActiveContentRouteRedirects, +} from './src/lib/content-route-redirects'; // Set up Nextra with its configuration // Note: nextra includes remark-gfm by default, so no custom mdxOptions needed @@ -55,6 +59,9 @@ export default withNextra({ }, // Configure redirects for Previous Version Documentation async redirects() { + const contentRouteRedirectRules = expandContentRouteRedirects( + loadActiveContentRouteRedirects(), + ); // release-notes 리다이렉트: src/content/release-notes/_redirects.ts에서 관리 const releaseNotesRedirectRules = releaseNotesRedirects.flatMap(([oldPath, newPath]) => [ { @@ -70,6 +77,7 @@ export default withNextra({ ]); return [ + ...contentRouteRedirectRules, ...releaseNotesRedirectRules, // querypie-overview -> overview 경로 변경에 대한 redirect (2026-01-22) diff --git a/openspec/changes/README.md b/openspec/changes/README.md index 55f6225dc..9d78d2d6e 100644 --- a/openspec/changes/README.md +++ b/openspec/changes/README.md @@ -7,6 +7,7 @@ | Change | 목적 | 상태 | | --- | --- | --- | | [`complete-reverse-sync`](./complete-reverse-sync/proposal.md) | snapshot-bound MDX → Confluence page update 계약과 구현 계획을 정의합니다. | proposed | +| [`title-derived-content-routes`](./title-derived-content-routes/proposal.md) | Confluence 제목 기반 canonical route와 8주 임시 redirect lifecycle을 정의합니다. | proposed | ## Change 구조 diff --git a/openspec/changes/title-derived-content-routes/design.md b/openspec/changes/title-derived-content-routes/design.md new file mode 100644 index 000000000..505f9c126 --- /dev/null +++ b/openspec/changes/title-derived-content-routes/design.md @@ -0,0 +1,76 @@ +## Context + +Confluence catalog는 content ID, 현재 제목의 영어 번역, 그리고 그 번역에서 생성한 path를 보관합니다. Conversion manifest는 content ID별로 실제 생성한 MDX path를 보관하므로, 같은 content ID의 manifest path가 달라지면 제목 또는 상위 계층 변경에 따른 public route 이동으로 판별할 수 있습니다. + +기존 `content-slug-overrides.yaml`은 표시 제목과 canonical slug를 분리해 route를 고정했습니다. 이 동작은 제목 변경을 public route에도 반영한다는 새 정책과 충돌합니다. + +## Goals / Non-Goals + +Goals: + +- 현재 문서 제목의 영어 번역을 canonical route의 최우선 기준으로 사용합니다. +- route 이동 시 기존 링크에 8주간 임시 redirect를 제공합니다. +- 생성일과 만료일을 review 가능한 repository data로 보존합니다. +- 만료된 redirect가 배포 설정에 남거나 계속 서비스되지 않도록 합니다. +- 연속 rename과 상위 문서 이동에서 redirect chain을 만들지 않습니다. + +Non-Goals: + +- 과거의 모든 수동 redirect를 같은 registry로 즉시 이전하지 않습니다. +- attachment URL 이동과 cleanup은 이번 변경 범위에 포함하지 않습니다. +- 외부 사이트로 향하는 legacy release note redirect 정책은 변경하지 않습니다. + +## Decisions + +### Decision: canonical route는 현재 제목 번역에서 생성합니다 + +Fetcher는 `breadcrumbs_en` 각 항목을 현재 title translation으로 계산하고 `slugify`하여 path를 생성합니다. Content ID 기반 stable slug override는 적용하지 않습니다. 상위 제목이 바뀌면 descendant path도 현재 breadcrumb 계층에 맞게 함께 이동합니다. + +### Decision: conversion manifest의 content ID를 route 이동 identity로 사용합니다 + +`convert_all.py`는 성공한 conversion을 finalization할 때 이전 manifest와 현재 output에서 `kind: mdx`인 항목을 content ID로 비교합니다. 같은 content ID의 `.mdx` path가 달라지면 이전 확장자를 제거한 route에서 새 route로 redirect를 생성합니다. + +Navigation `_meta.ts` 변경과 삭제된 content는 redirect 생성 대상이 아닙니다. + +### Decision: redirect registry는 locale 독립 exact route를 저장합니다 + +`src/content-route-redirects.yaml`은 다음 필드를 가진 record 목록을 저장합니다. + +- `source`: locale prefix가 없는 이전 exact route +- `destination`: locale prefix가 없는 현재 exact route +- `created_on`: `YYYY-MM-DD` UTC 생성일 +- `expires_on`: `created_on`부터 기본 56일 뒤의 `YYYY-MM-DD` 만료일 + +Next.js loader는 각 active record를 `ko`, `en`, `ja` locale route와 locale 없는 route에 대한 임시 redirect로 확장합니다. Redirect는 영구 cache를 피하기 위해 `permanent: false`를 사용합니다. + +### Decision: expiration은 runtime 제외와 persisted cleanup을 함께 적용합니다 + +`current_date >= expires_on`인 record는 active redirect가 아니며 Next.js route 설정에서 제외합니다. Confluence conversion이 성공해 manifest를 finalization할 때 같은 조건의 record를 registry에서 제거합니다. + +따라서 conversion 주기와 무관하게 만료 시점 이후 배포에서는 redirect가 서비스되지 않으며, 다음 conversion에서는 repository record도 삭제됩니다. + +### Decision: 연속 rename은 최종 목적지로 접습니다 + +기존 active redirect의 `destination`이 이번 이동의 이전 route와 같으면 생성일과 만료일을 유지한 채 새 route로 목적지를 갱신합니다. 이번 이동의 이전 route에는 별도 8주 redirect를 생성합니다. + +새 live route와 같은 `source`를 가진 과거 redirect는 제거해 실제 content route와 redirect가 충돌하지 않도록 합니다. + +Chain collapse 대상은 reconciliation을 시작할 때 이미 존재한 active redirect로 제한합니다. 같은 conversion에서 새로 생성한 redirect는 다른 content가 비운 route를 우연히 destination으로 사용할 수 있으므로, 이후 처리하는 다른 content의 이동에 따라 목적지를 다시 변경하지 않습니다. + +## Risks / Trade-offs + +- 제목 번역 수정만으로도 public route가 바뀌므로 번역 review가 route review를 포함하게 됩니다. +- 상위 문서 제목 변경은 여러 descendant exact redirect를 만들 수 있습니다. Wildcard redirect보다 record 수는 늘지만, content ID별 이동을 명시적으로 검증할 수 있고 다른 route를 과도하게 포착하지 않습니다. +- Registry cleanup은 conversion에서 persisted data를 갱신합니다. Conversion 사이에도 runtime loader가 만료 record를 제외하므로 만료된 redirect가 계속 서비스되는 문제는 없습니다. + +## Migration Plan + +1. content ID 기반 slug override 지원과 현재 override data를 제거합니다. +2. 기존 conversion manifest를 기준으로 새 제목 route를 생성합니다. +3. 이번에 이동하는 content별 8주 redirect를 registry에 기록합니다. +4. 한국어 문서를 새 route로 변환하고 영어·일본어 파일도 같은 route로 이동합니다. +5. locale별 이전 route가 새 route로 redirect되고 만료 record가 제외되는지 검증합니다. + +## Open Questions + +- 없음. diff --git a/openspec/changes/title-derived-content-routes/proposal.md b/openspec/changes/title-derived-content-routes/proposal.md new file mode 100644 index 000000000..2d76e49ec --- /dev/null +++ b/openspec/changes/title-derived-content-routes/proposal.md @@ -0,0 +1,31 @@ +## Why + +Confluence 문서 제목이 바뀌어도 content ID 기반 slug override로 기존 public route를 유지하면 제목 변경이 route에 반영되지 않습니다. 문서 제목을 canonical route의 기준으로 일관되게 사용하면서도, 이전 링크가 즉시 404로 바뀌지 않도록 제한된 기간의 redirect가 필요합니다. + +## What Changes + +- Confluence content의 canonical route는 현재 영어 제목 번역을 slugify한 경로를 사용합니다. +- 기존 route를 보존하는 content ID 기반 slug override를 제거합니다. +- conversion manifest에서 같은 content ID의 이전·현재 MDX 경로를 비교해 route 변경을 자동 감지합니다. +- 변경된 이전 route에는 생성일과 만료일을 가진 8주 임시 redirect를 생성합니다. +- 만료된 redirect는 runtime route 설정에서 제외하고, 다음 conversion에서 registry에서도 제거합니다. +- 같은 content가 연속해서 이름을 바꾸면 기존 redirect의 목적지를 최신 route로 갱신해 redirect chain을 방지합니다. + +## Capabilities + +### New Capabilities + +- `platform-docs-site-routing`: 제목 변경에 따른 canonical route 이동과 기간 제한 redirect lifecycle을 관리합니다. + +### Modified Capabilities + +- Confluence MDX conversion은 stable slug override 대신 현재 제목 번역에서 output path를 계산합니다. +- conversion manifest finalization은 content route 이동을 redirect registry에 반영합니다. + +## Impact + +- `confluence-mdx/bin/fetch/**`: title translation 기반 path 생성 계약을 단순화합니다. +- `confluence-mdx/bin/convert_all.py`: manifest path 변경 감지와 redirect lifecycle 갱신을 추가합니다. +- `src/content-route-redirects.yaml`: content route redirect의 source of truth가 됩니다. +- `next.config.ts`: 유효 기간 안의 content route redirect를 locale별 runtime rule로 확장합니다. +- 기존 `web-client` route와 운영 로그 수집 가이드 route를 새 제목 기반 route로 이동합니다. diff --git a/openspec/changes/title-derived-content-routes/specs/platform-docs-site-routing/spec.md b/openspec/changes/title-derived-content-routes/specs/platform-docs-site-routing/spec.md new file mode 100644 index 000000000..e595ee3a9 --- /dev/null +++ b/openspec/changes/title-derived-content-routes/specs/platform-docs-site-routing/spec.md @@ -0,0 +1,104 @@ +# platform-docs-site-routing + +## Purpose + +Confluence 제목 변경을 public canonical route에 반영하고 이전 route의 제한된 호환 기간을 관리하는 계약을 정의합니다. + +## References + +- `confluence-mdx/bin/fetch/translation.py` +- `confluence-mdx/bin/convert_all.py` +- `src/content-route-redirects.yaml` +- `next.config.ts` + +## Requirements + +### Requirement: Title-derived canonical route + +Confluence에서 생성하는 content의 canonical route는 현재 문서 제목의 영어 번역을 slugify한 breadcrumb path를 사용해야 합니다(SHALL). 이전 route를 유지하기 위한 content ID 기반 slug override를 적용해서는 안 됩니다(SHALL NOT). + +#### Scenario: 문서 제목 변경 + +- GIVEN 같은 content ID의 문서 제목 번역이 변경되었습니다. +- WHEN Confluence catalog와 MDX를 다시 생성합니다. +- THEN canonical route는 새 제목 번역에서 생성되어야 합니다(SHALL). +- AND 이전 제목에서 생성한 route를 content output으로 보존해서는 안 됩니다(SHALL NOT). + +#### Scenario: 상위 문서 제목 변경 + +- GIVEN 상위 문서 제목 번역이 변경되었습니다. +- WHEN descendant content의 breadcrumb path를 다시 생성합니다. +- THEN 상위 문서와 모든 descendant의 canonical route는 새 breadcrumb path를 사용해야 합니다(SHALL). + +### Requirement: Route move detection + +Converter는 성공한 conversion의 이전·현재 manifest에서 같은 content ID의 `kind: mdx` path를 비교해 route 이동을 감지해야 합니다(SHALL). + +#### Scenario: 같은 content ID의 output path 변경 + +- GIVEN 이전 manifest에 content ID의 기존 MDX path가 있습니다. +- AND 현재 conversion에 같은 content ID의 다른 MDX path가 있습니다. +- WHEN manifest를 finalization합니다. +- THEN 이전 route에서 현재 route로 redirect record를 생성해야 합니다(SHALL). + +#### Scenario: content 삭제 + +- GIVEN 이전 manifest의 content ID가 현재 conversion에서 사라졌습니다. +- WHEN manifest를 finalization합니다. +- THEN 목적지가 없는 redirect를 생성해서는 안 됩니다(SHALL NOT). + +### Requirement: Eight-week redirect lifecycle + +새 redirect record는 UTC `created_on`과 기본 56일 뒤의 `expires_on`을 가져야 합니다(SHALL). `current_date >= expires_on`인 redirect는 active rule에서 제거해야 합니다(SHALL). + +#### Scenario: redirect 생성 + +- GIVEN `2026-07-28`에 route 이동을 감지했습니다. +- WHEN redirect record를 생성합니다. +- THEN `created_on`은 `2026-07-28`이어야 합니다(SHALL). +- AND `expires_on`은 `2026-09-22`이어야 합니다(SHALL). + +#### Scenario: redirect 만료 + +- GIVEN redirect의 `expires_on`이 current date와 같거나 이전입니다. +- WHEN Next.js redirect 설정을 생성합니다. +- THEN 해당 redirect를 runtime rule에 포함해서는 안 됩니다(SHALL NOT). +- WHEN 다음 Confluence conversion을 finalization합니다. +- THEN 해당 redirect record를 persisted registry에서 제거해야 합니다(SHALL). + +### Requirement: Temporary locale redirects + +Active content route redirect는 `ko`, `en`, `ja` locale route와 locale prefix가 없는 route에 적용해야 하며(SHALL), 영구 redirect로 cache해서는 안 됩니다(SHALL NOT). + +#### Scenario: locale route 접근 + +- GIVEN `/support/old-route`에서 `/support/new-route`로 이동한 active redirect가 있습니다. +- WHEN `/ko/support/old-route`, `/en/support/old-route`, `/ja/support/old-route` 중 하나에 접근합니다. +- THEN 같은 locale의 `/support/new-route`로 임시 redirect해야 합니다(SHALL). + +#### Scenario: locale prefix 없는 route 접근 + +- GIVEN active content route redirect가 있습니다. +- WHEN locale prefix 없는 이전 route에 접근합니다. +- THEN locale prefix 없는 새 route로 임시 redirect해야 합니다(SHALL). + +### Requirement: Redirect chain prevention + +같은 content가 redirect 유지 기간 안에 다시 이동하면 기존 redirect의 목적지를 최신 canonical route로 갱신해야 합니다(SHALL). 새 live route와 source가 같은 과거 redirect는 제거해야 합니다(SHALL). 같은 conversion에서 서로 다른 content가 비워진 route를 재사용하면 각 이전 route는 해당 route를 비운 content의 최신 route를 가리켜야 합니다(SHALL). + +#### Scenario: 연속 제목 변경 + +- GIVEN active redirect `/title-a` → `/title-b`가 있습니다. +- WHEN 같은 content가 `/title-b`에서 `/title-c`로 이동합니다. +- THEN `/title-a`의 목적지는 `/title-c`로 갱신되어야 합니다(SHALL). +- AND `/title-b` → `/title-c` redirect를 생성해야 합니다(SHALL). +- AND `/title-a` redirect의 기존 생성일과 만료일을 연장해서는 안 됩니다(SHALL NOT). + +#### Scenario: 같은 conversion에서 비워진 route 재사용 + +- GIVEN content A가 `/title-a`에서 `/title-b`로 이동합니다. +- AND content B가 같은 conversion에서 `/title-b`에서 `/title-c`로 이동합니다. +- WHEN redirect registry를 reconcile합니다. +- THEN `/title-a`는 content A의 최신 live route인 `/title-b`로 redirect해야 합니다(SHALL). +- AND live route인 `/title-b`를 source로 하는 redirect를 생성해서는 안 됩니다(SHALL NOT). +- AND 같은 reconciliation에서 새로 생성한 `/title-a` redirect를 content B의 이동에 따라 `/title-c`로 변경해서는 안 됩니다(SHALL NOT). diff --git a/openspec/changes/title-derived-content-routes/tasks.md b/openspec/changes/title-derived-content-routes/tasks.md new file mode 100644 index 000000000..21da6f7c7 --- /dev/null +++ b/openspec/changes/title-derived-content-routes/tasks.md @@ -0,0 +1,30 @@ +## 1. Contract + +- [x] 1.1 현재 제목 기반 canonical route와 8주 redirect lifecycle을 change-local spec에 정의합니다. +- [x] 1.2 redirect registry schema, expiration 의미, 연속 rename 처리 방식을 design에 기록합니다. + +## 2. Implementation + +- [x] 2.1 content ID 기반 slug override 적용 경로와 설정을 제거합니다. +- [x] 2.2 conversion manifest의 content ID별 MDX path 변경에서 redirect를 생성합니다. +- [x] 2.3 redirect에 UTC 생성일과 기본 56일 만료일을 기록하고 만료 record를 정리합니다. +- [x] 2.4 active redirect를 locale별 Next.js 임시 redirect로 확장합니다. +- [x] 2.5 변경된 한국어 route와 영어·일본어 대응 route를 새 제목 경로로 이동합니다. + +## 3. Verification + +- [x] 3.1 title translation에서 새 canonical route가 생성되는 unit test를 추가합니다. +- [x] 3.2 route 이동, route 재사용, 56일 만료일, 만료 cleanup, 연속 rename을 Python test로 검증합니다. +- [x] 3.3 active/expired registry loading과 locale redirect 확장을 TypeScript test로 검증합니다. +- [x] 3.4 변경된 ko/en/ja 문서의 Skeleton 구조와 내부 link를 검증합니다. +- [x] 3.5 lint, converter test, Next build를 실행합니다. + +## 4. Spec / 구현 drift 확인 + +- [x] 4.1 `content-slug-overrides` 또는 stable route 보존을 canonical 정책으로 설명하는 active guidance가 남아 있지 않은지 검색합니다. +- [x] 4.2 만료된 redirect가 runtime 설정에 포함되거나 registry cleanup에서 누락되는 경로가 없는지 확인합니다. + +## 5. OpenSpec Cleanup + +- [ ] 5.1 변경이 accepted되면 `platform-docs-site-routing` accepted spec inventory를 갱신합니다. +- [ ] 5.2 구현과 검증이 완료된 change를 archive합니다. diff --git a/public/support/operational-log-collection-guide/image-20260707-104923.png b/public/support/operational-log-collection-guide/image-20260707-104923.png new file mode 100644 index 000000000..6810b5ec7 Binary files /dev/null and b/public/support/operational-log-collection-guide/image-20260707-104923.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-095819.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-095819.png new file mode 100644 index 000000000..b2716bdb2 Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-095819.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-100251.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-100251.png new file mode 100644 index 000000000..a56367e7c Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-100251.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-101427.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-101427.png new file mode 100644 index 000000000..5e58d2fb1 Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-101427.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-101804.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-101804.png new file mode 100644 index 000000000..2a8064031 Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-101804.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-102425.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-102425.png new file mode 100644 index 000000000..33ccf4255 Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/image-20260714-102425.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-log-viewer.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-log-viewer.png new file mode 100644 index 000000000..4baa6862b Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-log-viewer.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-events.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-events.png new file mode 100644 index 000000000..209712042 Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-events.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-overview.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-overview.png new file mode 100644 index 000000000..3d0fe9a84 Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-overview.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-relations.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-relations.png new file mode 100644 index 000000000..7d86821a4 Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-relations.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-yaml.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-yaml.png new file mode 100644 index 000000000..8591dfbbb Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-detail-yaml.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-list.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-list.png new file mode 100644 index 000000000..8d658a39f Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-resource-list.png differ diff --git a/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-shell-terminal.png b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-shell-terminal.png new file mode 100644 index 000000000..d0be24262 Binary files /dev/null and b/public/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client/web-client-shell-terminal.png differ diff --git a/src/content-route-redirects.yaml b/src/content-route-redirects.yaml new file mode 100644 index 000000000..91224b196 --- /dev/null +++ b/src/content-route-redirects.yaml @@ -0,0 +1,8 @@ +- source: /support/querypie-acp-operational-log-collection-guide + destination: /support/operational-log-collection-guide + created_on: '2026-07-28' + expires_on: '2026-09-22' +- source: /user-manual/kubernetes-access-control/web-client + destination: /user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client + created_on: '2026-07-28' + expires_on: '2026-09-22' diff --git a/src/content/en/support/_meta.ts b/src/content/en/support/_meta.ts index eddec2b04..4f2b96f8f 100644 --- a/src/content/en/support/_meta.ts +++ b/src/content/en/support/_meta.ts @@ -1,4 +1,4 @@ export default { 'premium-support': 'Premium Support', - 'querypie-acp-operational-log-collection-guide': 'QueryPie ACP Operational Log Collection Guide', + 'operational-log-collection-guide': 'Operational Log Collection Guide', }; diff --git a/src/content/en/support/querypie-acp-operational-log-collection-guide.mdx b/src/content/en/support/operational-log-collection-guide.mdx similarity index 98% rename from src/content/en/support/querypie-acp-operational-log-collection-guide.mdx rename to src/content/en/support/operational-log-collection-guide.mdx index 7cccf7cac..23faded56 100644 --- a/src/content/en/support/querypie-acp-operational-log-collection-guide.mdx +++ b/src/content/en/support/operational-log-collection-guide.mdx @@ -1,11 +1,11 @@ --- -title: 'QueryPie ACP Operational Log Collection Guide' -confluenceUrl: 'https://querypie.atlassian.net/wiki/spaces/QM/pages/2288353307/QueryPie+ACP' +title: 'Operational Log Collection Guide' +confluenceUrl: 'https://querypie.atlassian.net/wiki/spaces/QM/pages/2288353307' --- import { Callout } from 'nextra/components' -# QueryPie ACP Operational Log Collection Guide +# Operational Log Collection Guide ### Overview @@ -95,7 +95,7 @@ When `kill -3` is used as a thread dump fallback, the result is generally writte For suspected API hangs, response delays, or deadlocks, use `/api/config/monitoring` first.
-Example: https://<QueryPie address>/api/config/monitoring +Example: https://<QueryPie address>/api/config/monitoring
Example: https://<QueryPie address>/api/config/monitoring
diff --git a/src/content/en/user-manual/kubernetes-access-control/_meta.ts b/src/content/en/user-manual/kubernetes-access-control/_meta.ts index 067f54280..c177d4b45 100644 --- a/src/content/en/user-manual/kubernetes-access-control/_meta.ts +++ b/src/content/en/user-manual/kubernetes-access-control/_meta.ts @@ -1,4 +1,4 @@ export default { 'checking-access-permission-list': 'Checking Access Permission List', - 'web-client': 'Connecting to Kubernetes Clusters with Web Client', + 'connecting-to-kubernetes-clusters-with-web-client': 'Connecting to Kubernetes Clusters with Web Client', }; diff --git a/src/content/en/user-manual/kubernetes-access-control/web-client.mdx b/src/content/en/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client.mdx similarity index 74% rename from src/content/en/user-manual/kubernetes-access-control/web-client.mdx rename to src/content/en/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client.mdx index 205586795..44396f46f 100644 --- a/src/content/en/user-manual/kubernetes-access-control/web-client.mdx +++ b/src/content/en/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client.mdx @@ -18,7 +18,7 @@ With KAC Web Client, you can access Kubernetes clusters directly from your brows You can enter from the cluster detail screen in the Kubernetes menu using the Connect button, or if you have already selected a Role and a specific cluster, you can go directly to Web Client from the top menu on the user page.
-Move to the Web Client screen by selecting a specific cluster on the cluster detail screen and clicking the `Connect` button +Move to the Web Client screen by selecting a specific cluster on the cluster detail screen and clicking the `Connect` button
Move to the Web Client screen by selecting a specific cluster on the cluster detail screen and clicking the `Connect` button
@@ -27,7 +27,7 @@ Move to the Web Client screen by selecting a specific cluster on the cluster det #### Web Client Screen Layout
-The Web Client `Cluster Overview` displayed after connecting to a Role and a specific cluster +The Web Client `Cluster Overview` displayed after connecting to a Role and a specific cluster
The Web Client `Cluster Overview` displayed after connecting to a Role and a specific cluster
@@ -39,7 +39,7 @@ When you connect, the **Cluster Overview** screen appears, where you can check t * Right main area: Displays the list or details of the selected resource. * Bottom panel: Displays Pod logs and Shell terminal in tab format. It appears when you click the `Logs` or `Shell` button on the detail screen displayed after clicking a specific target in the list.
- Bottom panel exposed as tabs when the `Logs` or `Shell` button is clicked + Bottom panel exposed as tabs when the `Logs` or `Shell` button is clicked
Bottom panel exposed as tabs when the `Logs` or `Shell` button is clicked
@@ -50,7 +50,7 @@ When you connect, the **Cluster Overview** screen appears, where you can check t #### Selecting a Role
-QueryPie Web > Kubernetes > Kubernetes > Select a Role +QueryPie Web > Kubernetes > Kubernetes > Select a Role
QueryPie Web > Kubernetes > Kubernetes > Select a Role
@@ -65,7 +65,7 @@ QueryPie Web > Kubernetes > Kubernetes > Select a Role #### Connecting with Web Client
-image-20260714-102425.png +image-20260714-102425.png
@@ -80,7 +80,7 @@ Refer to the Agent manual for Agent installation and usage. ### Checking Resource Lists
-*QueryPie Web > Web Client > Resource List* +*QueryPie Web > Web Client > Resource List*
*QueryPie Web > Web Client > Resource List*
@@ -111,7 +111,7 @@ The detail panel consists of four tabs. #### Overview Tab
-*QueryPie Web > Web Client > Resource Detail > Overview* +*QueryPie Web > Web Client > Resource Detail > Overview*
*QueryPie Web > Web Client > Resource Detail > Overview*
@@ -127,7 +127,7 @@ You can check the resource metadata and status. #### YAML Tab
-*QueryPie Web > Web Client > Resource Detail > YAML* +*QueryPie Web > Web Client > Resource Detail > YAML*
*QueryPie Web > Web Client > Resource Detail > YAML*
@@ -138,7 +138,7 @@ You can check the full YAML definition of the resource and edit it directly. Cli #### Events Tab
-*QueryPie Web > Web Client > Resource Detail > Events* +*QueryPie Web > Web Client > Resource Detail > Events*
*QueryPie Web > Web Client > Resource Detail > Events*
@@ -151,7 +151,7 @@ If a Pod is running normally, there may be no events. #### Relations Tab
-*QueryPie Web > Web Client > Resource Detail > Relations* +*QueryPie Web > Web Client > Resource Detail > Relations*
*QueryPie Web > Web Client > Resource Detail > Relations*
@@ -163,7 +163,7 @@ You can understand parent and child resource relationships at a glance, and clic ### Checking Pod Logs
-*QueryPie Web > Web Client > Log Viewer* +*QueryPie Web > Web Client > Log Viewer*
*QueryPie Web > Web Client > Log Viewer*
@@ -178,7 +178,7 @@ You can understand parent and child resource relationships at a glance, and clic ### Connecting to a Pod Shell Terminal
-*QueryPie Web > Web Client > Shell Terminal* +*QueryPie Web > Web Client > Shell Terminal*
*QueryPie Web > Web Client > Shell Terminal*
diff --git a/src/content/ja/support/_meta.ts b/src/content/ja/support/_meta.ts index cbe352ab4..16f715fda 100644 --- a/src/content/ja/support/_meta.ts +++ b/src/content/ja/support/_meta.ts @@ -1,4 +1,4 @@ export default { 'premium-support': 'プレミアムサポート', - 'querypie-acp-operational-log-collection-guide': 'QueryPie ACP運用ログ収集ガイド', + 'operational-log-collection-guide': '運用ログ収集ガイド', }; diff --git a/src/content/ja/support/querypie-acp-operational-log-collection-guide.mdx b/src/content/ja/support/operational-log-collection-guide.mdx similarity index 98% rename from src/content/ja/support/querypie-acp-operational-log-collection-guide.mdx rename to src/content/ja/support/operational-log-collection-guide.mdx index 8bcc3c1db..567188a0d 100644 --- a/src/content/ja/support/querypie-acp-operational-log-collection-guide.mdx +++ b/src/content/ja/support/operational-log-collection-guide.mdx @@ -1,11 +1,11 @@ --- -title: 'QueryPie ACP運用ログ収集ガイド' -confluenceUrl: 'https://querypie.atlassian.net/wiki/spaces/QM/pages/2288353307/QueryPie+ACP' +title: '運用ログ収集ガイド' +confluenceUrl: 'https://querypie.atlassian.net/wiki/spaces/QM/pages/2288353307' --- import { Callout } from 'nextra/components' -# QueryPie ACP運用ログ収集ガイド +# 運用ログ収集ガイド ### Overview @@ -95,7 +95,7 @@ thread dump fallbackで`kill -3`を使用した場合、通常は結果がコン API hang、レスポンス遅延、deadlockが疑われる場合は、まず`/api/config/monitoring`を使用します。
-例:https://<QueryPieのアドレス>/api/config/monitoring +例:https://<QueryPieのアドレス>/api/config/monitoring
例:https://<QueryPieのアドレス>/api/config/monitoring
diff --git a/src/content/ja/user-manual/kubernetes-access-control/_meta.ts b/src/content/ja/user-manual/kubernetes-access-control/_meta.ts index ca867a6cb..cda05edd4 100644 --- a/src/content/ja/user-manual/kubernetes-access-control/_meta.ts +++ b/src/content/ja/user-manual/kubernetes-access-control/_meta.ts @@ -1,4 +1,4 @@ export default { 'checking-access-permission-list': 'アクセス権限一覧の確認', - 'web-client': 'Web ClientでKubernetesクラスターに接続する', + 'connecting-to-kubernetes-clusters-with-web-client': 'Web ClientでKubernetesクラスターに接続する', }; diff --git a/src/content/ja/user-manual/kubernetes-access-control/web-client.mdx b/src/content/ja/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client.mdx similarity index 76% rename from src/content/ja/user-manual/kubernetes-access-control/web-client.mdx rename to src/content/ja/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client.mdx index 3d48975a4..29e8acd02 100644 --- a/src/content/ja/user-manual/kubernetes-access-control/web-client.mdx +++ b/src/content/ja/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client.mdx @@ -18,7 +18,7 @@ KAC Web Clientを使用すると、別途Agentをインストールせずに、 Kubernetesメニューのクラスター詳細画面でConnectボタンからアクセスするか、Roleと特定のクラスターをすでに選択している場合は、ユーザーページ上部メニューのWeb Client項目から直接移動できます。
-クラスター詳細画面で特定のクラスターを選択後、`Connect` ボタンをクリックしてWeb Client画面へ移動 +クラスター詳細画面で特定のクラスターを選択後、`Connect` ボタンをクリックしてWeb Client画面へ移動
クラスター詳細画面で特定のクラスターを選択後、`Connect` ボタンをクリックしてWeb Client画面へ移動
@@ -27,7 +27,7 @@ Kubernetesメニューのクラスター詳細画面でConnectボタンからア #### Web Client画面構成
-Roleと特定のクラスターに接続した後に表示されるWeb Clientの `Cluster Overview` +Roleと特定のクラスターに接続した後に表示されるWeb Clientの `Cluster Overview`
Roleと特定のクラスターに接続した後に表示されるWeb Clientの `Cluster Overview`
@@ -39,7 +39,7 @@ Roleと特定のクラスターに接続した後に表示されるWeb Clientの * 右側メイン領域:選択したリソースの一覧または詳細情報を表示します。 * 下部パネル:Podログの確認とShellターミナルをタブ形式で表示します。一覧で特定の対象をクリックして表示される詳細画面で `Logs` または `Shell` ボタンをクリックしたときに表示されます。
- `Logs` または `Shell` ボタンをクリックしたときにタブとして表示される下部パネル + `Logs` または `Shell` ボタンをクリックしたときにタブとして表示される下部パネル
`Logs` または `Shell` ボタンをクリックしたときにタブとして表示される下部パネル
@@ -50,7 +50,7 @@ Roleと特定のクラスターに接続した後に表示されるWeb Clientの #### Roleを選択する
-QueryPie Web > Kubernetes > Kubernetes > Select a Role +QueryPie Web > Kubernetes > Kubernetes > Select a Role
QueryPie Web > Kubernetes > Kubernetes > Select a Role
@@ -65,7 +65,7 @@ QueryPie Web > Kubernetes > Kubernetes > Select a Role #### Web Clientで接続する
-image-20260714-102425.png +image-20260714-102425.png
@@ -80,7 +80,7 @@ Agentのインストールおよび使用方法はAgentマニュアルを参照 ### リソース一覧を確認する
-*QueryPie Web > Web Client > Resource List* +*QueryPie Web > Web Client > Resource List*
*QueryPie Web > Web Client > Resource List*
@@ -111,7 +111,7 @@ Agentのインストールおよび使用方法はAgentマニュアルを参照 #### Overviewタブ
-*QueryPie Web > Web Client > Resource Detail > Overview* +*QueryPie Web > Web Client > Resource Detail > Overview*
*QueryPie Web > Web Client > Resource Detail > Overview*
@@ -127,7 +127,7 @@ Agentのインストールおよび使用方法はAgentマニュアルを参照 #### YAMLタブ
-*QueryPie Web > Web Client > Resource Detail > YAML* +*QueryPie Web > Web Client > Resource Detail > YAML*
*QueryPie Web > Web Client > Resource Detail > YAML*
@@ -138,7 +138,7 @@ Agentのインストールおよび使用方法はAgentマニュアルを参照 #### Eventsタブ
-*QueryPie Web > Web Client > Resource Detail > Events* +*QueryPie Web > Web Client > Resource Detail > Events*
*QueryPie Web > Web Client > Resource Detail > Events*
@@ -151,7 +151,7 @@ Podが正常に動作している場合、イベントがないことがあり #### Relationsタブ
-*QueryPie Web > Web Client > Resource Detail > Relations* +*QueryPie Web > Web Client > Resource Detail > Relations*
*QueryPie Web > Web Client > Resource Detail > Relations*
@@ -163,7 +163,7 @@ Podが正常に動作している場合、イベントがないことがあり ### Podログを確認する
-*QueryPie Web > Web Client > Log Viewer* +*QueryPie Web > Web Client > Log Viewer*
*QueryPie Web > Web Client > Log Viewer*
@@ -178,7 +178,7 @@ Podが正常に動作している場合、イベントがないことがあり ### Pod Shellターミナルに接続する
-*QueryPie Web > Web Client > Shell Terminal* +*QueryPie Web > Web Client > Shell Terminal*
*QueryPie Web > Web Client > Shell Terminal*
diff --git a/src/content/ko/support/_meta.ts b/src/content/ko/support/_meta.ts index b3bad5154..1cb382a76 100644 --- a/src/content/ko/support/_meta.ts +++ b/src/content/ko/support/_meta.ts @@ -2,5 +2,5 @@ export default { 'premium-support': '프리미엄 지원', 'standard-edition': 'Standard Edition', 'standard-edition-license-policy': 'Standard Edition 라이선스 정책', - 'querypie-acp-operational-log-collection-guide': 'QueryPie ACP 운영 로그 수집 가이드', + 'operational-log-collection-guide': '운영 로그 수집 가이드', }; diff --git a/src/content/ko/support/querypie-acp-operational-log-collection-guide.mdx b/src/content/ko/support/operational-log-collection-guide.mdx similarity index 98% rename from src/content/ko/support/querypie-acp-operational-log-collection-guide.mdx rename to src/content/ko/support/operational-log-collection-guide.mdx index bfc43a7cd..7b0c282e7 100644 --- a/src/content/ko/support/querypie-acp-operational-log-collection-guide.mdx +++ b/src/content/ko/support/operational-log-collection-guide.mdx @@ -1,11 +1,11 @@ --- -title: 'QueryPie ACP 운영 로그 수집 가이드' -confluenceUrl: 'https://querypie.atlassian.net/wiki/spaces/QM/pages/2288353307/QueryPie+ACP' +title: '운영 로그 수집 가이드' +confluenceUrl: 'https://querypie.atlassian.net/wiki/spaces/QM/pages/2288353307' --- import { Callout } from 'nextra/components' -# QueryPie ACP 운영 로그 수집 가이드 +# 운영 로그 수집 가이드 ### Overview @@ -95,7 +95,7 @@ thread dump fallback에서 `kill -3`를 사용한 경우 결과가 보통 컨테 API hang, 응답 지연, deadlock 의심 상황에서는 `/api/config/monitoring`을 우선 사용합니다.
-예시 : https://<querypie 주소>/api/config/monitoring +예시 : https://<querypie 주소>/api/config/monitoring
예시 : https://<querypie 주소>/api/config/monitoring
diff --git a/src/content/ko/user-manual/kubernetes-access-control/_meta.ts b/src/content/ko/user-manual/kubernetes-access-control/_meta.ts index 0fd7f9e13..55ad23c3c 100644 --- a/src/content/ko/user-manual/kubernetes-access-control/_meta.ts +++ b/src/content/ko/user-manual/kubernetes-access-control/_meta.ts @@ -1,4 +1,4 @@ export default { 'checking-access-permission-list': '접근 권한 목록 확인하기', - 'web-client': 'Web Client로 쿠버네티스 클러스터 접속하기', + 'connecting-to-kubernetes-clusters-with-web-client': 'Web Client로 쿠버네티스 클러스터 접속하기', }; diff --git a/src/content/ko/user-manual/kubernetes-access-control/web-client.mdx b/src/content/ko/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client.mdx similarity index 76% rename from src/content/ko/user-manual/kubernetes-access-control/web-client.mdx rename to src/content/ko/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client.mdx index ad7cc5680..ea5368207 100644 --- a/src/content/ko/user-manual/kubernetes-access-control/web-client.mdx +++ b/src/content/ko/user-manual/kubernetes-access-control/connecting-to-kubernetes-clusters-with-web-client.mdx @@ -18,7 +18,7 @@ KAC Web Client를 사용하면 별도의 Agent 설치 없이 브라우저에서 Kubernetes 메뉴의 클러스터 상세 화면에서 Connect 버튼으로 진입하거나 Role과 특정 클러스터를 이미 선택한 경우 사용자 페이지 상단 메뉴에서 Web Client 항목으로 바로 이동할 수 있습니다.
-클러스터 상세 화면에서 특정 클러스터를 선택 후 `Connect` 버튼을 클릭하여 Web Client 화면으로 이동 +클러스터 상세 화면에서 특정 클러스터를 선택 후 `Connect` 버튼을 클릭하여 Web Client 화면으로 이동
클러스터 상세 화면에서 특정 클러스터를 선택 후 `Connect` 버튼을 클릭하여 Web Client 화면으로 이동
@@ -27,7 +27,7 @@ Kubernetes 메뉴의 클러스터 상세 화면에서 Connect 버튼으로 진 #### Web Client 화면 구성
-Role과 특정 클러스터에 접속한 뒤 표시되는 Web Client의 `Cluster Overview` +Role과 특정 클러스터에 접속한 뒤 표시되는 Web Client의 `Cluster Overview`
Role과 특정 클러스터에 접속한 뒤 표시되는 Web Client의 `Cluster Overview`
@@ -39,7 +39,7 @@ Role과 특정 클러스터에 접속한 뒤 표시되는 Web Client의 `Cluster * 우측 메인 영역: 선택한 리소스의 목록 또는 상세 정보를 표시합니다. * 하단 패널: Pod 로그 조회와 Shell 터미널을 탭 형식으로 표시합니다. 목록에서 특정 대상을 클릭하여 표시되는 상세화면에서 `Logs` 또는 `Shell` 버튼을 클릭했을 때 노출됩니다.
- `Logs` 또는 `Shell` 버튼을 클릭했을 때 탭으로 노출되는 하단 패널 + `Logs` 또는 `Shell` 버튼을 클릭했을 때 탭으로 노출되는 하단 패널
`Logs` 또는 `Shell` 버튼을 클릭했을 때 탭으로 노출되는 하단 패널
@@ -50,7 +50,7 @@ Role과 특정 클러스터에 접속한 뒤 표시되는 Web Client의 `Cluster #### Role 선택하기
-QueryPie Web > Kubernetes > Kubernetes > Select a Role +QueryPie Web > Kubernetes > Kubernetes > Select a Role
QueryPie Web > Kubernetes > Kubernetes > Select a Role
@@ -65,7 +65,7 @@ QueryPie Web > Kubernetes > Kubernetes > Select a Role #### Web Client로 접속하기
-image-20260714-102425.png +image-20260714-102425.png
@@ -80,7 +80,7 @@ Agent 설치 및 사용 방법은 Agent 매뉴얼을 참조하세요. ### 리소스 목록 확인하기
-*QueryPie Web > Web Client > Resource List* +*QueryPie Web > Web Client > Resource List*
*QueryPie Web > Web Client > Resource List*
@@ -111,7 +111,7 @@ Agent 설치 및 사용 방법은 Agent 매뉴얼을 참조하세요. #### Overview 탭
-*QueryPie Web > Web Client > Resource Detail > Overview* +*QueryPie Web > Web Client > Resource Detail > Overview*
*QueryPie Web > Web Client > Resource Detail > Overview*
@@ -127,7 +127,7 @@ Agent 설치 및 사용 방법은 Agent 매뉴얼을 참조하세요. #### YAML 탭
-*QueryPie Web > Web Client > Resource Detail > YAML* +*QueryPie Web > Web Client > Resource Detail > YAML*
*QueryPie Web > Web Client > Resource Detail > YAML*
@@ -138,7 +138,7 @@ Agent 설치 및 사용 방법은 Agent 매뉴얼을 참조하세요. #### Events 탭
-*QueryPie Web > Web Client > Resource Detail > Events* +*QueryPie Web > Web Client > Resource Detail > Events*
*QueryPie Web > Web Client > Resource Detail > Events*
@@ -151,7 +151,7 @@ Pod가 정상적으로 동작 중이면 이벤트가 없을 수 있습니다. #### Relations 탭
-*QueryPie Web > Web Client > Resource Detail > Relations* +*QueryPie Web > Web Client > Resource Detail > Relations*
*QueryPie Web > Web Client > Resource Detail > Relations*
@@ -163,7 +163,7 @@ Pod가 정상적으로 동작 중이면 이벤트가 없을 수 있습니다. ### Pod 로그 확인하기
-*QueryPie Web > Web Client > Log Viewer* +*QueryPie Web > Web Client > Log Viewer*
*QueryPie Web > Web Client > Log Viewer*
@@ -178,7 +178,7 @@ Pod가 정상적으로 동작 중이면 이벤트가 없을 수 있습니다. ### Pod Shell 터미널 접속하기
-*QueryPie Web > Web Client > Shell Terminal* +*QueryPie Web > Web Client > Shell Terminal*
*QueryPie Web > Web Client > Shell Terminal*
diff --git a/src/lib/content-route-redirects.test.ts b/src/lib/content-route-redirects.test.ts new file mode 100644 index 000000000..6ef3f59a9 --- /dev/null +++ b/src/lib/content-route-redirects.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + expandContentRouteRedirects, + loadActiveContentRouteRedirects, +} from './content-route-redirects'; + +function writeRegistry(contents: string): string { + const directory = mkdtempSync(path.join(tmpdir(), 'content-redirects-')); + const registryPath = path.join(directory, 'redirects.yaml'); + writeFileSync(registryPath, contents, 'utf8'); + return registryPath; +} + +describe('content route redirects', () => { + it('loads only active redirects and expands locale routes', () => { + const filePath = writeRegistry(` +- source: /support/active-old + destination: /support/active-new + created_on: '2026-07-28' + expires_on: '2026-09-22' +- source: /support/expired-old + destination: /support/expired-new + created_on: '2026-05-01' + expires_on: '2026-06-26' +`); + + const active = loadActiveContentRouteRedirects({ + filePath, + currentDate: new Date('2026-07-28T12:00:00.000Z'), + }); + + expect(active).toEqual([{ + source: '/support/active-old', + destination: '/support/active-new', + created_on: '2026-07-28', + expires_on: '2026-09-22', + }]); + expect(expandContentRouteRedirects(active)).toEqual([ + { + source: '/:locale(ko|en|ja)/support/active-old', + destination: '/:locale/support/active-new', + permanent: false, + }, + { + source: '/support/active-old', + destination: '/support/active-new', + permanent: false, + }, + ]); + }); + + it('treats expires_on as the first inactive date', () => { + const filePath = writeRegistry(` +- source: /old + destination: /new + created_on: '2026-07-28' + expires_on: '2026-09-22' +`); + + expect(loadActiveContentRouteRedirects({ + filePath, + currentDate: new Date('2026-09-22T00:00:00.000Z'), + })).toEqual([]); + }); + + it('rejects duplicate sources and invalid lifecycle metadata', () => { + const filePath = writeRegistry(` +- source: /old + destination: /new + created_on: '2026-07-28' + expires_on: '2026-07-28' +- source: /old + destination: /another + created_on: '2026-07-28' + expires_on: '2026-09-22' +`); + + expect(() => loadActiveContentRouteRedirects({ + filePath, + currentDate: new Date('2026-07-28T00:00:00.000Z'), + })).toThrow('expires_on must be later than created_on'); + }); +}); diff --git a/src/lib/content-route-redirects.ts b/src/lib/content-route-redirects.ts new file mode 100644 index 000000000..142a6b31e --- /dev/null +++ b/src/lib/content-route-redirects.ts @@ -0,0 +1,114 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { load } from 'js-yaml'; + +export type ContentRouteRedirect = { + source: string; + destination: string; + created_on: string; + expires_on: string; +}; + +export type NextContentRouteRedirect = { + source: string; + destination: string; + permanent: false; +}; + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function parseIsoDate(value: unknown, field: string): string { + if (typeof value !== 'string' || !ISO_DATE_PATTERN.test(value)) { + throw new Error(`${field} must use YYYY-MM-DD`); + } + const parsed = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) { + throw new Error(`${field} must be a valid ISO date: ${value}`); + } + return value; +} + +function validateRoute(value: unknown, field: string): string { + if ( + typeof value !== 'string' + || !value.startsWith('/') + || value === '/' + || value.endsWith('/') + || value.includes('//') + || value.split('/').slice(1).some((part) => ['', '.', '..'].includes(part)) + ) { + throw new Error(`${field} must be a canonical root-relative content route`); + } + return value; +} + +function validateRedirects(value: unknown): ContentRouteRedirect[] { + if (value == null) { + return []; + } + if (!Array.isArray(value)) { + throw new Error('Content redirect registry must be a list'); + } + + const seenSources = new Set(); + return value.map((item, index) => { + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + throw new Error(`Content redirect at index ${index} must be a mapping`); + } + const candidate = item as Record; + const source = validateRoute(candidate.source, 'source'); + const destination = validateRoute(candidate.destination, 'destination'); + const createdOn = parseIsoDate(candidate.created_on, 'created_on'); + const expiresOn = parseIsoDate(candidate.expires_on, 'expires_on'); + if (source === destination) { + throw new Error(`Content redirect source equals destination: ${source}`); + } + if (seenSources.has(source)) { + throw new Error(`Duplicate content redirect source: ${source}`); + } + if (expiresOn <= createdOn) { + throw new Error(`expires_on must be later than created_on for ${source}`); + } + seenSources.add(source); + return { + source, + destination, + created_on: createdOn, + expires_on: expiresOn, + }; + }); +} + +export function loadActiveContentRouteRedirects(options: { + filePath?: string; + currentDate?: Date; +} = {}): ContentRouteRedirect[] { + const filePath = options.filePath + ?? path.join(process.cwd(), 'src/content-route-redirects.yaml'); + const currentDate = options.currentDate ?? new Date(); + if (Number.isNaN(currentDate.getTime())) { + throw new Error('currentDate must be valid'); + } + const currentIsoDate = currentDate.toISOString().slice(0, 10); + const redirects = validateRedirects( + load(readFileSync(filePath, 'utf8')), + ); + return redirects.filter((redirect) => redirect.expires_on > currentIsoDate); +} + +export function expandContentRouteRedirects( + redirects: ContentRouteRedirect[], +): NextContentRouteRedirect[] { + return redirects.flatMap(({ source, destination }) => [ + { + source: `/:locale(ko|en|ja)${source}`, + destination: `/:locale${destination}`, + permanent: false, + }, + { + source, + destination, + permanent: false, + }, + ]); +}