diff --git a/apps/site/src/layouts/Base.astro b/apps/site/src/layouts/Base.astro
index ae285b4..4835f41 100644
--- a/apps/site/src/layouts/Base.astro
+++ b/apps/site/src/layouts/Base.astro
@@ -11,9 +11,10 @@ interface Props {
title: string;
description: string;
translationPath?: string | null;
+ noindex?: boolean;
}
-const { lang, title, description } = Astro.props;
+const { lang, title, description, noindex = false } = Astro.props;
const copy = lang === "ko" ? landingKo : landingEn;
const socialPreview = lang === "ko" ? socialPreviewKo : socialPreviewEn;
const socialPreviewAlt = lang === "ko"
@@ -67,6 +68,7 @@ const schema = isHome
{title}
+ {noindex && }
diff --git a/apps/site/src/pages/404.astro b/apps/site/src/pages/404.astro
new file mode 100644
index 0000000..64a8398
--- /dev/null
+++ b/apps/site/src/pages/404.astro
@@ -0,0 +1,213 @@
+---
+import Base from "../layouts/Base.astro";
+
+const title = "페이지를 찾을 수 없습니다 | Page not found | designpaca";
+const description = "요청한 주소를 찾을 수 없습니다. 한국어 또는 영어 홈에서 다시 시작할 수 있습니다. Page not found. Return to the Korean or English home page.";
+---
+
+
+
+
+
+
HTTP 404
+
페이지를 찾을 수 없습니다
+
+ 주소가 바뀌었거나 페이지가 이동했습니다. 아래 홈 화면에서 다시 시작해 주세요.
+
+
+ Page not found. The address may have changed or the page may have moved.
+
+
+
+
+
+ 현재 위치 Current route
+ 404
+
+
+
+
+
+
+
+
diff --git a/apps/site/tools/site-e2e.mjs b/apps/site/tools/site-e2e.mjs
index ba9902c..58f7563 100644
--- a/apps/site/tools/site-e2e.mjs
+++ b/apps/site/tools/site-e2e.mjs
@@ -32,8 +32,8 @@ const GEOMETRY_WIDTHS = [320, 390, 768, 1024, 1440, 1920, 2560];
const CAPTURE_WIDTHS = new Set([390, 1440]);
const HEIGHTS = new Map([[320, 844], [390, 844], [768, 1024], [1024, 900], [1440, 1000], [1920, 1080], [2560, 1440]]);
const AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
-const EXPECTED_SCENARIOS = 80;
-const EXPECTED_ASSERTIONS = 995;
+const EXPECTED_SCENARIOS = 81;
+const EXPECTED_ASSERTIONS = 1009;
const EXPECTED_CAPTURES = 8;
function parseArguments(argv) {
@@ -1394,6 +1394,62 @@ async function main() {
console.log(`Serving ${serveRoot}`);
console.log(`Evidence ${evidenceRoot}\n`);
+ const notFoundScope = "404 artifact and recovery";
+ await results.scenario(notFoundScope, async () => {
+ const artifact = path.join(serveRoot, "404.html");
+ results.equal(notFoundScope, "top-level 404 artifact exists", true, fs.existsSync(artifact));
+ const opened = await openPage(browser, server.origin, { path: "/404.html" }, { width: 390 });
+ try {
+ const state = await opened.page.evaluate(() => {
+ const robots = [...document.querySelectorAll("meta[name='robots']")].map((element) => element.getAttribute("content") || "");
+ const h1s = [...document.querySelectorAll("h1")].map((element) => element.textContent?.replace(/\s+/g, " ").trim() || "");
+ const englishCopy = [...document.querySelectorAll("[lang='en']")].some((element) => /Page not found\./.test(element.textContent || ""));
+ const links = [...document.querySelectorAll("main a[href]")].map((element) => ({
+ href: element.getAttribute("href"),
+ lang: element.getAttribute("lang"),
+ text: element.textContent?.replace(/\s+/g, " ").trim() || "",
+ }));
+ const executableScripts = [...document.scripts].filter((script) => {
+ const type = (script.getAttribute("type") || "").trim().toLowerCase();
+ return !["application/ld+json", "application/json", "importmap"].includes(type);
+ }).length;
+ return {
+ lang: document.documentElement.lang,
+ mainCount: document.querySelectorAll("main").length,
+ h1s,
+ robots,
+ englishCopy,
+ links,
+ executableScripts,
+ viewportWidth: innerWidth,
+ documentWidth: document.documentElement.scrollWidth,
+ };
+ });
+ const axe = await runAxe(opened.page);
+ results.equal(notFoundScope, "local artifact route responds", 200, opened.status);
+ results.equal(notFoundScope, "document language is Korean", "ko", state.lang);
+ results.equal(notFoundScope, "one main landmark", 1, state.mainCount);
+ results.equal(notFoundScope, "one unique h1", 1, state.h1s.length);
+ results.equal(notFoundScope, "h1 identifies the missing page", "페이지를 찾을 수 없습니다", state.h1s[0]);
+ results.equal(notFoundScope, "English explanation is language tagged", true, state.englishCopy);
+ results.equal(notFoundScope, "one robots directive", 1, state.robots.length);
+ results.check(notFoundScope, "robots directive prevents indexing", state.robots[0]?.split(",").some((value) => value.trim().toLowerCase() === "noindex"), {
+ expected: "noindex",
+ actual: state.robots,
+ });
+ results.check(notFoundScope, "Korean home recovery link exists", state.links.some((link) => link.href === "/" && link.text.endsWith("한국어 홈")), { actual: state.links });
+ results.check(notFoundScope, "English home recovery link exists and is language tagged", state.links.some((link) => link.href === "/en/" && link.lang === "en" && link.text === "EN English home"), { actual: state.links });
+ results.equal(notFoundScope, "no executable scripts", 0, state.executableScripts);
+ results.check(notFoundScope, "no horizontal document overflow", state.documentWidth <= state.viewportWidth + 1, {
+ expected: `<= ${state.viewportWidth + 1}`,
+ actual: state.documentWidth,
+ });
+ results.empty(notFoundScope, "axe WCAG A/AA violations", axe);
+ } finally {
+ await closePage(opened.page);
+ }
+ });
+
// Four routes across the complete local preflight width matrix. Only the
// 390/1440 states create visual evidence; every width gets the full geometry gate.
for (const route of ROUTES) {