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:
thalladaandClaude Fable 5.1 committed 2026-09-04 20:02:13 +00:00
commit 62803d73a6
6 files changed
+105 -9

No files matched your search

+58
View File
@@ -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&#39;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 &#34;No. 3&#34; &#38; \
&#60;b&#62;4&#60;/b&#62; for O&#39;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
View File
@@ -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
View File
@@ -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(),
}), }),
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-serif:"Newsreader", "Newsreader Fallback Georgia", "Newsreader Fallback Times", "Newsreader Fallback Noto Serif", "Newsreader Fallback DejaVu Serif", "Iowan Old Style", "Palatino Linotype", Georgia, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace;--color-paper:var(--paper);--color-paper-2:var(--paper-2);--color-ink:var(--ink)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-vaLine truncated @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-serif:"Newsreader", "Newsreader Fallback Georgia", "Newsreader Fallback Times", "Newsreader Fallback Noto Serif", "Newsreader Fallback DejaVu Serif", "Iowan Old Style", "Palatino Linotype", Georgia, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace;--color-paper:var(--paper);--color-paper-2:var(--paper-2);--color-ink:var(--ink)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-vaLine truncated
+2 -2
View File
@@ -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) {
+1
View File
@@ -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