diff --git a/.Jules/palette.md b/.Jules/palette.md index 7cf7b0d..b6586cf 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -32,3 +32,7 @@ ## 2026-08-22 - Add aria-labelledby to section landmarks **Learning:** `
`은 접근성 이름이 있을 때만 `region` 랜드마크로 노출되고, 이름이 없으면 `generic`으로 매핑되어 화면 탐색 랜드마크 목록에 나타나지 않습니다. `id` 속성만으로는 부족합니다. **Action:** `
`에는 고유한 `id`를 가진 내부 헤딩(`

`)을 `aria-labelledby`로 참조시켜 접근성 이름을 부여합니다. 회귀 테스트가 참조 대상 id의 실재 여부와 아이디가 있는 모든 섹션의 레이블링을 검증합니다. + +## 2026-09-13 - 외부 링크 접근성 패턴의 일관성 유지 +**Learning:** index.html에 잘 적용된 외부 링크 안내(aria-describedby="new-window-desc") 패턴이 404.html에는 누락되어 있었다. 보조 페이지(404 등)라도 메인 페이지와 동일한 수준의 접근성을 유지하지 않으면 스크린 리더 사용자에게 혼란을 줄 수 있다. +**Action:** 앞으로 새로운 페이지를 생성하거나 리뷰할 때, 메인 페이지의 접근성 기준(예: 외부 링크 시각적/보조적 안내)이 모든 HTML 문서에 일관되게 적용되었는지 확인한다. diff --git a/.github/workflows/static_site_regression.yml b/.github/workflows/static_site_regression.yml new file mode 100644 index 0000000..1ccd491 --- /dev/null +++ b/.github/workflows/static_site_regression.yml @@ -0,0 +1,67 @@ +name: Static Site Regression + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + static_contracts: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + + - name: Verify exact-head static accessibility contracts + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + python - <<'PY' + import inspect + from pathlib import Path + import runpy + + test_files = ( + Path("tests/test_404_page.py"), + Path("tests/test_external_links.py"), + ) + executed = 0 + for path in test_files: + namespace = runpy.run_path(str(path)) + checks = sorted( + (name, value) + for name, value in namespace.items() + if name.startswith("test_") and callable(value) + ) + if not checks: + raise AssertionError(f"{path} contains no executable test functions") + for name, check in checks: + if inspect.signature(check).parameters: + raise AssertionError( + f"{path}:{name} requires a test fixture; the dependency-free runner cannot execute it" + ) + check() + executed += 1 + if executed == 0: + raise AssertionError("no static accessibility contracts executed") + print(f"executed {executed} static accessibility contracts") + PY + python -m compileall -q tests/test_404_page.py tests/test_external_links.py + git diff --exit-code diff --git a/404.html b/404.html index 5e5fa8f..acb4ab3 100644 --- a/404.html +++ b/404.html @@ -16,6 +16,7 @@ + 새 창에서 열림 @@ -59,7 +60,7 @@

페이지를 찾을 수 없습니다

>

Founded by - Seongho Bae. + Seongho Bae. Context into judgment. Judgment into action.

diff --git a/tests/test_404_page.py b/tests/test_404_page.py index 4a6c192..cc011b4 100644 --- a/tests/test_404_page.py +++ b/tests/test_404_page.py @@ -79,3 +79,46 @@ def test_404_assets_referenced_exist_on_disk() -> None: """Local image/icon assets referenced by the 404 page must be present.""" for asset in re.findall(r'(?:href|src)="(assets/[^"#?]+)"', _page()): assert (ROOT / asset).is_file(), f"404.html references missing asset {asset}" + + +def test_404_page_external_links_accessible() -> None: + """External links on the 404 page must be accessible.""" + html = _page() + + # Check visually hidden description span + assert '새 창에서 열림' in html, ( + "404 page must contain the visually hidden description for new window links" + ) + + # Verify all external links reference it + from html.parser import HTMLParser + + class _LinkParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.links: list[dict[str, str | None]] = [] + + def handle_starttag(self, tag, attrs) -> None: + if tag == "a": + self.links.append(dict(attrs)) + + parser = _LinkParser() + parser.feed(html) + + external_links = [a for a in parser.links if a.get("target") == "_blank"] + assert len(external_links) > 0, "404 page should have external links" + + for anchor in external_links: + assert anchor.get("aria-describedby") == "new-window-desc", ( + f"External link {anchor.get('href')} must reference #new-window-desc" + ) + assert anchor.get("title") == "새 창에서 열림", ( + f"External link {anchor.get('href')} must have the correct title" + ) + rel_tokens = {token.lower() for token in (anchor.get("rel") or "").split()} + assert "noopener" in rel_tokens, ( + f"External link {anchor.get('href')} must keep opener isolation" + ) + assert "noreferrer" in rel_tokens, ( + f"External link {anchor.get('href')} must keep the product referrer policy" + ) diff --git a/tests/test_external_links.py b/tests/test_external_links.py index 4de0347..7153cbf 100644 --- a/tests/test_external_links.py +++ b/tests/test_external_links.py @@ -32,43 +32,56 @@ def handle_starttag(self, tag, attrs) -> None: self.links.append(attributes) -def _parse_index() -> _LinkParser: +def _parse_html(file_path: Path) -> _LinkParser: parser = _LinkParser() - parser.feed(INDEX.read_text(encoding="utf-8")) - assert parser.links, "homepage must contain at least one anchor" + parser.feed(file_path.read_text(encoding="utf-8")) return parser -def _external_links(parser: _LinkParser) -> list[dict[str, str | None]]: +def _external_links(parser: _LinkParser, file_name: str) -> list[dict[str, str | None]]: external = [a for a in parser.links if a.get("target") == "_blank"] - assert external, "homepage must contain at least one external link" return external def test_external_links_reference_the_new_window_description() -> None: """Every external link points at the shared visually hidden warning.""" - parser = _parse_index() - - assert DESC_ID in parser.ids, ( - f"homepage must define the #{DESC_ID} description element" - ) - for anchor in _external_links(parser): - assert anchor.get("aria-describedby") == DESC_ID, ( - f"External link {anchor.get('href')} must reference #{DESC_ID}" + for html_file in ROOT.rglob("*.html"): + if ".git" in html_file.parts or ".pytest_cache" in html_file.parts or "components" in html_file.parts: + continue + + parser = _parse_html(html_file) + external = _external_links(parser, html_file.name) + if not external: + continue + + assert DESC_ID in parser.ids, ( + f"{html_file.name} must define the #{DESC_ID} description element" ) + for anchor in external: + assert anchor.get("aria-describedby") == DESC_ID, ( + f"External link {anchor.get('href')} in {html_file.name} must reference #{DESC_ID}" + ) def test_external_links_keep_the_localized_title() -> None: """The title stays as supplemental hover metadata in both locales.""" - parser = _parse_index() + for html_file in ROOT.rglob("*.html"): + if ".git" in html_file.parts or ".pytest_cache" in html_file.parts or "components" in html_file.parts: + continue - for anchor in _external_links(parser): - assert anchor.get("title") == EXPECTED["title"], ( - f"External link {anchor.get('href')} is missing the Korean title" - ) - assert anchor.get("data-i18n-title") == EXPECTED["key"], ( - f"External link {anchor.get('href')} is missing data-i18n-title" - ) + parser = _parse_html(html_file) + external = _external_links(parser, html_file.name) + + for anchor in external: + assert anchor.get("title") == EXPECTED["title"], ( + f"External link {anchor.get('href')} in {html_file.name} is missing the Korean title" + ) + + # 404.html does not load i18n.js, so we only expect data-i18n-title on the homepage + if html_file.name == "index.html": + assert anchor.get("data-i18n-title") == EXPECTED["key"], ( + f"External link {anchor.get('href')} is missing data-i18n-title" + ) def test_i18n_has_new_tab_translation() -> None: @@ -82,19 +95,35 @@ def test_visually_hidden_class_is_defined() -> None: """The description element relies on a CSP-safe external class.""" css = (ROOT / "styles.css").read_text(encoding="utf-8") assert ".visually-hidden {" in css - index = INDEX.read_text(encoding="utf-8") - assert f'id="{DESC_ID}" class="visually-hidden"' in index + for html_file in ROOT.rglob("*.html"): + if ".git" in html_file.parts or ".pytest_cache" in html_file.parts or "components" in html_file.parts: + continue + + parser = _parse_html(html_file) + external = _external_links(parser, html_file.name) + if not external: + continue + + html_content = html_file.read_text(encoding="utf-8") + assert f'id="{DESC_ID}" class="visually-hidden"' in html_content, ( + f"{html_file.name} must define the visually-hidden class on the description element" + ) def test_external_links_keep_opener_and_referrer_policy() -> None: """Every new-context link retains explicit opener isolation and referrer policy.""" - parser = _parse_index() - - for anchor in _external_links(parser): - rel_tokens = {token.lower() for token in (anchor.get("rel") or "").split()} - assert "noopener" in rel_tokens, ( - f"External link {anchor.get('href')} must keep opener isolation" - ) - assert "noreferrer" in rel_tokens, ( - f"External link {anchor.get('href')} must keep the product referrer policy" - ) + for html_file in ROOT.rglob("*.html"): + if ".git" in html_file.parts or ".pytest_cache" in html_file.parts or "components" in html_file.parts: + continue + + parser = _parse_html(html_file) + external = _external_links(parser, html_file.name) + + for anchor in external: + rel_tokens = {token.lower() for token in (anchor.get("rel") or "").split()} + assert "noopener" in rel_tokens, ( + f"External link {anchor.get('href')} in {html_file.name} must keep opener isolation" + ) + assert "noreferrer" in rel_tokens, ( + f"External link {anchor.get('href')} in {html_file.name} must keep the product referrer policy" + )