feat(web): add Open Graph and Twitter Card meta tags

- Add og:title, og:description, og:image, og:type, og:site_name and
  twitter:card meta tags to base template with Askama block overrides
- Create static 1200x630 OG image served at /og-image.png
- Override OG blocks with entity-specific content on brew, cup, and
  stats detail pages
- Reuse BREWLOG_RP_ORIGIN as base URL for absolute og:image URLs via
  OnceLock initialized at server startup
This commit is contained in:
Jon Seager 2026-02-08 16:19:34 +00:00
parent f5e319ce03
commit 9e5dff7f5f
No known key found for this signature in database
15 changed files with 63 additions and 2 deletions

View file

@ -140,6 +140,19 @@ When adding new pragmas, add them after the existing ones in `Database::connect(
The middleware stack in `application/routes/mod.rs` applies layers in this order (outermost first): request tracing, cookie parsing, body size limit, security headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `CSP`, `HSTS`), and gzip compression. When adding new middleware, place it in the `ServiceBuilder` chain at the appropriate position.
### Open Graph Meta Tags
Social media preview cards are powered by Open Graph and Twitter Card meta tags in `templates/base.html`. The base template defines default `og:title`, `og:description`, `og:type`, `og:site_name`, and `twitter:card` tags using Askama blocks (`{% block og_title %}`, `{% block og_description %}`).
**Base URL** — `BREWLOG_RP_ORIGIN` is stored at startup via `set_base_url()` in `lib.rs` (a `OnceLock<String>`). Templates access it through the `base_url` field on their template struct, set with `crate::base_url()` in the handler.
**`og:image`** — a static 1200x630 PNG (`static/og-image.png`) served at `/og-image.png` via `include_bytes!()`. Only public shareable pages (home, brew detail, cup detail, stats) include it via `{% block head %}`, since those are the pages social crawlers can access.
**Adding OG tags to a new page:**
1. Add `pub base_url: &'static str` to the template struct
2. Set `base_url: crate::base_url()` in the handler
3. Override `{% block og_title %}`, `{% block og_description %}`, and add `<meta property="og:image" content="{{ base_url }}/og-image.png" />` in `{% block head %}`
### Repository Pattern
All data access goes through trait-based repositories defined in `domain/repositories.rs`. SQL implementations live in `infrastructure/repositories/`, each using a private `Record` struct with a `to_domain()` method to convert database rows to domain entities. Use typed ID wrappers from `domain/ids.rs` (e.g., `RoastId`, `BagId`) — never raw `i64`.

View file

@ -48,6 +48,7 @@ pub(crate) async fn brew_detail_page(
nav_active: "",
is_authenticated,
version_info: &crate::VERSION_INFO,
base_url: crate::base_url(),
brew: view,
};

View file

@ -53,6 +53,7 @@ pub(crate) async fn cup_detail_page(
nav_active: "",
is_authenticated,
version_info: &crate::VERSION_INFO,
base_url: crate::base_url(),
cup: view,
};

View file

@ -43,6 +43,7 @@ pub(crate) async fn home_page(
nav_active: "home",
is_authenticated,
version_info: &crate::VERSION_INFO,
base_url: crate::base_url(),
recent_brews: content.recent_brews,
open_bags: content.open_bags,
recent_events: content.recent_events,

View file

@ -43,6 +43,7 @@ pub(super) fn router() -> axum::Router<AppState> {
.route("/components/donut-chart.js", get(donut_chart_js))
.route("/favicon-light.svg", get(favicon_light))
.route("/favicon-dark.svg", get(favicon_dark))
.route("/og-image.png", get(og_image))
.route("/health", get(health))
}
@ -140,6 +141,16 @@ async fn favicon_dark() -> impl IntoResponse {
)
}
async fn og_image() -> impl IntoResponse {
(
[
("content-type", "image/png"),
("cache-control", "public, max-age=604800"),
],
include_bytes!("../../../../static/og-image.png").as_slice(),
)
}
async fn health() -> impl IntoResponse {
([("content-type", "application/json")], r#"{"status":"ok"}"#)
}

View file

@ -125,6 +125,7 @@ pub(crate) async fn stats_page(
nav_active: "stats",
is_authenticated,
version_info: &crate::VERSION_INFO,
base_url: crate::base_url(),
active_type: entity_type,
tabs,
tab_signal: "_active-tab",

View file

@ -1,3 +1,5 @@
use std::sync::OnceLock;
pub mod application;
pub mod domain;
pub mod infrastructure;
@ -12,3 +14,13 @@ pub const VERSION_INFO: VersionInfo = VersionInfo {
version: env!("CARGO_PKG_VERSION"),
commit: env!("GIT_HASH"),
};
static BASE_URL: OnceLock<String> = OnceLock::new();
pub fn set_base_url(url: String) {
let _ = BASE_URL.set(url);
}
pub fn base_url() -> &'static str {
BASE_URL.get().map_or("", std::string::String::as_str)
}

View file

@ -98,6 +98,8 @@ async fn run_server(command: ServeCommand) -> Result<()> {
)
})?;
brewlog::set_base_url(rp_origin.clone());
let config = ServerConfig {
bind_address: command.bind_address,
database_url: command.database_url,

View file

@ -105,6 +105,7 @@ pub struct HomeTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str,
pub recent_brews: Vec<BrewView>,
pub open_bags: Vec<BagView>,
@ -183,6 +184,7 @@ pub struct StatsPageTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str,
pub active_type: String,
pub tabs: Vec<Tab>,
pub tab_signal: &'static str,
@ -214,6 +216,7 @@ pub struct BrewDetailTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str,
pub brew: BrewDetailView,
}
@ -223,6 +226,7 @@ pub struct CupDetailTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str,
pub cup: CupDetailView,
}

BIN
static/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -7,6 +7,11 @@
name="description"
content="{% block description %}Self-hosted coffee logging — track roasters, roasts, bags, brews, and gear.{% endblock %}"
/>
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Brewlog" />
<meta property="og:title" content="{% block og_title %}Brewlog{% endblock %}" />
<meta property="og:description" content="{% block og_description %}Self-hosted coffee logging — track roasters, roasts, bags, brews, and gear.{% endblock %}" />
<meta name="twitter:card" content="summary_large_image" />
<title>{% block title %}Brewlog{% endblock %}</title>
<link rel="stylesheet" href="/styles.css" />
<link rel="icon" id="favicon" type="image/svg+xml" href="/favicon-light.svg" />

View file

@ -2,6 +2,9 @@
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
{% block description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
{% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %}
{% block og_description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/og-image.png" />{% endblock %}
{% block content %}
<header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">

View file

@ -2,6 +2,9 @@
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %}
{% block description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
{% block og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %}
{% block og_description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/og-image.png" />{% endblock %}
{% block content %}
<header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">

View file

@ -1,6 +1,7 @@
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import
"partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %} {% if
is_authenticated %}
"partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %}
<meta property="og:image" content="{{ base_url }}/og-image.png" />
{% if is_authenticated %}
<script>
const closeBag = async (bagId, cardEl) => {
if (!confirm("Close this bag? This will mark it as finished.")) return

View file

@ -1,4 +1,7 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/histogram.html" as histogram %} {% block title %}Brewlog · Stats{% endblock %}
{% block og_title %}Stats — Brewlog{% endblock %}
{% block og_description %}Aggregated coffee data across origins, consumption, and brewing.{% endblock %}
{% block head %}<meta property="og:image" content="{{ base_url }}/og-image.png" />{% endblock %}
{% block content %}
<header class="flex flex-col gap-2">
<div class="flex items-start justify-between gap-4">