Merge branches psi-fonts and psi-a11y-seo: Lighthouse fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
This commit is contained in:
@@ -197,9 +197,19 @@ pub struct Flash {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The site-wide `<meta name="description">`, used by every page that does not
|
||||||
|
/// set one of its own. Search engines truncate around 160 characters.
|
||||||
|
pub const DEFAULT_DESCRIPTION: &str = concat!(
|
||||||
|
"A daily newspaper of the web: articles hand-picked from one reader's feeds, ",
|
||||||
|
"published every morning as an EPUB and readable here."
|
||||||
|
);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Page {
|
pub struct Page {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
/// The `<meta name="description">` for this page; `DEFAULT_DESCRIPTION`
|
||||||
|
/// unless a handler overrides it with [`Page::with_description`].
|
||||||
|
pub description: String,
|
||||||
pub viewer: Option<Viewer>,
|
pub viewer: Option<Viewer>,
|
||||||
pub flash: Option<Flash>,
|
pub flash: Option<Flash>,
|
||||||
pub active_nav: String,
|
pub active_nav: String,
|
||||||
@@ -257,6 +267,7 @@ impl Page {
|
|||||||
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
|
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
title: title.into(),
|
title: title.into(),
|
||||||
|
description: DEFAULT_DESCRIPTION.to_string(),
|
||||||
viewer,
|
viewer,
|
||||||
flash: None,
|
flash: None,
|
||||||
active_nav: active_nav.to_string(),
|
active_nav: active_nav.to_string(),
|
||||||
@@ -265,6 +276,13 @@ impl Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the site-wide description with one written for this page.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_description(mut self, text: impl Into<String>) -> Self {
|
||||||
|
self.description = text.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_admin(&self) -> bool {
|
pub fn is_admin(&self) -> bool {
|
||||||
self.viewer
|
self.viewer
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1115,6 +1133,46 @@ mod tests {
|
|||||||
assert!(!html.contains("Newsreader-italic.woff2"), "{html}");
|
assert!(!html.contains("Newsreader-italic.woff2"), "{html}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pages_render_a_meta_description_and_escape_it() {
|
||||||
|
let render = |page: Page| {
|
||||||
|
ErrorTemplate {
|
||||||
|
page,
|
||||||
|
heading: "h".into(),
|
||||||
|
message: "m".into(),
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every page carries a description; the default one when none is set.
|
||||||
|
assert!(DEFAULT_DESCRIPTION.len() <= 160, "{DEFAULT_DESCRIPTION}");
|
||||||
|
let html = render(Page::new("t", None, "latest"));
|
||||||
|
assert!(
|
||||||
|
html.contains(
|
||||||
|
"<meta name=\"description\" content=\"A daily newspaper of the web: articles \
|
||||||
|
hand-picked from one reader's feeds, published every morning as an EPUB and \
|
||||||
|
readable here.\">"
|
||||||
|
),
|
||||||
|
"{html}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A page-specific one replaces it, HTML-escaped into the attribute.
|
||||||
|
let page = Page::new("t", None, "latest")
|
||||||
|
.with_description("Issue \"No. 3\" & <b>4</b> for O'Donnell");
|
||||||
|
assert_eq!(page.description, "Issue \"No. 3\" & <b>4</b> for O'Donnell");
|
||||||
|
let html = render(page);
|
||||||
|
assert!(
|
||||||
|
html.contains(
|
||||||
|
"<meta name=\"description\" content=\"Issue "No. 3" & \
|
||||||
|
<b>4</b> for O'Donnell\">"
|
||||||
|
),
|
||||||
|
"{html}"
|
||||||
|
);
|
||||||
|
assert!(!html.contains("<b>4</b>"), "{html}");
|
||||||
|
assert!(!html.contains(DEFAULT_DESCRIPTION), "{html}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stylesheet_points_both_newsreader_faces_at_versioned_urls() {
|
fn stylesheet_points_both_newsreader_faces_at_versioned_urls() {
|
||||||
let css = APP_CSS.as_str();
|
let css = APP_CSS.as_str();
|
||||||
|
|||||||
+35
-4
@@ -11,6 +11,19 @@ use crate::web::issue::{self, Download};
|
|||||||
use crate::web::session::{AuthSession, Viewer};
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
use crate::web::{Html, Page, WebError};
|
use crate::web::{Html, Page, WebError};
|
||||||
|
|
||||||
|
/// The `<meta name="description">` for the landing page before the first issue
|
||||||
|
/// of the day exists.
|
||||||
|
const NO_ISSUE_DESCRIPTION: &str = concat!(
|
||||||
|
"The latest issue of The Daily EPUB is not out yet; ",
|
||||||
|
"the next one lands tomorrow morning."
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The `<meta name="description">` for the archive index.
|
||||||
|
const ARCHIVE_DESCRIPTION: &str = concat!(
|
||||||
|
"Every issue of The Daily EPUB, newest first: browse the archive by month ",
|
||||||
|
"and read or download any past morning's paper."
|
||||||
|
);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PublicIssue {
|
pub struct PublicIssue {
|
||||||
pub date: Date,
|
pub date: Date,
|
||||||
@@ -140,6 +153,20 @@ impl From<&Issue> for PublicIssue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PublicIssue {
|
||||||
|
/// The `<meta name="description">` for an issue page: the same counts the
|
||||||
|
/// masthead prints, in a sentence a search result can show.
|
||||||
|
fn description(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"Issue No. {} for {}: {} articles across {} sections.",
|
||||||
|
self.issue_number,
|
||||||
|
self.display_date,
|
||||||
|
self.article_count,
|
||||||
|
self.sections.len(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn domain(raw: &str) -> String {
|
fn domain(raw: &str) -> String {
|
||||||
url::Url::parse(raw)
|
url::Url::parse(raw)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -193,7 +220,8 @@ pub async fn latest(
|
|||||||
let Some(date) = state.db.latest_issue_date().await? else {
|
let Some(date) = state.db.latest_issue_date().await? else {
|
||||||
let viewer = auth.user().await.map(Viewer::from);
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
let response = Html(IssuePublicTemplate {
|
let response = Html(IssuePublicTemplate {
|
||||||
page: Page::new("Latest issue", viewer, "latest"),
|
page: Page::new("Latest issue", viewer, "latest")
|
||||||
|
.with_description(NO_ISSUE_DESCRIPTION),
|
||||||
issue: empty_issue(),
|
issue: empty_issue(),
|
||||||
downloads: Vec::new(),
|
downloads: Vec::new(),
|
||||||
empty: true,
|
empty: true,
|
||||||
@@ -220,9 +248,11 @@ pub async fn show_issue(
|
|||||||
return Ok(public_cache(response, &headers));
|
return Ok(public_cache(response, &headers));
|
||||||
}
|
}
|
||||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||||
|
let issue = PublicIssue::from(&view.issue);
|
||||||
let response = Html(IssuePublicTemplate {
|
let response = Html(IssuePublicTemplate {
|
||||||
page: Page::new(format!("Issue {date}"), None, active_nav),
|
page: Page::new(format!("Issue {date}"), None, active_nav)
|
||||||
issue: PublicIssue::from(&view.issue),
|
.with_description(issue.description()),
|
||||||
|
issue,
|
||||||
downloads: Vec::new(),
|
downloads: Vec::new(),
|
||||||
empty: false,
|
empty: false,
|
||||||
})
|
})
|
||||||
@@ -259,7 +289,8 @@ pub async fn archive(
|
|||||||
"Issue archive",
|
"Issue archive",
|
||||||
auth.user().await.map(Viewer::from),
|
auth.user().await.map(Viewer::from),
|
||||||
"archive",
|
"archive",
|
||||||
),
|
)
|
||||||
|
.with_description(ARCHIVE_DESCRIPTION),
|
||||||
months,
|
months,
|
||||||
})
|
})
|
||||||
.into_response();
|
.into_response();
|
||||||
|
|||||||
+8
-2
@@ -280,10 +280,16 @@ struct AccountTemplate {
|
|||||||
error: String,
|
error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `<meta name="description">` for both renders of the sign-in page.
|
||||||
|
const LOGIN_DESCRIPTION: &str = concat!(
|
||||||
|
"Sign in to The Daily EPUB to read the full issue, ",
|
||||||
|
"rate what you read, and download the morning's editions."
|
||||||
|
);
|
||||||
|
|
||||||
pub async fn login_page(auth: AuthSession, Query(query): Query<LoginQuery>) -> Response {
|
pub async fn login_page(auth: AuthSession, Query(query): Query<LoginQuery>) -> Response {
|
||||||
let viewer = auth.user().await.map(Viewer::from);
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
Html(LoginTemplate {
|
Html(LoginTemplate {
|
||||||
page: Page::new("Sign in", viewer, "login"),
|
page: Page::new("Sign in", viewer, "login").with_description(LOGIN_DESCRIPTION),
|
||||||
next: valid_next(query.next.as_deref()).to_string(),
|
next: valid_next(query.next.as_deref()).to_string(),
|
||||||
error: String::new(),
|
error: String::new(),
|
||||||
})
|
})
|
||||||
@@ -316,7 +322,7 @@ pub async fn login(
|
|||||||
None => Ok((
|
None => Ok((
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
Html(LoginTemplate {
|
Html(LoginTemplate {
|
||||||
page: Page::new("Sign in", None, "login"),
|
page: Page::new("Sign in", None, "login").with_description(LOGIN_DESCRIPTION),
|
||||||
next: destination,
|
next: destination,
|
||||||
error: "invalid username or password".into(),
|
error: "invalid username or password".into(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -80,9 +80,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#7a7568;
|
--paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#6b665a;
|
||||||
--rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
|
--rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
|
||||||
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#8a6d1f; color-scheme:light;
|
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#7d631c; color-scheme:light;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="{{ page.description }}">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>{{ page.title }} · The Daily EPUB</title>
|
<title>{{ page.title }} · The Daily EPUB</title>
|
||||||
{# Start the regular face downloading with the stylesheet rather than after
|
{# Start the regular face downloading with the stylesheet rather than after
|
||||||
|
|||||||
Reference in New Issue
Block a user