Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
content="OpenCMO, AI CMO, SEO audit, GEO audit, AI search visibility, community visibility, open-source growth tools, self-hosted growth console"
/>
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
<!--
Opt the React-managed app shell out of browser/Google Translate.
Chrome Translate replaces text nodes in place, which breaks React's
reconciliation and surfaces as `NotFoundError: Failed to execute
'insertBefore' on 'Node'` followed by a blank `#root`. The static
SEO copy in <main id="static-site-copy"> below opts back in via
translate="yes" so crawlers and translated previews still see it.
-->
<meta name="google" content="notranslate" />
<meta name="theme-color" content="#0f172a" />
<link rel="canonical" href="https://www.aidcmo.com/" />

Expand Down Expand Up @@ -62,8 +71,8 @@
}
</script>
</head>
<body class="bg-zinc-50 text-zinc-900 font-sans antialiased selection:bg-indigo-100 selection:text-indigo-900">
<main id="static-site-copy">
<body class="bg-zinc-50 text-zinc-900 font-sans antialiased selection:bg-indigo-100 selection:text-indigo-900 notranslate" translate="no">
<main id="static-site-copy" translate="yes" class="translate">
<header>
<p>aidCMO</p>
<h1>OpenCMO free trial: enter one URL and open your own overseas growth console.</h1>
Expand Down Expand Up @@ -99,7 +108,7 @@ <h2>OpenCMO — AI CMO growth console</h2>
</ul>
</section>
</main>
<div id="root"></div>
<div id="root" translate="no" class="notranslate"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
7 changes: 5 additions & 2 deletions frontend/src/components/project/ProjectCommandCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ function AgentCard({
}

function getLatestSerpTimestamp(latest: LatestScans) {
const timestamps = latest.serp
// Tolerate older/partial payloads where `serp` is missing — without this
// fallback the optional-chained `.map` chain blows up and surfaces as a
// blank project page.
const timestamps = (latest.serp ?? [])
.map((snapshot) => snapshot.checked_at)
.filter((value): value is string => Boolean(value))
.map((value) => utcDate(value).getTime());
Expand Down Expand Up @@ -167,7 +170,7 @@ function hasAnyScanData(latest: LatestScans, latestMonitoring?: MonitoringSummar
latest.seo ||
latest.geo ||
latest.community ||
latest.serp.length > 0 ||
(latest.serp?.length ?? 0) > 0 ||
latestMonitoring,
);
}
Expand Down
74 changes: 74 additions & 0 deletions tests/test_frontend_translate_optout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Regression tests for issue #26: Chrome Translate blanks the SPA.

Chrome/Google Translate replaces text nodes inside the React-managed DOM,
which breaks reconciliation and surfaces as
`NotFoundError: Failed to execute 'insertBefore' on 'Node'` followed by
`#root` being emptied. We mitigate this by marking the body and `#root`
with `translate="no"` / `notranslate`, plus a `<meta name="google"
content="notranslate">` hint. The static SEO copy in
`<main id="static-site-copy">` opts back in via `translate="yes"` so
crawlers still see it.

These tests guard those markers so a future edit to `index.html` cannot
silently regress the fix.
"""

from __future__ import annotations

import re
from pathlib import Path

_INDEX_HTML = Path("frontend/index.html")


def _read_index_html() -> str:
text = _INDEX_HTML.read_text(encoding="utf-8")
assert text, "frontend/index.html should not be empty"
return text


def _strip_html_comments(text: str) -> str:
"""Drop HTML comments so the example markup inside our own opt-out comment
block doesn't accidentally satisfy / shadow the real tag regexes.
"""
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)


def test_index_html_has_google_notranslate_meta() -> None:
text = _read_index_html()
assert re.search(
r'<meta\s+name="google"\s+content="notranslate"\s*/?>',
text,
), 'expected <meta name="google" content="notranslate"> opt-out hint'


def test_body_opts_out_of_translation() -> None:
text = _strip_html_comments(_read_index_html())
body_match = re.search(r"<body(\s[^>]*)?>", text)
assert body_match, "frontend/index.html should declare a <body> tag"
attrs = body_match.group(1) or ""
assert 'translate="no"' in attrs, "<body> must carry translate=\"no\""
assert "notranslate" in attrs, "<body> class list must include 'notranslate'"


def test_react_root_opts_out_of_translation() -> None:
text = _strip_html_comments(_read_index_html())
root_match = re.search(r'<div\s+id="root"(\s[^>]*)?>', text)
assert root_match, "frontend/index.html should declare <div id=\"root\"> for React"
attrs = root_match.group(1) or ""
assert 'translate="no"' in attrs, "#root must carry translate=\"no\""
assert "notranslate" in attrs, "#root must include the 'notranslate' class"


def test_static_seo_copy_remains_translatable() -> None:
"""SEO/no-JS fallback copy lives outside #root; it should stay translatable
so crawlers and translated previews can still read it.
"""
text = _strip_html_comments(_read_index_html())
main_match = re.search(r'<main\s+id="static-site-copy"(\s[^>]*)?>', text)
assert main_match, "static SEO copy <main> should be present"
attrs = main_match.group(1) or ""
assert 'translate="yes"' in attrs, (
"static SEO copy must explicitly opt back into translation, "
"otherwise the body-level translate=\"no\" would suppress it too"
)
Loading