diff --git a/frontend/e2e/seo.spec.ts b/frontend/e2e/seo.spec.ts index 13afa7d1..e2319321 100644 --- a/frontend/e2e/seo.spec.ts +++ b/frontend/e2e/seo.spec.ts @@ -218,11 +218,19 @@ test('seo: css display flex page keeps route identity + structured data', async await expect(triviaMain).toContainText(/flex:\s*auto/i); }); -test('seo: login page is noindex', async ({ page }) => { - await setSeoHost(page, 'frontendatlas.com'); - await page.goto('/auth/login'); - await expect.poll(async () => (await getMeta(page, 'robots')) || '').toContain('noindex'); -}); +for (const route of ['/auth/login', '/auth/signup']) { + for (const query of ['', '?src=showcase_hero&redirectTo=%2Fdashboard']) { + test(`seo: crawlable auth page stays noindex at ${route}${query}`, async ({ page }) => { + await setSeoHost(page, 'frontendatlas.com'); + await page.goto(`${route}${query}`); + const base = process.env.PLAYWRIGHT_SSR === '1' + ? (process.env.PLAYWRIGHT_CANONICAL_BASE || 'https://frontendatlas.com').replace(/\/$/, '') + : new URL(page.url()).origin; + await expect.poll(() => getMeta(page, 'robots')).toBe('noindex,nofollow'); + await expect.poll(() => getCanonical(page)).toBe(`${base}${route}`); + }); + } +} test('seo: preview host forces noindex', async ({ page }) => { await setSeoHost(page, 'preview.frontendatlas.vercel.app'); diff --git a/frontend/scripts/audit-internal-link-equity.test.mjs b/frontend/scripts/audit-internal-link-equity.test.mjs index 2276ff74..1ca95a66 100644 --- a/frontend/scripts/audit-internal-link-equity.test.mjs +++ b/frontend/scripts/audit-internal-link-equity.test.mjs @@ -222,10 +222,28 @@ test('repository robots allows clean/parameterized previews without exposing pri assert.equal(allows(`/${family}/example/preview?entry=x&v=2`), true); for (const suffix of ['', '/preview-extra', '/preview/child']) assert.equal(allows(`/${family}/example${suffix}`), false); } - assert.equal(allows('/auth/login'), false); assert.equal(allows('/dashboard'), false); }); +test('repository robots lets crawlers read login/signup noindex without opening other auth or private paths', () => { + const allows = createRobotsPolicy(fs.readFileSync(path.join(frontendRoot, 'src', 'robots.txt'), 'utf8')); + for (const route of ['/auth/login', '/auth/signup']) { + for (const suffix of ['', '?src=showcase_hero', '?redirectTo=%2Fdashboard&src=pricing']) { + assert.equal(allows(`${route}${suffix}`), true, `${route}${suffix}`); + } + for (const suffix of ['-extra', '/child', '/child?src=pricing']) { + assert.equal(allows(`${route}${suffix}`), false, `${route}${suffix}`); + } + } + for (const route of [ + '/auth/callback?code=example', '/auth/forgot-password', '/auth/reset-password', + '/dashboard', '/profile?tab=activity', '/admin/users', '/tracks/foundations-30d', + '/companies/google', + ]) { + assert.equal(allows(route), false, route); + } +}); + test('robots combines matching groups, prefers Allow ties and respects specific agent policy', () => { const allows = createRobotsPolicy('User-agent: *\nDisallow: /\nUser-agent: Googlebot\nDisallow: /private\nUser-agent: Googlebot\nAllow: /private\nDisallow: /secret\n'); assert.equal(allows('/'), true); diff --git a/frontend/scripts/sitemap.test.mjs b/frontend/scripts/sitemap.test.mjs index 92ee0d98..8af00964 100644 --- a/frontend/scripts/sitemap.test.mjs +++ b/frontend/scripts/sitemap.test.mjs @@ -842,6 +842,20 @@ function assertAngularMasteryRedirects() { } } +function assertLegacyVueQuestionRedirect(paths) { + const source = '/vue/trivia/vue-destructuring-breaks-reactivity-toRefs-toRef'; + const destination = '/vue/trivia/vue-destructuring-breaks-reactivity-torefs-toref'; + const config = JSON.parse(fs.readFileSync(VERCEL_CONFIG_PATH, 'utf8')); + const matches = config.redirects.filter((redirect) => redirect.source === source); + if (matches.length !== 1 || matches[0].destination !== destination || matches[0].permanent !== true) { + throw new Error('The legacy mixed-case Vue question URL must permanently redirect to its canonical URL.'); + } + const prerender = new Set(fs.readFileSync(PRERENDER_PATH, 'utf8').split(/\r?\n/).filter(Boolean)); + if (!paths.has(destination) || !prerender.has(destination) || paths.has(source) || prerender.has(source)) { + throw new Error('Only the canonical Vue question URL should be included in sitemap and prerender output.'); + } +} + function assertRobotsAllowsCodingQueryNoindex() { if (!fs.existsSync(ROBOTS_PATH)) { throw new Error(`Missing ${ROBOTS_PATH}.`); @@ -1205,6 +1219,7 @@ assertAngularHttpCancellationLabSitemapEntry(entries); assertReactStaleClosuresSitemapEntry(entries); assertIndexableRouteTitlesUnique(); assertAngularMasteryRedirects(); +assertLegacyVueQuestionRedirect(paths); assertRobotsAllowsCodingQueryNoindex(); assertVercelCodingQueryNoindexHeaders(); assertVercelLockedRouteNoindexHeaders(); diff --git a/frontend/src/robots.txt b/frontend/src/robots.txt index dbe25508..c90dd08a 100644 --- a/frontend/src/robots.txt +++ b/frontend/src/robots.txt @@ -1,6 +1,10 @@ User-agent: * Allow: / Disallow: /auth/ +Allow: /auth/login$ +Allow: /auth/login? +Allow: /auth/signup$ +Allow: /auth/signup? Disallow: /dashboard Disallow: /profile Disallow: /admin diff --git a/frontend/vercel.json b/frontend/vercel.json index 1f206272..0b379796 100644 --- a/frontend/vercel.json +++ b/frontend/vercel.json @@ -103,6 +103,11 @@ } ], "redirects": [ + { + "source": "/vue/trivia/vue-destructuring-breaks-reactivity-toRefs-toRef", + "destination": "/vue/trivia/vue-destructuring-breaks-reactivity-torefs-toref", + "permanent": true + }, { "source": "/guides/framework-prep/react-prep-path/mastery", "destination": "/guides/framework-prep/react-prep-path",