From c17498901aa36dc8cb8dc477ad8806cc5c01d71e Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Wed, 17 Jun 2026 14:42:03 +0530 Subject: [PATCH 1/5] While Creating landing page through LLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit when generated and on reviewing it mention screaming from realised this project is doing exactly what screaming frog doing. πŸ˜‘ --- src/website_profiling/crawl/crawler.py | 12 +- .../crawl/fetchers/browser.py | 40 ++++- .../crawl/fetchers/static.py | 23 ++- .../reporting/seo_summary.py | 23 ++- .../test_reporting_builder_modules.py | 34 ++++ tests/test_crawl_fetchers.py | 78 ++++++++ tests/test_crawler_unit.py | 56 ++++++ web/app/client-providers.tsx | 7 +- web/app/globals.css | 137 ++++++++++++++ web/app/write/page.tsx | 9 +- web/src/components/AppLoadingScreen.tsx | 45 +++++ web/src/components/LandingShell.tsx | 90 +++++++++- .../components/landing/LandingCodeBlock.tsx | 27 ++- .../landing/LandingFeatureSpotlight.tsx | 61 ++++--- .../components/landing/LandingFinalCta.tsx | 83 ++++++--- web/src/components/landing/LandingFooter.tsx | 3 +- .../components/landing/LandingGoogleSetup.tsx | 170 +++++++----------- web/src/components/landing/LandingHero.tsx | 51 ++++-- .../components/landing/LandingLimitations.tsx | 100 ++++++----- .../components/landing/LandingPageSection.tsx | 49 +++++ .../components/landing/LandingPathStrip.tsx | 111 ++++++++---- .../components/landing/LandingProductMock.tsx | 37 ++-- .../components/landing/LandingQuickStart.tsx | 77 ++++++++ .../components/landing/LandingScrollCue.tsx | 24 +++ .../landing/LandingSectionHeader.tsx | 16 +- .../components/landing/LandingStatsStrip.tsx | 106 ++++++----- .../components/landing/LandingUseCases.tsx | 46 ++--- web/src/components/landing/landingLayout.ts | 29 +++ web/src/strings.json | 38 +++- web/src/views/Landing.tsx | 142 ++++++++------- 30 files changed, 1283 insertions(+), 441 deletions(-) create mode 100644 web/src/components/AppLoadingScreen.tsx create mode 100644 web/src/components/landing/LandingPageSection.tsx create mode 100644 web/src/components/landing/LandingQuickStart.tsx create mode 100644 web/src/components/landing/LandingScrollCue.tsx create mode 100644 web/src/components/landing/landingLayout.ts diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py index c74d727..1c68926 100644 --- a/src/website_profiling/crawl/crawler.py +++ b/src/website_profiling/crawl/crawler.py @@ -302,6 +302,8 @@ def worker(self, url: str) -> dict: ) status = result.status + is_success = isinstance(status, int) and 200 <= status < 300 + is_redirect = isinstance(status, int) and 300 <= status < 400 ct = result.content_type text = result.text response_time_ms = result.response_time_ms @@ -371,7 +373,15 @@ def worker(self, url: str) -> dict: if self.store_outlinks: outlink_list.append(link) self.link_edges_accum.append({"from_url": url, **edge}) - self.frontier.try_enqueue_link(link, url) + # Only crawl links discovered on successful (2xx) pages; links + # parsed from custom 4xx/5xx error pages should not be followed. + if is_success: + self.frontier.try_enqueue_link(link, url) + + # A redirect (3xx) has no crawlable body; enqueue its target so the + # destination is fetched and recorded as its own row (per-hop chain). + if is_redirect and final_url and final_url.rstrip("/") != url.rstrip("/"): + self.frontier.try_enqueue_link(final_url, url) ext["response_time_ms"] = response_time_ms if response_time_ms is not None else "" ext["content_length"] = content_length or 0 diff --git a/src/website_profiling/crawl/fetchers/browser.py b/src/website_profiling/crawl/fetchers/browser.py index 4d6a7d6..bf2e258 100644 --- a/src/website_profiling/crawl/fetchers/browser.py +++ b/src/website_profiling/crawl/fetchers/browser.py @@ -299,6 +299,21 @@ async def _fetch_page(self, page: Any, url: str) -> FetchResult: max_per_page=self.console_max_per_page, ) collector.attach(page) + + # Record main-frame navigation responses in order so that (a) a response + # that was received is not lost when goto raises, and (b) we can report + # the URL's OWN status (e.g. a 301) instead of the followed destination. + nav_responses: list[Any] = [] + + def _on_response(resp: Any) -> None: + try: + req = resp.request + if req.is_navigation_request() and resp.frame == page.main_frame: + nav_responses.append(resp) + except Exception: + pass + + page.on("response", _on_response) try: try: response = await page.goto( @@ -312,15 +327,21 @@ async def _fetch_page(self, page: Any, url: str) -> FetchResult: if self.extra_wait_ms and response is not None: await asyncio.sleep(self.extra_wait_ms / 1000.0) finally: + try: + page.remove_listener("response", _on_response) + except Exception: + pass if collector is not None: collector.detach(page) response_time_ms = int((time.perf_counter() - t0) * 1000) final_url = page.url or url - redirect_chain_length = 1 if final_url.rstrip("/") != url.rstrip("/") else 0 browser_diagnostics = collector.build() if collector is not None else None - if response is None: + # Prefer the first observed main-frame response: this is the URL's own + # response (a 3xx redirect or an error status), not the final hop. + own_response = nav_responses[0] if nav_responses else response + if own_response is None: return FetchResult( status=None, content_type=None, @@ -329,20 +350,27 @@ async def _fetch_page(self, page: Any, url: str) -> FetchResult: content_length=0, final_url=final_url, headers_dict={}, - redirect_chain_length=redirect_chain_length, + redirect_chain_length=1 if final_url.rstrip("/") != url.rstrip("/") else 0, fetch_method="rendered", browser_diagnostics=browser_diagnostics, ) - status = response.status - headers = response.headers or {} + status = own_response.status + redirect_chain_length = sum( + 1 for r in nav_responses if 300 <= int(getattr(r, "status", 0) or 0) < 400 + ) + headers = own_response.headers or {} lower_headers = {str(k).lower(): v for k, v in headers.items()} ct = lower_headers.get("content-type", "") headers_dict = { k: (headers.get(k) or lower_headers.get(k.lower(), "")) for k in HEADER_KEYS } - is_html = status == 200 and ("text/html" in ct or "application/xhtml+xml" in ct) + is_redirect = 300 <= status < 400 + # Capture body for 2xx and error (4xx/5xx) HTML pages; skip redirects. + is_html = (not is_redirect) and ( + "text/html" in ct or "application/xhtml+xml" in ct + ) text: Optional[str] = None content_length = 0 if is_html: diff --git a/src/website_profiling/crawl/fetchers/static.py b/src/website_profiling/crawl/fetchers/static.py index 37a867a..44d9f57 100644 --- a/src/website_profiling/crawl/fetchers/static.py +++ b/src/website_profiling/crawl/fetchers/static.py @@ -5,6 +5,7 @@ import threading import time from typing import Callable, Optional +from urllib.parse import urljoin import requests @@ -71,16 +72,30 @@ def fetch(self, url: str) -> FetchResult: session = self.session try: t0 = time.perf_counter() - resp = session.get(url, timeout=self.timeout, allow_redirects=True) + # Do NOT auto-follow redirects: we want to record the URL's own + # response (e.g. 301/308) rather than collapsing the chain into the + # final 200. The crawler enqueues the Location target so each hop is + # crawled and recorded as its own row. + resp = session.get(url, timeout=self.timeout, allow_redirects=False) response_time_ms = int((time.perf_counter() - t0) * 1000) ct = resp.headers.get("Content-Type", "") - is_html = resp.status_code == 200 and ( + location = resp.headers.get("Location") or resp.headers.get("location") or "" + # A redirect is a 3xx with a Location header (matches requests' own + # definition; excludes 304 Not Modified). + is_redirect = resp.status_code in (301, 302, 303, 307, 308) and bool(location) + # Capture the body for 2xx and error (4xx/5xx) HTML pages so custom + # error pages can be analysed; redirects have no meaningful body. + is_html = (not is_redirect) and ( "text/html" in ct or "application/xhtml+xml" in ct ) text = resp.text if is_html else None content_length = len(resp.content) if resp.content is not None else 0 - final_url = resp.url or url - redirect_chain_length = len(resp.history) + if is_redirect: + final_url = urljoin(url, location) + redirect_chain_length = 1 + else: + final_url = resp.url or url + redirect_chain_length = len(resp.history) headers_dict = {k: (resp.headers.get(k) or "") for k in HEADER_KEYS} return FetchResult( status=resp.status_code, diff --git a/src/website_profiling/reporting/seo_summary.py b/src/website_profiling/reporting/seo_summary.py index bd49de9..e930063 100644 --- a/src/website_profiling/reporting/seo_summary.py +++ b/src/website_profiling/reporting/seo_summary.py @@ -10,10 +10,29 @@ META_DESC_LEN_MAX = 160 THIN_CONTENT_CHARS = 300 + +def _status_text(value: object) -> str: + """Normalize a status value to a clean string (e.g. 400.0 -> "400"). + + Numeric statuses can arrive as ints, strings, or floats (when pandas coerces + a column containing NaN); non-numeric markers like "error"/"blocked_by_robots" + pass through unchanged. Keeps status-code matching robust across all of them. + """ + if value is None: + return "" + try: + f = float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return str(value).strip() + if f != f: # NaN + return "" + return str(int(f)) + + def _compute_summary_seo_issues(df: pd.DataFrame) -> dict: """Compute crawl summary, SEO health metrics, issues list, and recommendations from crawl DataFrame.""" total = len(df) - status_str = df["status"].astype(str) if "status" in df.columns else pd.Series(["unknown"] * len(df)) + status_str = df["status"].map(_status_text) if "status" in df.columns else pd.Series(["unknown"] * len(df)) count_2xx = int((status_str.str.match(r"2\d{2}").fillna(False)).sum()) count_3xx = int((status_str.str.match(r"3\d{2}").fillna(False)).sum()) count_4xx = int((status_str.str.match(r"4\d{2}").fillna(False)).sum()) @@ -76,7 +95,7 @@ def _compute_summary_seo_issues(df: pd.DataFrame) -> dict: if pd.isna(u) or not u: continue u = str(u).strip() - st = str(row.get("status", "")).strip() + st = _status_text(row.get("status", "")) if st.startswith("4") or st.startswith("5") or st in ("error", "blocked_by_robots"): issues["broken"].append({"url": u, "status": st}) elif st.startswith("3"): diff --git a/tests/reporting/test_reporting_builder_modules.py b/tests/reporting/test_reporting_builder_modules.py index 9720689..b348e78 100644 --- a/tests/reporting/test_reporting_builder_modules.py +++ b/tests/reporting/test_reporting_builder_modules.py @@ -122,6 +122,40 @@ def test_compute_summary_seo_issues() -> None: assert out["recommendations"] +def test_status_text_normalization() -> None: + assert seo_summary._status_text(400) == "400" + assert seo_summary._status_text(400.0) == "400" + assert seo_summary._status_text("301") == "301" + assert seo_summary._status_text("error") == "error" + assert seo_summary._status_text(None) == "" + assert seo_summary._status_text(float("nan")) == "" + + +def test_compute_summary_classifies_numeric_and_float_statuses() -> None: + df = pd.DataFrame( + [ + {"url": "https://example.com/ok", "status": 200.0}, + {"url": "https://example.com/redir", "status": 301, "final_url": "https://example.com/dest"}, + {"url": "https://example.com/bad", "status": 400.0}, + {"url": "https://example.com/boom", "status": 500}, + ] + ) + out = seo_summary._compute_summary_seo_issues(df) + summary = out["summary"] + assert summary["count_2xx"] == 1 + assert summary["count_3xx"] == 1 + assert summary["count_4xx"] == 1 + assert summary["count_5xx"] == 1 + + broken = {b["url"] for b in out["issues"]["broken"]} + assert {"https://example.com/bad", "https://example.com/boom"} <= broken + + redirects = {r["url"]: r for r in out["issues"]["redirects"]} + assert "https://example.com/redir" in redirects + assert redirects["https://example.com/redir"]["status"] == "301" + assert redirects["https://example.com/redir"]["final_url"] == "https://example.com/dest" + + def test_content_analytics_helpers() -> None: df = _crawl_df() content = content_analytics._build_content_analytics(df) diff --git a/tests/test_crawl_fetchers.py b/tests/test_crawl_fetchers.py index e599809..3545629 100644 --- a/tests/test_crawl_fetchers.py +++ b/tests/test_crawl_fetchers.py @@ -40,6 +40,84 @@ def test_static_fetcher_parses_html(): assert result.browser_diagnostics is None +class _FakeResp: + def __init__(self, status_code, headers=None, text="", url=""): + self.status_code = status_code + self.headers = headers or {} + self.text = text + self.content = text.encode("utf-8") + self.url = url + self.history = [] + + @property + def is_redirect(self): + return self.status_code in (301, 302, 303, 307, 308) and "Location" in self.headers + + @property + def is_permanent_redirect(self): + return self.status_code in (301, 308) and "Location" in self.headers + + +class _FakeSession: + def __init__(self, resp): + self._resp = resp + self.headers = {} + self.calls = [] + + def get(self, url, timeout=None, allow_redirects=True): + self.calls.append({"url": url, "allow_redirects": allow_redirects}) + return self._resp + + def close(self): + pass + + +def test_static_fetcher_records_permanent_redirect_without_following(): + resp = _FakeResp( + 301, + headers={"Location": "https://example.com/new", "Content-Type": "text/html"}, + text="ignored redirect body", + url="https://example.com/old", + ) + session = _FakeSession(resp) + fetcher = StaticFetcher(timeout=5, session=session) + result = fetcher.fetch("https://example.com/old") + + # The redirect must NOT be followed: status is the real 301, not the dest's 200. + assert session.calls[0]["allow_redirects"] is False + assert result.status == 301 + assert result.final_url == "https://example.com/new" + assert result.text is None + assert result.redirect_chain_length == 1 + + +def test_static_fetcher_resolves_relative_redirect_location(): + resp = _FakeResp( + 308, + headers={"Location": "/moved"}, + url="https://example.com/old", + ) + fetcher = StaticFetcher(timeout=5, session=_FakeSession(resp)) + result = fetcher.fetch("https://example.com/old") + assert result.status == 308 + assert result.final_url == "https://example.com/moved" + + +def test_static_fetcher_records_client_error_and_captures_body(): + resp = _FakeResp( + 400, + headers={"Content-Type": "text/html"}, + text="Bad Request", + url="https://example.com/bad", + ) + fetcher = StaticFetcher(timeout=5, session=_FakeSession(resp)) + result = fetcher.fetch("https://example.com/bad") + assert result.status == 400 + # Non-200 HTML body is now captured so custom error pages can be analysed. + assert result.text is not None and "Bad Request" in result.text + assert result.redirect_chain_length == 0 + + def test_page_diagnostics_collector_builds_summary(): from website_profiling.crawl.fetchers.browser import _PageDiagnosticsCollector diff --git a/tests/test_crawler_unit.py b/tests/test_crawler_unit.py index 06717b3..16f3524 100644 --- a/tests/test_crawler_unit.py +++ b/tests/test_crawler_unit.py @@ -743,3 +743,59 @@ def test_worker_custom_extraction_invalid_regex_is_ignored(monkeypatch) -> None: out = c.worker("https://site.com/a") assert "custom_extract" not in out + +def test_worker_records_redirect_and_enqueues_target(monkeypatch) -> None: + from website_profiling.crawl.crawler import Crawler + from website_profiling.crawl.fetchers.base import FetchResult + + monkeypatch.setattr( + "website_profiling.crawl.sitemap.discover_sitemap_urls", + lambda *_a, **_k: [], + ) + c = Crawler(start_url="https://site.com", ignore_robots=True, use_wappalyzer=False) + c.fetch = lambda _url: FetchResult( # type: ignore[method-assign] + status=301, + content_type="", + text=None, + response_time_ms=1, + content_length=0, + final_url="https://site.com/new", + headers_dict={}, + redirect_chain_length=1, + fetch_method="static", + ) + out = c.worker("https://site.com/old") + # The redirect is recorded as a 3xx row (not collapsed to the destination). + assert str(out["status"]) == "301" + assert out["final_url"] == "https://site.com/new" + # ...and the redirect target is enqueued so the destination is crawled too. + assert c.frontier.queue_contains("https://site.com/new") + + +def test_worker_does_not_enqueue_links_from_error_page(monkeypatch) -> None: + from website_profiling.crawl.crawler import Crawler + from website_profiling.crawl.fetchers.base import FetchResult + + monkeypatch.setattr( + "website_profiling.crawl.sitemap.discover_sitemap_urls", + lambda *_a, **_k: [], + ) + c = Crawler(start_url="https://site.com", ignore_robots=True, use_wappalyzer=False) + html = 'x' + c.fetch = lambda _url: FetchResult( # type: ignore[method-assign] + status=404, + content_type="text/html", + text=html, + response_time_ms=1, + content_length=len(html), + final_url="https://site.com/bad", + headers_dict={}, + redirect_chain_length=0, + fetch_method="static", + ) + out = c.worker("https://site.com/bad") + # The 404 is recorded with its real numeric status... + assert str(out["status"]) == "404" + # ...but links discovered on an error page are NOT followed. + assert not c.frontier.queue_contains("https://site.com/error-link") + diff --git a/web/app/client-providers.tsx b/web/app/client-providers.tsx index 83de93f..46d3ec0 100644 --- a/web/app/client-providers.tsx +++ b/web/app/client-providers.tsx @@ -7,13 +7,10 @@ import { PipelineProvider } from '@/context/PipelineContext'; import { SessionProvider } from '@/context/SessionContext'; import ChatFab from '@/components/chat/ChatFab'; import PipelineRunnerFab from '@/components/pipeline/PipelineRunnerFab'; +import AppLoadingScreen from '@/components/AppLoadingScreen'; function LoadingFallback() { - return ( -
-

Loading…

-
- ); + return ; } export default function ClientProviders({ children }: { children: ReactNode }): ReactNode { diff --git a/web/app/globals.css b/web/app/globals.css index b431661..293a537 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -654,6 +654,119 @@ select:focus-visible { background: color-mix(in srgb, var(--app-bg-muted) 35%, transparent); } +/* Quantized full-page scroll β€” one section per viewport (minus header). */ +.landing-scroll-container { + scroll-snap-type: y mandatory; + scroll-behavior: smooth; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.landing-scroll-container::-webkit-scrollbar { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + .landing-scroll-container { + scroll-snap-type: y proximity; + scroll-behavior: auto; + } +} + +.landing-section { + display: flex; + width: 100%; + height: calc(100dvh - var(--landing-header-h, 3.5rem)); + flex-shrink: 0; + flex-direction: column; + overflow: hidden; + scroll-snap-align: start; + scroll-snap-stop: always; +} + +.landing-section-body { + min-height: 0; + flex: 1; + overflow: hidden; +} + +.landing-section-body-centered { + display: flex; + flex-direction: column; + justify-content: center; +} + +.landing-section-split { + display: grid; + width: 100%; + min-width: 0; + align-items: stretch; + gap: 1.5rem; +} + +@media (min-width: 768px) { + .landing-section-split { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0; + } +} + +.landing-split-copy { + display: flex; + min-height: 0; + flex-direction: column; + justify-content: center; +} + +.landing-split-visual { + display: flex; + min-height: 0; + align-items: center; + justify-content: center; +} + +@media (min-width: 768px) { + .landing-split-visual { + height: 100%; + padding: 0.75rem 1.5rem 0.75rem 0.75rem; + } + + .landing-section-split--reversed .landing-split-visual { + padding: 0.75rem 0.75rem 0.75rem 1.5rem; + } +} + +.landing-split-mock { + height: 100%; + max-height: 100%; + width: 100%; + min-height: 0; +} + +.landing-footer-snap { + flex-shrink: 0; + scroll-snap-align: start; + scroll-snap-stop: always; +} + +@media (prefers-reduced-motion: no-preference) { + .landing-scroll-cue { + animation: landingScrollCue 2.2s ease-in-out infinite; + } + + @keyframes landingScrollCue { + 0%, + 100% { + transform: translateY(0); + opacity: 0.55; + } + 50% { + transform: translateY(4px); + opacity: 1; + } + } +} + /* ───────────────────────────────────────────────────────────── Reusable redesign utilities β€” depth, motion, warmth ───────────────────────────────────────────────────────────── */ @@ -780,4 +893,28 @@ select:focus-visible { transform: translateX(100%); } } + + .shimmer-text { + background: linear-gradient( + 90deg, + var(--app-text-subtle) 0%, + var(--app-text-heading) 45%, + var(--app-link) 55%, + var(--app-text-subtle) 100% + ); + background-size: 200% auto; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: shimmerText 2.2s ease-in-out infinite; + } + @keyframes shimmerText { + 0%, + 100% { + background-position: 0% center; + } + 50% { + background-position: 100% center; + } + } } diff --git a/web/app/write/page.tsx b/web/app/write/page.tsx index 145e65f..f486d93 100644 --- a/web/app/write/page.tsx +++ b/web/app/write/page.tsx @@ -1,17 +1,12 @@ import { Suspense } from 'react'; +import AppLoadingScreen from '@/components/AppLoadingScreen'; import WriteStudio from '@/views/WriteStudio'; export const dynamic = 'force-dynamic'; export default function WritePage() { return ( - - Loading… - - } - > + }> ); diff --git a/web/src/components/AppLoadingScreen.tsx b/web/src/components/AppLoadingScreen.tsx new file mode 100644 index 0000000..6aec3c3 --- /dev/null +++ b/web/src/components/AppLoadingScreen.tsx @@ -0,0 +1,45 @@ +import { Skeleton } from '@/components/Skeleton'; +import { strings } from '@/lib/strings'; + +const app = strings.app; + +export default function AppLoadingScreen() { + return ( +
+
+ +
+

+ {app.productName} +

+

{app.productSubtitle}

+ +
+
+ + +
+ + +
+ + + +
+
+ +

{app.loading}

+

{app.loadingSubtitle}

+
+
+ ); +} diff --git a/web/src/components/LandingShell.tsx b/web/src/components/LandingShell.tsx index 55119f2..b21488c 100644 --- a/web/src/components/LandingShell.tsx +++ b/web/src/components/LandingShell.tsx @@ -1,9 +1,10 @@ 'use client'; import Link from 'next/link'; -import type { ReactNode } from 'react'; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import AppLogo from '@/components/AppLogo'; import ThemeToggle from '@/components/ThemeToggle'; +import { LANDING_SECTION_IDS, landingGutterClass } from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; export interface LandingShellProps { @@ -20,11 +21,72 @@ const NAV_ITEMS = [ export default function LandingShell({ children, footer }: LandingShellProps) { const vl = strings.views.landing; const app = strings.app; + const headerRef = useRef(null); + const mainRef = useRef(null); + const [headerHeight, setHeaderHeight] = useState(56); + + useEffect(() => { + const header = headerRef.current; + if (!header) return; + + const syncHeight = () => setHeaderHeight(header.offsetHeight); + + syncHeight(); + const observer = new ResizeObserver(syncHeight); + observer.observe(header); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const main = mainRef.current; + if (!main) return; + + const scrollToHash = (behavior: ScrollBehavior = 'smooth') => { + const hash = window.location.hash; + if (!hash) return; + const target = main.querySelector(hash); + if (target instanceof HTMLElement) { + target.scrollIntoView({ behavior, block: 'start' }); + } + }; + + scrollToHash('auto'); + + const onHashChange = () => scrollToHash('smooth'); + const onClick = (event: MouseEvent) => { + const anchor = (event.target as Element).closest('a[href^="#"]'); + if (!(anchor instanceof HTMLAnchorElement)) return; + const hash = anchor.getAttribute('href'); + if (!hash || hash === '#') return; + const target = main.querySelector(hash); + if (!(target instanceof HTMLElement)) return; + event.preventDefault(); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + window.history.pushState(null, '', hash); + }; + + window.addEventListener('hashchange', onHashChange); + main.addEventListener('click', onClick); + return () => { + window.removeEventListener('hashchange', onHashChange); + main.removeEventListener('click', onClick); + }; + }, []); + + const shellStyle = { + '--landing-header-h': `${headerHeight}px`, + } as CSSProperties; return ( -
-
-
+
+
+
{app.productName} @@ -68,7 +130,7 @@ export default function LandingShell({ children, footer }: LandingShellProps) {
-
{children}
+
+ {children} - {footer ? ( -
{footer}
- ) : null} + {footer ? ( +
+ {footer} +
+ ) : null} +
); } diff --git a/web/src/components/landing/LandingCodeBlock.tsx b/web/src/components/landing/LandingCodeBlock.tsx index 8daa0d3..40d3f11 100644 --- a/web/src/components/landing/LandingCodeBlock.tsx +++ b/web/src/components/landing/LandingCodeBlock.tsx @@ -9,9 +9,10 @@ const vl = strings.views.landing; interface LandingCodeBlockProps { label?: string; command: string; + prominent?: boolean; } -export default function LandingCodeBlock({ label, command }: LandingCodeBlockProps) { +export default function LandingCodeBlock({ label, command, prominent = false }: LandingCodeBlockProps) { const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { @@ -25,24 +26,36 @@ export default function LandingCodeBlock({ label, command }: LandingCodeBlockPro }, [command]); return ( -
-
+
+
{label ? ( -

{label}

+

+ {label} +

) : ( )}
-
-        
+      
+        
           $ 
           {command}
         
diff --git a/web/src/components/landing/LandingFeatureSpotlight.tsx b/web/src/components/landing/LandingFeatureSpotlight.tsx
index 8a6bae3..8ff0ad9 100644
--- a/web/src/components/landing/LandingFeatureSpotlight.tsx
+++ b/web/src/components/landing/LandingFeatureSpotlight.tsx
@@ -3,6 +3,13 @@
 import Link from 'next/link';
 import { CheckCircle2, ChevronRight } from 'lucide-react';
 import LandingProductMock, { type LandingProductMockVariant } from '@/components/landing/LandingProductMock';
+import {
+  landingGutterClass,
+  landingSectionSplitClass,
+  landingSplitCopyClass,
+  landingSplitMockClass,
+  landingSplitVisualClass,
+} from '@/components/landing/landingLayout';
 
 interface LandingFeatureSpotlightProps {
   eyebrow: string;
@@ -25,43 +32,47 @@ export default function LandingFeatureSpotlight({
   ctaLabel,
   reversed = false,
 }: LandingFeatureSpotlightProps) {
-  const textCol = (
-    
-

{eyebrow}

-

{title}

-

{description}

-
    - {bullets.map((bullet) => ( -
  • - - {bullet} + const copy = ( + <> +

    {eyebrow}

    +

    {title}

    +

    {description}

    +
      + {bullets.slice(0, 3).map((bullet) => ( +
    • + + {bullet}
    • ))}
    {ctaLabel} -
- ); - - const mockCol = ( -
- -
+ ); return ( -
*:first-child]:order-2' : '' - }`} - > - {textCol} - {mockCol} +
+
+ {copy} +
+
+
+ +
+
); } diff --git a/web/src/components/landing/LandingFinalCta.tsx b/web/src/components/landing/LandingFinalCta.tsx index 0a25c11..09b5752 100644 --- a/web/src/components/landing/LandingFinalCta.tsx +++ b/web/src/components/landing/LandingFinalCta.tsx @@ -1,35 +1,78 @@ 'use client'; import Link from 'next/link'; -import Button from '@/components/Button'; +import { CheckCircle2, ChevronRight } from 'lucide-react'; +import LandingProductMock from '@/components/landing/LandingProductMock'; +import LandingSectionHeader from '@/components/landing/LandingSectionHeader'; +import { + landingContentClass, + landingGutterClass, + landingSectionSplitClass, + landingSplitCopyClass, + landingSplitMockClass, + landingSplitVisualClass, +} from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; const vl = strings.views.landing; export default function LandingFinalCta() { return ( -
-
-

{vl.finalCtaTitle}

-

- {vl.finalCtaSubtitle} -

-
- - - - - - + + +
+

+ {vl.heroProofNoSubscription} Β· {vl.heroProofLocalData} +

-

- {vl.heroProofNoSubscription} Β· {vl.heroProofLocalData} -

+ +
+
+ +
+
+
+ + - +
); } diff --git a/web/src/components/landing/LandingFooter.tsx b/web/src/components/landing/LandingFooter.tsx index 2c81b8f..71753ea 100644 --- a/web/src/components/landing/LandingFooter.tsx +++ b/web/src/components/landing/LandingFooter.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import type { ReactNode } from 'react'; import { ExternalLink } from 'lucide-react'; +import { landingGutterClass } from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; const vl = strings.views.landing; @@ -35,7 +36,7 @@ function FooterLink({ export default function LandingFooter() { return ( -
+

{app.productName}

diff --git a/web/src/components/landing/LandingGoogleSetup.tsx b/web/src/components/landing/LandingGoogleSetup.tsx index 1220270..9c72324 100644 --- a/web/src/components/landing/LandingGoogleSetup.tsx +++ b/web/src/components/landing/LandingGoogleSetup.tsx @@ -2,8 +2,9 @@ import { useState } from 'react'; import Link from 'next/link'; -import { ChevronDown, ExternalLink } from 'lucide-react'; +import { ExternalLink } from 'lucide-react'; import Button from '@/components/Button'; +import { landingContentClass } from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; type GuideSection = { @@ -28,118 +29,73 @@ const SECTION_ORDER = [ export default function LandingGoogleSetup() { const vl = strings.views.landing; const sections = vl.googleSetupSections as Record; - const [openIds, setOpenIds] = useState>(() => new Set(['prerequisites', 'oauthClient'])); - - const toggle = (id: string) => { - setOpenIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const expandAll = () => setOpenIds(new Set(SECTION_ORDER)); - const collapseAll = () => setOpenIds(new Set()); + const [activeId, setActiveId] = useState('oauthClient'); + const active = sections[activeId]; return ( -
-
-
-

+

+
+
+

{vl.sectionSetupGuide}

-
-
-

{vl.googleSetupTitle}

-

- {vl.googleSetupSubtitle} -

-

- {vl.googleSetupNote} -

-
-
- - - - - -
-
+

{vl.googleSetupTitle}

+

+ {vl.googleSetupSubtitle} +

+ + + +
-
- {SECTION_ORDER.map((id, index) => { - const section = sections[id]; - if (!section) return null; - const expanded = openIds.has(id); - const panelId = `google-setup-panel-${id}`; - return ( -
- - {expanded ? ( -
-
    - {section.items.map((item) => ( -
  1. {item}
  2. - ))} -
- {section.linkLabel && section.linkUrl ? ( - - {section.linkLabel} - - - ) : null} -
- ) : null} -
- ); - })} -
+
+ {SECTION_ORDER.map((id, index) => { + const section = sections[id]; + if (!section) return null; + const selected = activeId === id; + return ( + + ); + })}
-
+ + {active ? ( +
+

{active.title}

+

+ {active.items[0]} +

+ {active.linkLabel && active.linkUrl ? ( + + {active.linkLabel} + + + ) : null} +
+ ) : null} + +

+ {vl.googleSetupNote} +

+
); } diff --git a/web/src/components/landing/LandingHero.tsx b/web/src/components/landing/LandingHero.tsx index 285f537..5a0f90a 100644 --- a/web/src/components/landing/LandingHero.tsx +++ b/web/src/components/landing/LandingHero.tsx @@ -3,50 +3,73 @@ import Link from 'next/link'; import { CheckCircle2, ChevronRight } from 'lucide-react'; import LandingProductMock from '@/components/landing/LandingProductMock'; +import LandingScrollCue from '@/components/landing/LandingScrollCue'; +import { + LANDING_SECTION_IDS, + landingGutterClass, + landingSectionClass, + landingSectionPad, + landingSectionSplitClass, + landingSplitCopyClass, + landingSplitMockClass, + landingSplitVisualClass, +} from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; const vl = strings.views.landing; export default function LandingHero() { return ( -
-
-
+
+
+

{vl.heroEyebrow}

-

+

{vl.heroTitle}

-

{vl.heroSubtitle}

-
    +

    + {vl.heroSubtitle} +

    +
      {vl.heroBullets.map((bullet) => ( -
    • - - {bullet} +
    • + + {bullet}
    • ))}
    -
    +
    {vl.ctaRunAudit} {vl.ctaDashboard}
    +

    + {vl.heroProofNoSubscription} Β· {vl.heroProofLocalData} +

    -
    - +
    +
    + +
    + +
); } diff --git a/web/src/components/landing/LandingLimitations.tsx b/web/src/components/landing/LandingLimitations.tsx index fda5fa2..66653c1 100644 --- a/web/src/components/landing/LandingLimitations.tsx +++ b/web/src/components/landing/LandingLimitations.tsx @@ -1,55 +1,73 @@ 'use client'; -import { ExternalLink } from 'lucide-react'; +import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react'; import LandingSectionHeader from '@/components/landing/LandingSectionHeader'; +import { + landingContentClass, + landingGutterClass, + landingSectionSplitClass, + landingSplitCopyClass, +} from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; const vl = strings.views.landing; export default function LandingLimitations() { return ( -
-
- -
-
-

{vl.limitationsIsTitle}

-
    - {vl.limitationsIsItems.map((item) => ( -
  • - - {item} -
  • - ))} -
-
-
-

{vl.limitationsIsntTitle}

-
    - {vl.limitationsIsntItems.map((item) => ( -
  • - - {item} -
  • - ))} -
+
+
+
+ +

+ {vl.limitationsContext} +

+
+ +
+
+
+

{vl.limitationsIsTitle}

+
    + {vl.limitationsIsItems.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+

{vl.limitationsIsntTitle}

+
    + {vl.limitationsIsntItems.map((item) => ( +
  • + + {item} +
  • + ))} +
+
-

- - {vl.limitationsReadmeLink} - - -

-
+ + +
); } diff --git a/web/src/components/landing/LandingPageSection.tsx b/web/src/components/landing/LandingPageSection.tsx new file mode 100644 index 0000000..c609959 --- /dev/null +++ b/web/src/components/landing/LandingPageSection.tsx @@ -0,0 +1,49 @@ +'use client'; + +import type { ReactNode } from 'react'; +import Reveal from '@/components/Reveal'; +import LandingScrollCue from '@/components/landing/LandingScrollCue'; +import { + landingContentClass, + landingGutterClass, + landingSectionBodyCenteredClass, + landingSectionBodyClass, + landingSectionClass, + landingSectionPad, +} from '@/components/landing/landingLayout'; + +export interface LandingPageSectionProps { + id?: string; + nextSectionId?: string; + scrollCueLabel?: string; + /** Edge-to-edge layout (hero / spotlight splits). Skips outer gutters. */ + fullBleed?: boolean; + className?: string; + children: ReactNode; +} + +export default function LandingPageSection({ + id, + nextSectionId, + scrollCueLabel, + fullBleed = false, + className = '', + children, +}: LandingPageSectionProps) { + const bodyClass = fullBleed + ? `${landingSectionBodyClass} ${landingContentClass}` + : `${landingSectionBodyCenteredClass} ${landingContentClass} ${landingGutterClass}`; + + return ( + +
{children}
+ {nextSectionId ? ( + + ) : null} +
+ ); +} diff --git a/web/src/components/landing/LandingPathStrip.tsx b/web/src/components/landing/LandingPathStrip.tsx index 69fce38..6b20759 100644 --- a/web/src/components/landing/LandingPathStrip.tsx +++ b/web/src/components/landing/LandingPathStrip.tsx @@ -1,6 +1,13 @@ 'use client'; -import { ArrowRight, BarChart2, Download, Play, Settings2 } from 'lucide-react'; +import { BarChart2, ChevronRight, Download, Play, Settings2 } from 'lucide-react'; +import LandingSectionHeader from '@/components/landing/LandingSectionHeader'; +import { + landingContentClass, + landingGutterClass, + landingSectionSplitClass, + landingSplitCopyClass, +} from '@/components/landing/landingLayout'; import { strings } from '@/lib/strings'; const vl = strings.views.landing; @@ -14,46 +21,72 @@ const STEPS = [ export default function LandingPathStrip() { return ( -
-

- {vl.pathTitle} -

-
-
- {STEPS.map(({ step, id, icon: Icon, label, hint }, index) => ( -
- - - - - - - {step} +
+
+ + +
+
+ {STEPS.map(({ step, id, icon: Icon, label, hint }) => ( + + + + + + + {step} + - - - {label} - {hint} - - - {index < STEPS.length - 1 ? ( - - ) : null} +

+ {label} +

+

{hint}

+ + ))}
- ))} +
+
+ + -
+
); } diff --git a/web/src/components/landing/LandingProductMock.tsx b/web/src/components/landing/LandingProductMock.tsx index 0560d3a..48cdd7b 100644 --- a/web/src/components/landing/LandingProductMock.tsx +++ b/web/src/components/landing/LandingProductMock.tsx @@ -17,6 +17,9 @@ interface LandingProductMockProps { variant?: LandingProductMockVariant; className?: string; elevated?: boolean; + compact?: boolean; + /** Stretch to fill a split-column visual area (hero / spotlights). */ + fillHeight?: boolean; } const NAV_ITEMS = [ @@ -272,30 +275,38 @@ export default function LandingProductMock({ variant = 'default', className = '', elevated = false, + compact = false, + fillHeight = false, }: LandingProductMockProps) { + const bodyMinH = fillHeight + ? 'min-h-0 flex-1' + : compact + ? 'min-h-[200px] sm:min-h-[220px]' + : 'min-h-[320px] sm:min-h-[360px]'; + return (
-
+
- - - + + + - + https://site-audit.local/{variant === 'crawl' ? 'links' : variant === 'issues' ? 'issues' : 'overview'}
-
-
); } diff --git a/web/src/components/landing/LandingPageSection.tsx b/web/src/components/landing/LandingPageSection.tsx index c609959..274e476 100644 --- a/web/src/components/landing/LandingPageSection.tsx +++ b/web/src/components/landing/LandingPageSection.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from 'react'; import Reveal from '@/components/Reveal'; -import LandingScrollCue from '@/components/landing/LandingScrollCue'; import { landingContentClass, landingGutterClass, @@ -14,8 +13,6 @@ import { export interface LandingPageSectionProps { id?: string; - nextSectionId?: string; - scrollCueLabel?: string; /** Edge-to-edge layout (hero / spotlight splits). Skips outer gutters. */ fullBleed?: boolean; className?: string; @@ -24,8 +21,6 @@ export interface LandingPageSectionProps { export default function LandingPageSection({ id, - nextSectionId, - scrollCueLabel, fullBleed = false, className = '', children, @@ -41,9 +36,6 @@ export default function LandingPageSection({ className={`${landingSectionClass} ${landingSectionPad} ${className}`.trim()} >
{children}
- {nextSectionId ? ( - - ) : null} ); } diff --git a/web/src/components/landing/LandingScrollCue.tsx b/web/src/components/landing/LandingScrollCue.tsx deleted file mode 100644 index 92338e9..0000000 --- a/web/src/components/landing/LandingScrollCue.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import { useLandingDeckContext } from '@/components/landing/LandingDeckContext'; -import { LANDING_DECK_SECTION_ORDER } from '@/components/landing/landingLayout'; -import { strings } from '@/lib/strings'; - -interface LandingScrollCueProps { - href: string; - label?: string; -} - -export default function LandingScrollCue({ - href, - label = 'Scroll to next section', -}: LandingScrollCueProps) { - const vl = strings.views.landing; - const deck = useLandingDeckContext(); - const nextId = href.startsWith('#') ? href.slice(1) : href; - const nextIndex = LANDING_DECK_SECTION_ORDER.indexOf(nextId); - const slideLabel = - deck && nextIndex >= 0 - ? `${vl.deckNext} Β· ${nextIndex + 1}/${deck.total}` - : label; - const ariaLabel = - deck && nextIndex >= 0 - ? vl.deckSlideOf - .replace('{current}', String(nextIndex + 1)) - .replace('{total}', String(deck.total)) - : label; - - return ( - - {slideLabel} - - ); -} diff --git a/web/src/views/Landing.tsx b/web/src/views/Landing.tsx index 5100fe4..e0ae4c9 100644 --- a/web/src/views/Landing.tsx +++ b/web/src/views/Landing.tsx @@ -48,27 +48,15 @@ export default function LandingPage() { > - + - + - +
- + - + - + - + - +
- + From 06698e4131c602a110a205aa8bae8b606fcd248d Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Wed, 17 Jun 2026 15:58:14 +0530 Subject: [PATCH 4/5] p for presentation --- web/app/globals.css | 31 +++ .../landing/LandingDeckControls.tsx | 7 + .../landing/LandingFeatureSpotlight.tsx | 27 +++ .../components/landing/LandingFinalCta.tsx | 2 +- web/src/components/landing/LandingHero.tsx | 2 + .../components/landing/LandingHeroTopBar.tsx | 2 +- .../components/landing/LandingPageSection.tsx | 11 + .../components/landing/LandingPathStrip.tsx | 4 +- .../components/landing/LandingProductMock.tsx | 197 +++++++++++++++++- .../components/landing/LandingSlideBadge.tsx | 19 ++ .../components/landing/landingLayout.test.ts | 31 +++ web/src/components/landing/landingLayout.ts | 14 +- web/src/strings.json | 92 +++++++- web/src/views/Landing.tsx | 78 +++++-- 14 files changed, 479 insertions(+), 38 deletions(-) create mode 100644 web/src/components/landing/LandingSlideBadge.tsx create mode 100644 web/src/components/landing/landingLayout.test.ts diff --git a/web/app/globals.css b/web/app/globals.css index 66e6f9d..6f36277 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -683,6 +683,7 @@ select:focus-visible { .landing-deck-slide, .landing-section { + position: relative; display: flex; width: 100%; height: 100%; @@ -903,6 +904,36 @@ select:focus-visible { color: var(--app-text-subtle); } +.landing-deck-speaker-note { + pointer-events: none; + max-width: 28rem; + text-align: right; + font-size: 0.7rem; + line-height: 1.35; + color: var(--app-text-muted); +} + +.landing-slide-badge { + pointer-events: none; + position: absolute; + top: 0.75rem; + right: 1.25rem; + z-index: 10; + display: none; + border-radius: 9999px; + border: 1px solid color-mix(in srgb, var(--app-link) 35%, transparent); + background: color-mix(in srgb, var(--app-link) 12%, transparent); + padding: 0.25rem 0.65rem; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--app-link); +} + +.landing-presenter .landing-slide-badge { + display: inline-flex; +} + .landing-presenter .landing-section { padding-top: 0.75rem; padding-bottom: 2.5rem; diff --git a/web/src/components/landing/LandingDeckControls.tsx b/web/src/components/landing/LandingDeckControls.tsx index 3c163d8..023e0a3 100644 --- a/web/src/components/landing/LandingDeckControls.tsx +++ b/web/src/components/landing/LandingDeckControls.tsx @@ -20,6 +20,7 @@ export default function LandingDeckControls() { const { activeIndex, total, + activeId, goNext, goPrev, presenterMode, @@ -30,6 +31,9 @@ export default function LandingDeckControls() { setAutoAdvanceMs, } = useLandingDeckRequired(); + const deckNotes = vl.deckNotes as Record | undefined; + const speakerNote = activeId && deckNotes ? deckNotes[activeId] ?? '' : ''; + const atStart = activeIndex <= 0; const atEnd = activeIndex >= total - 1; @@ -124,6 +128,9 @@ export default function LandingDeckControls() {

{vl.deckShortcutsHint}

+ {speakerNote ? ( +

{speakerNote}

+ ) : null}
) : null} diff --git a/web/src/components/landing/LandingFeatureSpotlight.tsx b/web/src/components/landing/LandingFeatureSpotlight.tsx index b7ebeed..949e561 100644 --- a/web/src/components/landing/LandingFeatureSpotlight.tsx +++ b/web/src/components/landing/LandingFeatureSpotlight.tsx @@ -21,6 +21,9 @@ interface LandingFeatureSpotlightProps { mockVariant: LandingProductMockVariant; ctaHref: string; ctaLabel: string; + secondaryCtaHref?: string; + secondaryCtaLabel?: string; + secondaryCtaExternal?: boolean; reversed?: boolean; } @@ -32,6 +35,9 @@ export default function LandingFeatureSpotlight({ mockVariant, ctaHref, ctaLabel, + secondaryCtaHref, + secondaryCtaLabel, + secondaryCtaExternal = false, reversed = false, }: LandingFeatureSpotlightProps) { const { ref: bulletsRef, inView: bulletsInView } = useInView(); @@ -63,6 +69,27 @@ export default function LandingFeatureSpotlight({ {ctaLabel} + {secondaryCtaHref && secondaryCtaLabel ? ( + secondaryCtaExternal ? ( + + {secondaryCtaLabel} + + + ) : ( + + {secondaryCtaLabel} + + + ) + ) : null} ); diff --git a/web/src/components/landing/LandingFinalCta.tsx b/web/src/components/landing/LandingFinalCta.tsx index 09b5752..ff3fdb4 100644 --- a/web/src/components/landing/LandingFinalCta.tsx +++ b/web/src/components/landing/LandingFinalCta.tsx @@ -59,7 +59,7 @@ export default function LandingFinalCta() {
- +
diff --git a/web/src/components/landing/LandingHero.tsx b/web/src/components/landing/LandingHero.tsx index c29787b..d2cc534 100644 --- a/web/src/components/landing/LandingHero.tsx +++ b/web/src/components/landing/LandingHero.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { CheckCircle2, ChevronRight } from 'lucide-react'; import LandingHeroTopBar from '@/components/landing/LandingHeroTopBar'; import LandingProductMock from '@/components/landing/LandingProductMock'; +import LandingSlideBadge from '@/components/landing/LandingSlideBadge'; import { LANDING_SECTION_IDS, landingGutterClass, @@ -24,6 +25,7 @@ export default function LandingHero() { return (
+ ).hero ?? ''} />
diff --git a/web/src/components/landing/LandingHeroTopBar.tsx b/web/src/components/landing/LandingHeroTopBar.tsx index fb86e89..8f8e08f 100644 --- a/web/src/components/landing/LandingHeroTopBar.tsx +++ b/web/src/components/landing/LandingHeroTopBar.tsx @@ -11,7 +11,7 @@ import { strings } from '@/lib/strings'; const NAV_ITEMS = [ { href: '#features', labelKey: 'navFeatures' as const }, { href: '#quick-start', labelKey: 'navQuickStart' as const }, - { href: '#google-setup', labelKey: 'navGoogleSetup' as const }, + { href: '#spotlight-google', labelKey: 'navGoogleSetup' as const }, ] as const; /** Title-slide chrome: logo, section links, and primary actions (hero slide only). */ diff --git a/web/src/components/landing/LandingPageSection.tsx b/web/src/components/landing/LandingPageSection.tsx index 274e476..8f25d2f 100644 --- a/web/src/components/landing/LandingPageSection.tsx +++ b/web/src/components/landing/LandingPageSection.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import Reveal from '@/components/Reveal'; +import LandingSlideBadge from '@/components/landing/LandingSlideBadge'; import { landingContentClass, landingGutterClass, @@ -10,6 +11,7 @@ import { landingSectionClass, landingSectionPad, } from '@/components/landing/landingLayout'; +import { strings } from '@/lib/strings'; export interface LandingPageSectionProps { id?: string; @@ -19,6 +21,12 @@ export interface LandingPageSectionProps { children: ReactNode; } +function slideBadgeForId(id: string | undefined): string { + if (!id) return ''; + const badges = strings.views.landing.deckBadges as Record | undefined; + return badges?.[id] ?? ''; +} + export default function LandingPageSection({ id, fullBleed = false, @@ -29,12 +37,15 @@ export default function LandingPageSection({ ? `${landingSectionBodyClass} ${landingContentClass}` : `${landingSectionBodyCenteredClass} ${landingContentClass} ${landingGutterClass}`; + const badge = slideBadgeForId(id); + return ( + {badge ? : null}
{children}
); diff --git a/web/src/components/landing/LandingPathStrip.tsx b/web/src/components/landing/LandingPathStrip.tsx index 6b20759..a6b35a2 100644 --- a/web/src/components/landing/LandingPathStrip.tsx +++ b/web/src/components/landing/LandingPathStrip.tsx @@ -15,8 +15,8 @@ const vl = strings.views.landing; const STEPS = [ { step: 1, id: 'quick-start', icon: Download, label: vl.pathStepInstall, hint: vl.pathStepInstallHint }, { step: 2, id: 'spotlights', icon: Play, label: vl.pathStepCrawl, hint: vl.pathStepCrawlHint }, - { step: 3, id: 'google-setup', icon: Settings2, label: vl.pathStepGoogle, hint: vl.pathStepGoogleHint }, - { step: 4, id: 'features', icon: BarChart2, label: vl.pathStepReport, hint: vl.pathStepReportHint }, + { step: 3, id: 'spotlight-google', icon: Settings2, label: vl.pathStepGoogle, hint: vl.pathStepGoogleHint }, + { step: 4, id: 'spotlight-compare-export', icon: BarChart2, label: vl.pathStepReport, hint: vl.pathStepReportHint }, ] as const; export default function LandingPathStrip() { diff --git a/web/src/components/landing/LandingProductMock.tsx b/web/src/components/landing/LandingProductMock.tsx index 48cdd7b..78f11e6 100644 --- a/web/src/components/landing/LandingProductMock.tsx +++ b/web/src/components/landing/LandingProductMock.tsx @@ -11,7 +11,14 @@ import { CompactStackedBar, } from '@/components/charts/compact'; -export type LandingProductMockVariant = 'default' | 'crawl' | 'issues'; +export type LandingProductMockVariant = + | 'default' + | 'crawl' + | 'issues' + | 'google' + | 'contentStudio' + | 'aiChat' + | 'compareExport'; interface LandingProductMockProps { variant?: LandingProductMockVariant; @@ -26,10 +33,23 @@ const NAV_ITEMS = [ { label: 'Overview', activeFor: ['default'] as const }, { label: 'Issues', activeFor: ['issues'] as const }, { label: 'All URLs', activeFor: ['crawl'] as const }, - { label: 'Search', activeFor: [] as const }, - { label: 'Export', activeFor: [] as const }, + { label: 'Search', activeFor: ['google'] as const }, + { label: 'Write', activeFor: ['contentStudio'] as const }, + { label: 'Chat', activeFor: ['aiChat'] as const }, + { label: 'Compare', activeFor: ['compareExport'] as const }, + { label: 'Export', activeFor: ['compareExport'] as const }, ]; +const MOCK_PATHS: Record = { + default: 'overview', + crawl: 'links', + issues: 'issues', + google: 'search-performance', + contentStudio: 'write', + aiChat: 'chat', + compareExport: 'compare', +}; + const MOCK_GSC_BAR_HEIGHTS = [40, 65, 52, 78, 45, 88, 60, 72, 55, 80, 68, 92]; function MockLineChart({ label }: { label?: string }) { @@ -271,6 +291,173 @@ function OverviewPanel() { ); } +function GooglePanel() { + return ( + <> +
+ + + +
+
+ + + + +
+ + +
+
+
+ +
+ + + +
+
+ + ); +} + +function MockTermRow({ term, count, target, tone }: { term: string; count: number; target: number; tone: 'ok' | 'warn' | 'bad' }) { + const toneClass = + tone === 'ok' ? 'bg-emerald-500' : tone === 'warn' ? 'bg-amber-500' : 'bg-red-500'; + const pct = Math.min(100, Math.round((count / Math.max(target, 1)) * 100)); + return ( +
+
+ {term} + + {count}/{target} + +
+
+
+
+
+ ); +} + +function ContentStudioPanel() { + return ( +
+
+
+

Title

+

Technical SEO Audit Guide 2026

+
+
+

Body

+
+ + + + +
+
+
+ +
+ ); +} + +function AiChatPanel() { + return ( +
+
+

Summarize site health and export a PDF report.

+
+
+

Health score 82 (+4). 12 critical issues remain…

+
+ + + +
+
+
+ + Download PDF + + + View issues table + +
+
+ ); +} + +function CompareExportPanel() { + return ( + <> +
+ + + +
+
+ + + + + + +
+
+ + Export PDF + + + Export HTML + +
+ + ); +} + +function renderPanel(variant: LandingProductMockVariant) { + switch (variant) { + case 'crawl': + return ; + case 'issues': + return ; + case 'google': + return ; + case 'contentStudio': + return ; + case 'aiChat': + return ; + case 'compareExport': + return ; + default: + return ; + } +} + export default function LandingProductMock({ variant = 'default', className = '', @@ -298,7 +485,7 @@ export default function LandingProductMock({ - https://site-audit.local/{variant === 'crawl' ? 'links' : variant === 'issues' ? 'issues' : 'overview'} + https://site-audit.local/{MOCK_PATHS[variant]}
@@ -326,7 +513,7 @@ export default function LandingProductMock({
- {variant === 'crawl' ? : variant === 'issues' ? : } + {renderPanel(variant)}
diff --git a/web/src/components/landing/LandingSlideBadge.tsx b/web/src/components/landing/LandingSlideBadge.tsx new file mode 100644 index 0000000..13502ca --- /dev/null +++ b/web/src/components/landing/LandingSlideBadge.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { useLandingDeckRequired } from '@/components/landing/LandingDeckContext'; + +export interface LandingSlideBadgeProps { + label: string; +} + +export default function LandingSlideBadge({ label }: LandingSlideBadgeProps) { + const { presenterMode } = useLandingDeckRequired(); + + if (!presenterMode || !label) return null; + + return ( + + {label} + + ); +} diff --git a/web/src/components/landing/landingLayout.test.ts b/web/src/components/landing/landingLayout.test.ts new file mode 100644 index 0000000..e0d1700 --- /dev/null +++ b/web/src/components/landing/landingLayout.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { + LANDING_DECK_SECTION_ORDER, + LANDING_SECTION_IDS, +} from '@/components/landing/landingLayout'; + +describe('landingLayout deck order', () => { + it('includes all spotlight section ids', () => { + expect(LANDING_DECK_SECTION_ORDER).toContain(LANDING_SECTION_IDS.spotlightGoogle); + expect(LANDING_DECK_SECTION_ORDER).toContain(LANDING_SECTION_IDS.spotlightContentStudio); + expect(LANDING_DECK_SECTION_ORDER).toContain(LANDING_SECTION_IDS.spotlightAiChat); + expect(LANDING_DECK_SECTION_ORDER).toContain(LANDING_SECTION_IDS.spotlightCompareExport); + }); + + it('does not include removed google-setup slide', () => { + expect(LANDING_DECK_SECTION_ORDER).not.toContain('google-setup'); + }); + + it('orders setup before product spotlights', () => { + const quickStart = LANDING_DECK_SECTION_ORDER.indexOf(LANDING_SECTION_IDS.quickStart); + const crawl = LANDING_DECK_SECTION_ORDER.indexOf(LANDING_SECTION_IDS.spotlights); + const google = LANDING_DECK_SECTION_ORDER.indexOf(LANDING_SECTION_IDS.spotlightGoogle); + expect(quickStart).toBeGreaterThan(-1); + expect(crawl).toBeGreaterThan(quickStart); + expect(google).toBeGreaterThan(crawl); + }); + + it('has 15 slides including footer', () => { + expect(LANDING_DECK_SECTION_ORDER).toHaveLength(15); + }); +}); diff --git a/web/src/components/landing/landingLayout.ts b/web/src/components/landing/landingLayout.ts index b444295..63ee9c2 100644 --- a/web/src/components/landing/landingLayout.ts +++ b/web/src/components/landing/landingLayout.ts @@ -18,11 +18,14 @@ export const LANDING_SECTION_IDS = { hero: 'hero', stats: 'stats', getStarted: 'get-started', + quickStart: 'quick-start', spotlights: 'spotlights', spotlightIssues: 'spotlight-issues', + spotlightGoogle: 'spotlight-google', + spotlightContentStudio: 'spotlight-content-studio', + spotlightAiChat: 'spotlight-ai-chat', + spotlightCompareExport: 'spotlight-compare-export', useCases: 'use-cases', - quickStart: 'quick-start', - googleSetup: 'google-setup', features: 'features', limitations: 'limitations', finalCta: 'final-cta', @@ -34,11 +37,14 @@ export const LANDING_DECK_SECTION_ORDER: readonly string[] = [ LANDING_SECTION_IDS.hero, LANDING_SECTION_IDS.stats, LANDING_SECTION_IDS.getStarted, + LANDING_SECTION_IDS.quickStart, LANDING_SECTION_IDS.spotlights, LANDING_SECTION_IDS.spotlightIssues, + LANDING_SECTION_IDS.spotlightGoogle, + LANDING_SECTION_IDS.spotlightContentStudio, + LANDING_SECTION_IDS.spotlightAiChat, + LANDING_SECTION_IDS.spotlightCompareExport, LANDING_SECTION_IDS.useCases, - LANDING_SECTION_IDS.quickStart, - LANDING_SECTION_IDS.googleSetup, LANDING_SECTION_IDS.features, LANDING_SECTION_IDS.limitations, LANDING_SECTION_IDS.finalCta, diff --git a/web/src/strings.json b/web/src/strings.json index e064394..cac5eb6 100644 --- a/web/src/strings.json +++ b/web/src/strings.json @@ -1462,7 +1462,8 @@ "metaDescription": "Crawl your sites, find technical SEO issues, connect Search Console and Analytics, and export client reports β€” free and self-hosted.", "navFeatures": "Features", "navQuickStart": "Quick start", - "navGoogleSetup": "Google setup", + "navGoogleSetup": "Integrations", + "navIntegrations": "Integrations", "navGithub": "GitHub", "navOpenApp": "Dashboard", "navRunAudit": "Run audit", @@ -1476,13 +1477,13 @@ "deckShortcutsHint": "← β†’ or Space to navigate Β· P presenter Β· F fullscreen", "heroEyebrow": "Open source Β· Self-hosted", "heroTitle": "Technical SEO audits you control", - "heroSubtitle": "Crawl every URL, prioritize real issues, connect Search Console and Analytics, and export client reports β€” on infrastructure you own.", + "heroSubtitle": "Tired of expensive subscriptions, per-seat pricing, and crawl data on someone else's servers? Run crawl, issues, Lighthouse, Search Console, content writing, and AI on infrastructure you control.", "heroBullets": [ - "No subscription tiers β€” MIT licensed and self-hosted", - "Your crawl data and reports stay on your infrastructure", - "Export HTML and PDF reports ready for clients" + "Open-source Screaming Frog alternative β€” MIT licensed, self-hosted", + "Your crawl data and reports stay in your PostgreSQL database", + "Export HTML and PDF client reports β€” no gated exports" ], - "heroProofNoSubscription": "No subscription tiers", + "heroProofNoSubscription": "FREE Β· SELF-HOSTED", "heroProofLocalData": "Your data stays local", "ctaDashboard": "Open dashboard", "ctaRunAudit": "Run your first audit", @@ -1490,7 +1491,7 @@ "scrollHint": "New here? See how to get started.", "statsEyebrow": "Self-hosted", "statsTitle": "Built for teams who own their data", - "statsSubtitle": "MIT-licensed audits on your infrastructure β€” no vendor lock-in, no subscription tiers, and your crawl data stays local.", + "statsSubtitle": "Full technical SEO workflows without vendor lock-in β€” crawl, integrate, write, and export from a stack you own.", "stat1Label": "License", "stat1Value": "MIT Β· Open source", "stat1Hint": "Fork, modify, and deploy without restrictions", @@ -1501,8 +1502,8 @@ "stat3Value": "Crawl Β· Lighthouse Β· Issues", "stat3Hint": "Full-site crawl, performance scores, and issue triage", "stat4Label": "AI workflow", - "stat4Value": "Chat Β· MCP tools", - "stat4Hint": "Ask questions and automate workflows with MCP", + "stat4Value": "340 MCP tools", + "stat4Hint": "AI chat plus programmatic access from Cursor and Claude Desktop", "trustTitle": "Runs on your stack", "trustGithub": "View on GitHub", "trustLicense": "MIT License", @@ -1529,6 +1530,77 @@ "spotlightsSectionSubtitle": "Familiar audit workflows β€” crawl maps, issue boards, and performance scores β€” in a UI built for daily SEO work.", "spotlight1Cta": "Run your first crawl", "spotlight2Cta": "Open the dashboard", + "spotlight3Eyebrow": "Google integrations", + "spotlight3Title": "Search Performance and traffic from your credentials", + "spotlight3Description": "Connect Google Search Console and GA4 with OAuth β€” nothing is fabricated when Google isn't connected. See clicks, impressions, sessions, and keyword blends from your own data.", + "spotlight3Bullets": [ + "Search Performance: clicks, impressions, and top queries from GSC", + "GA4 sessions and users per property", + "Keywords Explorer blends on-site frequency with Search Console data" + ], + "spotlight3Cta": "Open Integrations", + "spotlight3CtaHref": "/pipeline", + "spotlight3SecondaryCta": "Full setup guide", + "spotlight3SecondaryCtaHref": "https://github.com/codefrydev/WebsiteProfiling#google-search-console--analytics", + "spotlight4Eyebrow": "Content Studio", + "spotlight4Title": "Write with live SEO scoring as you type", + "spotlight4Description": "Draft and optimize content powered by Search Console data and on-page heuristics from your audit. The Guided Draft Wizard walks you through intent, outline, and tone.", + "spotlight4Bullets": [ + "Live SEO grade with recommended terms and coverage gaps", + "Terms highlight in the editor so you know what to fix", + "Save drafts per property β€” title, meta, rich text or Markdown" + ], + "spotlight4Cta": "Open Content Studio", + "spotlight4CtaHref": "/write", + "spotlight5Eyebrow": "AI Chat", + "spotlight5Title": "Ask your audit in plain English", + "spotlight5Description": "The assistant calls real tools against your crawl data β€” charts, tables, and download buttons appear in chat. It doesn't invent URLs or scores.", + "spotlight5Bullets": [ + "\"What are my top critical issues?\" β€” real issue tables", + "\"Export a PDF report\" β€” download buttons in the thread", + "340 MCP tools for Cursor and Claude Desktop β€” same data, programmatic" + ], + "spotlight5Cta": "Open AI Chat", + "spotlight5CtaHref": "/chat", + "spotlight6Eyebrow": "Compare & export", + "spotlight6Title": "Track fixes and ship client reports", + "spotlight6Description": "Run a second crawl after fixes? Compare audits shows what improved and what regressed. Export delivers HTML or PDF β€” no upgrade tiers, no gated exports.", + "spotlight6Bullets": [ + "Category deltas and issue diffs between audit runs", + "Google metric changes when Search Console is connected", + "HTML and PDF exports ready for agencies and clients" + ], + "spotlight6Cta": "Compare audit runs", + "spotlight6CtaHref": "/compare", + "deckBadges": { + "hero": "MIT Β· Open source", + "stats": "Self-hosted Β· PostgreSQL", + "get-started": "4 steps to first audit", + "quick-start": "Docker Β· local dev", + "spotlights": "Site crawl", + "spotlight-issues": "Lighthouse Β· Issues", + "spotlight-google": "GSC Β· GA4", + "spotlight-content-studio": "Live SEO scoring", + "spotlight-ai-chat": "340 MCP tools", + "spotlight-compare-export": "HTML Β· PDF export", + "limitations": "Honest scope" + }, + "deckNotes": { + "hero": "0:00 β€” Hook: expensive subscriptions, per-seat pricing, data on vendor servers.", + "stats": "0:15 β€” What it is: open-source alternative, MIT, your PostgreSQL database.", + "get-started": "0:45 β€” Quick setup: clone, Docker or local-run, add a property.", + "quick-start": "0:45 β€” Install commands β€” point to README, don't dwell.", + "spotlights": "1:30 β€” Run audit: crawl pipeline, URL map, site structure.", + "spotlight-issues": "2:30 β€” Overview + issues: health score, severity board, drill into 1–2 issues.", + "spotlight-google": "6:00 β€” Google: GSC queries, GA4 traffic, honest empty states.", + "spotlight-content-studio": "7:00 β€” Content Studio: Guided Draft Wizard, live score, term highlights.", + "spotlight-ai-chat": "8:15 β€” AI Chat: one strong prompt, chart + export in thread.", + "spotlight-compare-export": "9:00 β€” Compare two runs + PDF export for agencies.", + "use-cases": "Agencies, in-house teams, developers β€” portfolio without per-seat fees.", + "features": "5:15 β€” Performance, on-page, image SEO, accessibility β€” same crawl.", + "limitations": "9:45 β€” Honest scope: not Ahrefs, no live backlink index.", + "final-cta": "9:45 β€” CTA: star on GitHub, run your first crawl today." + }, "useCasesEyebrow": "Who it's for", "useCasesTitle": "One platform, different workflows", "useCasesSubtitle": "Whether you audit client sites or your own properties, Site Audit fits how technical SEO teams actually work.", @@ -1565,7 +1637,7 @@ "finalCtaBullets": [ "Spin up with Docker Compose or ./local-run", "Run a crawl from the pipeline in minutes", - "Triage issues and export reports for clients" + "Compare runs, triage issues, and export reports for clients" ], "finalCtaInstallLink": "View install commands", "footerProductTitle": "Product", diff --git a/web/src/views/Landing.tsx b/web/src/views/Landing.tsx index e0ae4c9..5d0e9fd 100644 --- a/web/src/views/Landing.tsx +++ b/web/src/views/Landing.tsx @@ -12,7 +12,6 @@ import LandingHero from '@/components/landing/LandingHero'; import LandingFeatureSpotlight from '@/components/landing/LandingFeatureSpotlight'; import LandingFinalCta from '@/components/landing/LandingFinalCta'; import LandingFooter from '@/components/landing/LandingFooter'; -import LandingGoogleSetup from '@/components/landing/LandingGoogleSetup'; import LandingLimitations from '@/components/landing/LandingLimitations'; import LandingPathStrip from '@/components/landing/LandingPathStrip'; import LandingQuickStart from '@/components/landing/LandingQuickStart'; @@ -56,6 +55,10 @@ export default function LandingPage() { + + + +
@@ -80,27 +83,72 @@ export default function LandingPage() { - - + + - - + + + + + + - - + + + + + + From 68e9f536326adefd837fa16b665404a487c7377f Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Wed, 17 Jun 2026 16:06:42 +0530 Subject: [PATCH 5/5] cicd --- tests/test_browser_fetcher_unit.py | 177 +++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/tests/test_browser_fetcher_unit.py b/tests/test_browser_fetcher_unit.py index a1a7538..29032a8 100644 --- a/tests/test_browser_fetcher_unit.py +++ b/tests/test_browser_fetcher_unit.py @@ -846,3 +846,180 @@ def test_browser_fetcher_close_when_loop_unavailable(fake_playwright) -> None: fetcher._loop = None fetcher._closed = False fetcher.close() + + +class _NavRequest: + def __init__(self, *, is_navigation: bool) -> None: + self._is_navigation = is_navigation + + def is_navigation_request(self) -> bool: + return self._is_navigation + + +class _NavResponse: + def __init__(self, page: "_NavResponsePage", *, status: int, is_navigation: bool) -> None: + self.status = status + self.headers = {"content-type": "text/html", "Location": "https://example.com/new"} + self.request = _NavRequest(is_navigation=is_navigation) + self.frame = page.main_frame + + +class _NavResponsePage(_FakePage): + def __init__(self) -> None: + super().__init__() + self.main_frame = object() + self.url = "https://example.com/new" + + async def goto(self, _url: str, **_kwargs: Any) -> _FakeResponse: + for handler in self._handlers.get("response", []): + handler(_NavResponse(self, status=301, is_navigation=True)) + return _FakeResponse() + + +class _NavResponseContext(_FakeContext): + async def new_page(self) -> _NavResponsePage: + return _NavResponsePage() + + +class _NavResponseBrowser(_FakeBrowser): + async def new_context(self, **_kwargs: Any) -> _NavResponseContext: + return _NavResponseContext() + + +class _NavResponseChromium: + async def launch(self, **_kwargs: Any) -> _NavResponseBrowser: + return _NavResponseBrowser() + + +class _NavResponsePlaywright: + chromium = _NavResponseChromium() + + async def stop(self) -> None: + return None + + +class _NavResponsePlaywrightContext: + async def start(self) -> _NavResponsePlaywright: + return _NavResponsePlaywright() + + +def test_browser_fetcher_prefers_recorded_navigation_response(monkeypatch: pytest.MonkeyPatch) -> None: + from website_profiling.crawl.fetchers.browser import BrowserFetcher + + fake_api = MagicMock() + fake_api.async_playwright = lambda: _NavResponsePlaywrightContext() + monkeypatch.setitem(__import__("sys").modules, "playwright", MagicMock(async_api=fake_api)) + monkeypatch.setitem(__import__("sys").modules, "playwright.async_api", fake_api) + + fetcher = BrowserFetcher(timeout=5, js_concurrency=1, extra_wait_ms=0, block_resources=False) + try: + result = fetcher.fetch("https://example.com/old") + assert result.status == 301 + assert result.redirect_chain_length == 1 + assert result.text is None + finally: + fetcher.close() + + +class _BrokenNavResponse: + @property + def request(self) -> None: + raise RuntimeError("response handler failed") + + +class _BrokenNavResponsePage(_FakePage): + async def goto(self, _url: str, **_kwargs: Any) -> _FakeResponse: + for handler in self._handlers.get("response", []): + handler(_BrokenNavResponse()) + return _FakeResponse() + + +class _BrokenNavResponseContext(_FakeContext): + async def new_page(self) -> _BrokenNavResponsePage: + return _BrokenNavResponsePage() + + +class _BrokenNavResponseBrowser(_FakeBrowser): + async def new_context(self, **_kwargs: Any) -> _BrokenNavResponseContext: + return _BrokenNavResponseContext() + + +class _BrokenNavResponseChromium: + async def launch(self, **_kwargs: Any) -> _BrokenNavResponseBrowser: + return _BrokenNavResponseBrowser() + + +class _BrokenNavResponsePlaywright: + chromium = _BrokenNavResponseChromium() + + async def stop(self) -> None: + return None + + +class _BrokenNavResponsePlaywrightContext: + async def start(self) -> _BrokenNavResponsePlaywright: + return _BrokenNavResponsePlaywright() + + +def test_browser_fetcher_response_handler_swallows_errors(monkeypatch: pytest.MonkeyPatch) -> None: + from website_profiling.crawl.fetchers.browser import BrowserFetcher + + fake_api = MagicMock() + fake_api.async_playwright = lambda: _BrokenNavResponsePlaywrightContext() + monkeypatch.setitem(__import__("sys").modules, "playwright", MagicMock(async_api=fake_api)) + monkeypatch.setitem(__import__("sys").modules, "playwright.async_api", fake_api) + + fetcher = BrowserFetcher(timeout=5, js_concurrency=1, extra_wait_ms=0, block_resources=False) + try: + result = fetcher.fetch("https://example.com/") + assert result.status == 200 + finally: + fetcher.close() + + +class _RemoveListenerFailPage(_FakePage): + def remove_listener(self, event: str, handler: Any) -> None: + raise RuntimeError("listener missing") + + +class _RemoveListenerFailContext(_FakeContext): + async def new_page(self) -> _RemoveListenerFailPage: + return _RemoveListenerFailPage() + + +class _RemoveListenerFailBrowser(_FakeBrowser): + async def new_context(self, **_kwargs: Any) -> _RemoveListenerFailContext: + return _RemoveListenerFailContext() + + +class _RemoveListenerFailChromium: + async def launch(self, **_kwargs: Any) -> _RemoveListenerFailBrowser: + return _RemoveListenerFailBrowser() + + +class _RemoveListenerFailPlaywright: + chromium = _RemoveListenerFailChromium() + + async def stop(self) -> None: + return None + + +class _RemoveListenerFailPlaywrightContext: + async def start(self) -> _RemoveListenerFailPlaywright: + return _RemoveListenerFailPlaywright() + + +def test_browser_fetcher_remove_listener_failure_swallowed(monkeypatch: pytest.MonkeyPatch) -> None: + from website_profiling.crawl.fetchers.browser import BrowserFetcher + + fake_api = MagicMock() + fake_api.async_playwright = lambda: _RemoveListenerFailPlaywrightContext() + monkeypatch.setitem(__import__("sys").modules, "playwright", MagicMock(async_api=fake_api)) + monkeypatch.setitem(__import__("sys").modules, "playwright.async_api", fake_api) + + fetcher = BrowserFetcher(timeout=5, js_concurrency=1, extra_wait_ms=0, block_resources=False) + try: + result = fetcher.fetch("https://example.com/") + assert result.status == 200 + finally: + fetcher.close()