ok
This commit is contained in:
parent
c2070693fb
commit
909e241907
55 changed files with 594 additions and 223 deletions
|
|
@ -7,7 +7,9 @@ use axum::middleware::Next;
|
|||
use axum::response::Response;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::language::{language_from_accept_language, query_string_with_language};
|
||||
use crate::language::{
|
||||
language_from_accept_language, query_string_with_language, supported_languages,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
const OG_PLACEHOLDER: &str =
|
||||
|
|
@ -133,6 +135,18 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
|
|||
description: "Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.",
|
||||
indexable: true,
|
||||
}),
|
||||
"/terms" => Some(SeoPage {
|
||||
canonical_path: "/terms",
|
||||
title: "Terms of Service | Perfect Postcode",
|
||||
description: "The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.",
|
||||
indexable: true,
|
||||
}),
|
||||
"/privacy" => Some(SeoPage {
|
||||
canonical_path: "/privacy",
|
||||
title: "Privacy Policy | Perfect Postcode",
|
||||
description: "How Perfect Postcode collects, uses and protects your data: account details, payments, saved searches, AI queries, analytics and your UK GDPR rights.",
|
||||
indexable: true,
|
||||
}),
|
||||
"/dashboard" => Some(SeoPage {
|
||||
canonical_path: "/dashboard",
|
||||
title: "Perfect Postcode dashboard",
|
||||
|
|
@ -163,7 +177,14 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
|
|||
description: "Accept your invitation to explore property prices, energy ratings, crime stats, school ratings, and more across England.",
|
||||
indexable: false,
|
||||
}),
|
||||
_ => None,
|
||||
// Generated growth pages (cheaper-twin / value-index landing pages). Without this they
|
||||
// would 404 (should_return_404 keys off seo_page_for_path); see analysis/build_pages.py.
|
||||
_ => crate::generated_data_pages::data_page(path).map(|d| SeoPage {
|
||||
canonical_path: d.path,
|
||||
title: d.title,
|
||||
description: d.description,
|
||||
indexable: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,12 +227,46 @@ fn not_found_response(public_url: &str, path: &str) -> Response {
|
|||
<title>Page not found - Perfect Postcode</title>
|
||||
<meta name="description" content="This Perfect Postcode page could not be found." />
|
||||
<link rel="canonical" href="{public_url_e}/" />
|
||||
<style>
|
||||
:root {{ color-scheme: dark; }}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0b1220;
|
||||
color: #e7ecf3;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}}
|
||||
main {{ max-width: 32rem; }}
|
||||
.brand {{ font-weight: 700; letter-spacing: .01em; color: #2dd4bf; font-size: 1.1rem; margin-bottom: 1.5rem; }}
|
||||
h1 {{ font-size: 2rem; margin: 0 0 .5rem; }}
|
||||
p {{ color: #9fb0c3; line-height: 1.6; margin: .5rem 0; }}
|
||||
code {{ color: #cbd5e1; background: rgba(148,163,184,.15); padding: .1rem .35rem; border-radius: .25rem; word-break: break-all; }}
|
||||
a.cta {{
|
||||
display: inline-block;
|
||||
margin-top: 1.5rem;
|
||||
padding: .65rem 1.25rem;
|
||||
border-radius: .5rem;
|
||||
background: #2dd4bf;
|
||||
color: #0b1220;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}}
|
||||
a.cta:hover {{ background: #5eead4; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="brand">Perfect Postcode</div>
|
||||
<h1>Page not found</h1>
|
||||
<p>The requested path was not found: {path_e}</p>
|
||||
<p><a href="{public_url_e}/">Go to Perfect Postcode</a></p>
|
||||
<p>We couldn't find <code>{path_e}</code>.</p>
|
||||
<p>It may have moved, or the link might be incomplete.</p>
|
||||
<a class="cta" href="{public_url_e}/">Back to Perfect Postcode</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#
|
||||
|
|
@ -234,7 +289,13 @@ fn route_seo_tags(
|
|||
) -> String {
|
||||
let path_e = escape_attr(path);
|
||||
let query_e = escape_attr(query_string);
|
||||
let screenshot_query_string = query_string_with_language(query_string, language);
|
||||
// Generated data pages have a clean URL with no query, but their OG card must frame the finding's
|
||||
// map view rather than the default whole-England map. Use the registry's screenshot query.
|
||||
let screenshot_query_base = match crate::generated_data_pages::data_page(trim_trailing_slash(path)) {
|
||||
Some(dp) if !dp.screenshot_query.is_empty() => dp.screenshot_query,
|
||||
_ => query_string,
|
||||
};
|
||||
let screenshot_query_string = query_string_with_language(screenshot_query_base, language);
|
||||
let screenshot_query_e = escape_attr(&screenshot_query_string);
|
||||
let public_url_e = escape_attr(public_url.trim_end_matches('/'));
|
||||
let canonical_path_e = escape_attr(page.canonical_path);
|
||||
|
|
@ -266,9 +327,26 @@ fn route_seo_tags(
|
|||
"noindex,follow"
|
||||
};
|
||||
|
||||
// Emit hreflang alternates for indexable pages so search engines can serve
|
||||
// the right localized variant (the SPA switches language via ?lang=).
|
||||
let hreflang = if page.indexable {
|
||||
let mut tags = String::new();
|
||||
for lang in supported_languages() {
|
||||
tags.push_str(&format!(
|
||||
"\n <link rel=\"alternate\" hreflang=\"{lang}\" href=\"{public_url_e}{canonical_path_e}?lang={lang}\" />"
|
||||
));
|
||||
}
|
||||
tags.push_str(&format!(
|
||||
"\n <link rel=\"alternate\" hreflang=\"x-default\" href=\"{public_url_e}{canonical_path_e}\" />"
|
||||
));
|
||||
tags
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<meta name="robots" content="{robots}" />
|
||||
<link rel="canonical" href="{canonical_url}" />
|
||||
<link rel="canonical" href="{canonical_url}" />{hreflang}
|
||||
<meta property="og:title" content="{title_e}" />
|
||||
<meta property="og:description" content="{description_e}" />
|
||||
<meta property="og:type" content="website" />
|
||||
|
|
@ -406,3 +484,48 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
|||
parts.headers.remove(header::CONTENT_LENGTH);
|
||||
Response::from_parts(parts, Body::from(html))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legal_pages_resolve_and_are_not_404() {
|
||||
for path in ["/terms", "/privacy", "/terms/", "/privacy/"] {
|
||||
assert!(
|
||||
seo_page_for_path(path).is_some(),
|
||||
"{path} should resolve to an SEO page (legal pages must render, not 404)"
|
||||
);
|
||||
assert!(
|
||||
!should_return_404(path),
|
||||
"{path} must not return a server 404"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_paths_still_404() {
|
||||
assert!(should_return_404("/definitely-not-a-real-page"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexable_pages_emit_hreflang() {
|
||||
let page = seo_page_for_path("/terms").expect("terms page");
|
||||
let tags = route_seo_tags(&page, "/terms", "", "https://perfect-postcode.co.uk", "en");
|
||||
assert!(tags.contains(r#"hreflang="x-default""#));
|
||||
assert!(tags.contains(r#"hreflang="de""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noindex_pages_omit_hreflang() {
|
||||
let page = seo_page_for_path("/dashboard").expect("dashboard page");
|
||||
let tags = route_seo_tags(
|
||||
&page,
|
||||
"/dashboard",
|
||||
"",
|
||||
"https://perfect-postcode.co.uk",
|
||||
"en",
|
||||
);
|
||||
assert!(!tags.contains("hreflang"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue