max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
12 files changed,
+208 insertions,
-68 deletions
| @@ -10,13 +10,21 @@ | |||
| 10 | 10 | <meta name="description" content="{% block meta_description %}Sell your work directly. 0% platform fee, only Stripe's ~3% processing. Music, software, writing, and more.{% endblock %}"> | |
| 11 | 11 | ||
| 12 | 12 | {# Global OG / Twitter card metadata. Per-page templates emit their own | |
| 13 | - | og:title / og:description / og:image / og:url / twitter:* tags from | |
| 13 | + | og:title / og:description / og:url / og:image / twitter:* tags from | |
| 14 | 14 | within {% block head %}; only the page-invariant tags live here. | |
| 15 | 15 | Pages without overrides (cart, library, policy) get a generic but | |
| 16 | - | valid card from site_name + type + card. #} | |
| 16 | + | valid card from site_name + type + card. | |
| 17 | + | ||
| 18 | + | og:type and twitter:card are blocks rather than plain tags because a | |
| 19 | + | page needs to CONTRADICT them, not add to them: an item is a product, | |
| 20 | + | a post is an article, a page with no image wants a summary card. Emitted | |
| 21 | + | unconditionally they shipped twice on every page that had its own, and | |
| 22 | + | a consumer that takes the first occurrence read this generic value and | |
| 23 | + | never the page's. Override the block; do not emit the tag again inside | |
| 24 | + | {% block head %}. #} | |
| 17 | 25 | <meta property="og:site_name" content="Makenotwork"> | |
| 18 | - | <meta property="og:type" content="website"> | |
| 19 | - | <meta name="twitter:card" content="summary_large_image"> | |
| 26 | + | {% block og_type %}<meta property="og:type" content="website">{% endblock %} | |
| 27 | + | {% block social_card %}<meta name="twitter:card" content="summary_large_image">{% endblock %} | |
| 20 | 28 | ||
| 21 | 29 | {% block head %}{% endblock %} | |
| 22 | 30 | </head> |
| @@ -3,12 +3,8 @@ | |||
| 3 | 3 | ||
| 4 | 4 | {% block title %}{{ item.title }} - Makenotwork{% endblock %} | |
| 5 | 5 | ||
| 6 | - | {% block head %} | |
| 7 | - | <meta property="og:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 8 | - | <meta property="og:description" content="{{ item.description }}"> | |
| 9 | - | <meta property="og:type" content="music.song"> | |
| 10 | - | <meta property="og:url" content="{{ host_url }}/i/{{ item.id }}"> | |
| 11 | - | <meta property="og:site_name" content="Makenotwork"> | |
| 6 | + | {% block og_type %}<meta property="og:type" content="music.song">{% endblock %} | |
| 7 | + | {% block social_card %} | |
| 12 | 8 | {% match item.content %} | |
| 13 | 9 | {% when crate::types::ItemContent::Audio with { cover_url, .. } %} | |
| 14 | 10 | {% if let Some(img) = cover_url %} | |
| @@ -21,6 +17,12 @@ | |||
| 21 | 17 | {% when _ %} | |
| 22 | 18 | <meta name="twitter:card" content="summary"> | |
| 23 | 19 | {% endmatch %} | |
| 20 | + | {% endblock %} | |
| 21 | + | ||
| 22 | + | {% block head %} | |
| 23 | + | <meta property="og:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 24 | + | <meta property="og:description" content="{{ item.description }}"> | |
| 25 | + | <meta property="og:url" content="{{ host_url }}/i/{{ item.id }}"> | |
| 24 | 26 | <meta name="twitter:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 25 | 27 | <meta name="twitter:description" content="{{ item.description }}"> | |
| 26 | 28 | <script type="application/ld+json"> |
| @@ -3,15 +3,8 @@ | |||
| 3 | 3 | {% block title %}{{ title }} - {{ project_title }}{% endblock %} | |
| 4 | 4 | {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Reading) }}"{% endblock %} | |
| 5 | 5 | ||
| 6 | - | {% block head %} | |
| 7 | - | <meta property="og:title" content="{{ title }} - {{ project_title }}"> | |
| 8 | - | <meta property="og:description" content="{{ creator_display_name.as_deref().unwrap_or(&creator_username) }} on Makenotwork"> | |
| 9 | - | <meta property="og:type" content="article"> | |
| 10 | - | <meta property="og:url" content="{{ host_url }}/p/{{ project_slug }}/blog/{{ post_slug }}"> | |
| 11 | - | <link rel="canonical" href="{{ host_url }}/p/{{ project_slug }}/blog/{{ post_slug }}"> | |
| 12 | - | <meta property="og:site_name" content="Makenotwork"> | |
| 13 | - | <meta name="twitter:title" content="{{ title }} - {{ project_title }}"> | |
| 14 | - | <meta name="twitter:description" content="{{ creator_display_name.as_deref().unwrap_or(&creator_username) }} on Makenotwork"> | |
| 6 | + | {% block og_type %}<meta property="og:type" content="article">{% endblock %} | |
| 7 | + | {% block social_card %} | |
| 15 | 8 | {% if let Some(img) = project_cover_image_url %} | |
| 16 | 9 | <meta name="twitter:card" content="summary_large_image"> | |
| 17 | 10 | <meta property="og:image" content="{{ img }}"> | |
| @@ -21,6 +14,15 @@ | |||
| 21 | 14 | <meta property="og:image" content="{{ host_url }}/static/images/og-card.png"> | |
| 22 | 15 | <meta name="twitter:image" content="{{ host_url }}/static/images/og-card.png"> | |
| 23 | 16 | {% endif %} | |
| 17 | + | {% endblock %} | |
| 18 | + | ||
| 19 | + | {% block head %} | |
| 20 | + | <meta property="og:title" content="{{ title }} - {{ project_title }}"> | |
| 21 | + | <meta property="og:description" content="{{ creator_display_name.as_deref().unwrap_or(&creator_username) }} on Makenotwork"> | |
| 22 | + | <meta property="og:url" content="{{ host_url }}/p/{{ project_slug }}/blog/{{ post_slug }}"> | |
| 23 | + | <link rel="canonical" href="{{ host_url }}/p/{{ project_slug }}/blog/{{ post_slug }}"> | |
| 24 | + | <meta name="twitter:title" content="{{ title }} - {{ project_title }}"> | |
| 25 | + | <meta name="twitter:description" content="{{ creator_display_name.as_deref().unwrap_or(&creator_username) }} on Makenotwork"> | |
| 24 | 26 | <script type="application/ld+json"> | |
| 25 | 27 | { | |
| 26 | 28 | "@context": "https://schema.org", |
| @@ -8,10 +8,8 @@ | |||
| 8 | 8 | {% block head %} | |
| 9 | 9 | <meta property="og:title" content="Makenotwork: Sell your work directly. Keep what you earn."> | |
| 10 | 10 | <meta property="og:description" content="A platform for selling music, software, writing, and more. 0% platform fee, only Stripe's ~3% processing."> | |
| 11 | - | <meta property="og:type" content="website"> | |
| 12 | 11 | <meta property="og:url" content="{{ host_url }}/"> | |
| 13 | 12 | <link rel="canonical" href="{{ host_url }}/"> | |
| 14 | - | <meta name="twitter:card" content="summary_large_image"> | |
| 15 | 13 | <meta property="og:image" content="{{ host_url }}/static/images/og-card.png"> | |
| 16 | 14 | <meta name="twitter:image" content="{{ host_url }}/static/images/og-card.png"> | |
| 17 | 15 | {% endblock %} |
| @@ -4,17 +4,17 @@ | |||
| 4 | 4 | {% block title %}{{ item.title }} - Makenotwork{% endblock %} | |
| 5 | 5 | {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }} item-page"{% endblock %} | |
| 6 | 6 | ||
| 7 | + | {% block og_type %}<meta property="og:type" content="product">{% endblock %} | |
| 8 | + | {% block social_card %}{% if item.cover_image_url.is_some() || project_cover_image_url.is_some() %}<meta name="twitter:card" content="summary_large_image">{% else %}<meta name="twitter:card" content="summary">{% endif %}{% endblock %} | |
| 9 | + | ||
| 7 | 10 | {% block head %} | |
| 8 | 11 | {# Tier 0 creator theme (inherited from parent project): primitive-layer override. #} | |
| 9 | 12 | <style id="creator-theme">{{ theme_css|safe }}</style> | |
| 10 | 13 | <meta property="og:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 11 | 14 | <meta property="og:description" content="{{ item.description }}"> | |
| 12 | - | <meta property="og:type" content="product"> | |
| 13 | 15 | <meta property="og:url" content="{{ host_url }}/i/{{ item.id }}"> | |
| 14 | 16 | <link rel="canonical" href="{{ host_url }}/i/{{ item.id }}"> | |
| 15 | - | <meta property="og:site_name" content="Makenotwork"> | |
| 16 | 17 | <meta property="product:price:amount" content="{{ item.price }}"> | |
| 17 | - | {% if item.cover_image_url.is_some() || project_cover_image_url.is_some() %}<meta name="twitter:card" content="summary_large_image">{% else %}<meta name="twitter:card" content="summary">{% endif %} | |
| 18 | 18 | <meta name="twitter:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 19 | 19 | <meta name="twitter:description" content="{{ item.description }}"> | |
| 20 | 20 | {% if let Some(img) = item.cover_image_url %} |
| @@ -4,16 +4,15 @@ | |||
| 4 | 4 | {% block title %}{{ project.title }} - {{ creator_username }}{% endblock %} | |
| 5 | 5 | {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }} project-page"{% endblock %} | |
| 6 | 6 | ||
| 7 | + | {% block social_card %}{% if project.cover_image_url.is_some() %}<meta name="twitter:card" content="summary_large_image">{% else %}<meta name="twitter:card" content="summary">{% endif %}{% endblock %} | |
| 8 | + | ||
| 7 | 9 | {% block head %} | |
| 8 | 10 | {# Tier 0 creator theme: primitive-layer override; semantic layer derives from it. #} | |
| 9 | 11 | <style id="creator-theme">{{ theme_css|safe }}</style> | |
| 10 | 12 | <meta property="og:title" content="{{ project.title }} by {{ creator_username }}"> | |
| 11 | 13 | <meta property="og:description" content="{{ project.description }}"> | |
| 12 | - | <meta property="og:type" content="website"> | |
| 13 | 14 | <meta property="og:url" content="{{ host_url }}/p/{{ project.slug }}"> | |
| 14 | 15 | <link rel="canonical" href="{{ host_url }}/p/{{ project.slug }}"> | |
| 15 | - | <meta property="og:site_name" content="Makenotwork"> | |
| 16 | - | {% if project.cover_image_url.is_some() %}<meta name="twitter:card" content="summary_large_image">{% else %}<meta name="twitter:card" content="summary">{% endif %} | |
| 17 | 16 | <meta name="twitter:title" content="{{ project.title }} by {{ creator_username }}"> | |
| 18 | 17 | <meta name="twitter:description" content="{{ project.description }}"> | |
| 19 | 18 | {% if let Some(img) = project.cover_image_url %} |
| @@ -3,12 +3,7 @@ | |||
| 3 | 3 | {% block title %}{{ project.title }} - {{ creator_username }}{% endblock %} | |
| 4 | 4 | {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }} project-paywall-page"{% endblock %} | |
| 5 | 5 | ||
| 6 | - | {% block head %} | |
| 7 | - | <meta property="og:title" content="{{ project.title }} by {{ creator_username }}"> | |
| 8 | - | <meta property="og:description" content="{{ project.description }}"> | |
| 9 | - | <meta property="og:type" content="website"> | |
| 10 | - | <meta property="og:url" content="{{ host_url }}/p/{{ project.slug }}"> | |
| 11 | - | <link rel="canonical" href="{{ host_url }}/p/{{ project.slug }}"> | |
| 6 | + | {% block social_card %} | |
| 12 | 7 | {% if let Some(img) = project.cover_image_url %} | |
| 13 | 8 | <meta property="og:image" content="{{ img }}"> | |
| 14 | 9 | <meta name="twitter:card" content="summary_large_image"> | |
| @@ -18,6 +13,13 @@ | |||
| 18 | 13 | {% endif %} | |
| 19 | 14 | {% endblock %} | |
| 20 | 15 | ||
| 16 | + | {% block head %} | |
| 17 | + | <meta property="og:title" content="{{ project.title }} by {{ creator_username }}"> | |
| 18 | + | <meta property="og:description" content="{{ project.description }}"> | |
| 19 | + | <meta property="og:url" content="{{ host_url }}/p/{{ project.slug }}"> | |
| 20 | + | <link rel="canonical" href="{{ host_url }}/p/{{ project.slug }}"> | |
| 21 | + | {% endblock %} | |
| 22 | + | ||
| 21 | 23 | {% block content %} | |
| 22 | 24 | {% include "partials/site_header.html" %} | |
| 23 | 25 |
| @@ -3,12 +3,8 @@ | |||
| 3 | 3 | {% block title %}{{ item.title }} - Makenotwork{% endblock %} | |
| 4 | 4 | {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Reading) }}"{% endblock %} | |
| 5 | 5 | ||
| 6 | - | {% block head %} | |
| 7 | - | <meta property="og:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 8 | - | <meta property="og:description" content="{{ item.description }}"> | |
| 9 | - | <meta property="og:type" content="article"> | |
| 10 | - | <meta property="og:url" content="{{ host_url }}/i/{{ item.id }}"> | |
| 11 | - | <meta property="og:site_name" content="Makenotwork"> | |
| 6 | + | {% block og_type %}<meta property="og:type" content="article">{% endblock %} | |
| 7 | + | {% block social_card %} | |
| 12 | 8 | {% if let Some(img) = item.cover_image_url %} | |
| 13 | 9 | <meta property="og:image" content="{{ img }}"> | |
| 14 | 10 | <meta name="twitter:card" content="summary_large_image"> | |
| @@ -16,6 +12,12 @@ | |||
| 16 | 12 | {% else %} | |
| 17 | 13 | <meta name="twitter:card" content="summary"> | |
| 18 | 14 | {% endif %} | |
| 15 | + | {% endblock %} | |
| 16 | + | ||
| 17 | + | {% block head %} | |
| 18 | + | <meta property="og:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 19 | + | <meta property="og:description" content="{{ item.description }}"> | |
| 20 | + | <meta property="og:url" content="{{ host_url }}/i/{{ item.id }}"> | |
| 19 | 21 | <meta name="twitter:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 20 | 22 | <meta name="twitter:description" content="{{ item.description }}"> | |
| 21 | 23 | <script type="application/ld+json"> |
| @@ -3,16 +3,16 @@ | |||
| 3 | 3 | {% block title %}{{ user.display_name_or_username() }} - Makenotwork{% endblock %} | |
| 4 | 4 | {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }} user-page"{% endblock %} | |
| 5 | 5 | ||
| 6 | + | {% block og_type %}<meta property="og:type" content="profile">{% endblock %} | |
| 7 | + | {% block social_card %}{% if user.avatar_url.is_some() %}<meta name="twitter:card" content="summary_large_image">{% else %}<meta name="twitter:card" content="summary">{% endif %}{% endblock %} | |
| 8 | + | ||
| 6 | 9 | {% block head %} | |
| 7 | 10 | {# Tier 0 creator theme: primitive-layer override; semantic layer derives from it. #} | |
| 8 | 11 | <style id="creator-theme">{{ theme_css|safe }}</style> | |
| 9 | 12 | <meta property="og:title" content="{{ user.display_name_or_username() }} - Makenotwork"> | |
| 10 | 13 | <meta property="og:description" content="{% if let Some(bio) = user.bio %}{{ bio }}{% else %}Creator on Makenotwork{% endif %}"> | |
| 11 | - | <meta property="og:type" content="profile"> | |
| 12 | 14 | <meta property="og:url" content="{{ host_url }}/u/{{ user.username }}"> | |
| 13 | 15 | <link rel="canonical" href="{{ host_url }}/u/{{ user.username }}"> | |
| 14 | - | <meta property="og:site_name" content="Makenotwork"> | |
| 15 | - | {% if user.avatar_url.is_some() %}<meta name="twitter:card" content="summary_large_image">{% else %}<meta name="twitter:card" content="summary">{% endif %} | |
| 16 | 16 | <meta name="twitter:title" content="{{ user.display_name_or_username() }} - Makenotwork"> | |
| 17 | 17 | <meta name="twitter:description" content="{% if let Some(bio) = user.bio %}{{ bio }}{% else %}Creator on Makenotwork{% endif %}"> | |
| 18 | 18 | {% if let Some(img) = user.avatar_url %} |
| @@ -3,12 +3,8 @@ | |||
| 3 | 3 | ||
| 4 | 4 | {% block title %}{{ item.title }} - Makenotwork{% endblock %} | |
| 5 | 5 | ||
| 6 | - | {% block head %} | |
| 7 | - | <meta property="og:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 8 | - | <meta property="og:description" content="{{ item.description }}"> | |
| 9 | - | <meta property="og:type" content="video.other"> | |
| 10 | - | <meta property="og:url" content="{{ host_url }}/i/{{ item.id }}"> | |
| 11 | - | <meta property="og:site_name" content="Makenotwork"> | |
| 6 | + | {% block og_type %}<meta property="og:type" content="video.other">{% endblock %} | |
| 7 | + | {% block social_card %} | |
| 12 | 8 | {% match item.content %} | |
| 13 | 9 | {% when crate::types::ItemContent::Video with { cover_url, .. } %} | |
| 14 | 10 | {% if let Some(img) = cover_url %} | |
| @@ -21,6 +17,12 @@ | |||
| 21 | 17 | {% when _ %} | |
| 22 | 18 | <meta name="twitter:card" content="summary"> | |
| 23 | 19 | {% endmatch %} | |
| 20 | + | {% endblock %} | |
| 21 | + | ||
| 22 | + | {% block head %} | |
| 23 | + | <meta property="og:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 24 | + | <meta property="og:description" content="{{ item.description }}"> | |
| 25 | + | <meta property="og:url" content="{{ host_url }}/i/{{ item.id }}"> | |
| 24 | 26 | <meta name="twitter:title" content="{{ item.title }} - {{ creator_username }}"> | |
| 25 | 27 | <meta name="twitter:description" content="{{ item.description }}"> | |
| 26 | 28 | <script type="application/ld+json"> |
| @@ -128,26 +128,6 @@ | |||
| 128 | 128 | Ok(()) | |
| 129 | 129 | } | |
| 130 | 130 | ||
| 131 | - | /// Update the cover image URL for an item (defense-in-depth: verifies ownership). | |
| 132 | - | #[tracing::instrument(skip_all)] | |
| 133 | - | pub async fn update_item_cover_image_url( | |
| 134 | - | pool: &PgPool, | |
| 135 | - | item_id: ItemId, | |
| 136 | - | user_id: UserId, | |
| 137 | - | url: &str, | |
| 138 | - | ) -> Result<()> { | |
| 139 | - | sqlx::query( | |
| 140 | - | "UPDATE items SET cover_image_url = $2, updated_at = NOW() WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)", | |
| 141 | - | ) | |
| 142 | - | .bind(item_id) | |
| 143 | - | .bind(url) | |
| 144 | - | .bind(user_id) | |
| 145 | - | .execute(pool) | |
| 146 | - | .await?; | |
| 147 | - | ||
| 148 | - | Ok(()) | |
| 149 | - | } | |
| 150 | - | ||
| 151 | 131 | /// Atomically update cover image URL, S3 key, and file size in a single UPDATE | |
| 152 | 132 | /// (defense-in-depth: verifies ownership), guarded by a compare-and-swap on the | |
| 153 | 133 | /// existing `cover_s3_key`. |
| @@ -1,0 +1,145 @@ | |||
| 1 | + | //! Seal: no template emits a `<head>` tag that `base.html` already owns. | |
| 2 | + | //! | |
| 3 | + | //! `base.html` emits three page-invariant social tags before `{% block head %}`: | |
| 4 | + | //! `og:site_name` as a plain tag, and `og:type` / `twitter:card` as blocks with | |
| 5 | + | //! generic defaults. A page that needs a different type or card overrides the | |
| 6 | + | //! block. A page that emits the tag again inside `{% block head %}` instead | |
| 7 | + | //! ships it twice, and the two disagree. | |
| 8 | + | //! | |
| 9 | + | //! That is not hypothetical. Until 2026-08-15 base emitted all three | |
| 10 | + | //! unconditionally and nine page templates emitted their own on top, so every | |
| 11 | + | //! item page carried `og:type` as both `website` and `product`, every post as | |
| 12 | + | //! both `website` and `article`, and the project pages carried `twitter:card` | |
| 13 | + | //! as both `summary_large_image` and `summary`. Base's copy came first in the | |
| 14 | + | //! document, so a consumer taking the first occurrence read the generic value | |
| 15 | + | //! and the page's real one never won. Nothing caught it because nothing looked: | |
| 16 | + | //! there was no test anywhere in the tree mentioning these tags. | |
| 17 | + | //! | |
| 18 | + | //! Rendering every page to check this would need a database and a fixture per | |
| 19 | + | //! template. Reading the templates needs neither, and the defect is a property | |
| 20 | + | //! of the source rather than of any particular row, so this is a source seal in | |
| 21 | + | //! the same shape as the `frontend_globals` ratchet. | |
| 22 | + | ||
| 23 | + | use std::fs; | |
| 24 | + | use std::path::{Path, PathBuf}; | |
| 25 | + | ||
| 26 | + | /// Tags `base.html` owns, with the block a page must override to change one. | |
| 27 | + | /// `None` means the tag is invariant and a page may not restate it at all. | |
| 28 | + | const OWNED: &[(&str, Option<&str>)] = &[ | |
| 29 | + | ("og:site_name", None), | |
| 30 | + | ("og:type", Some("og_type")), | |
| 31 | + | ("twitter:card", Some("social_card")), | |
| 32 | + | ]; | |
| 33 | + | ||
| 34 | + | fn templates_dir() -> PathBuf { | |
| 35 | + | Path::new(env!("CARGO_MANIFEST_DIR")).join("templates") | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// Every `.html` under `templates/`, walked rather than listed so a new page | |
| 39 | + | /// is covered the day it lands. | |
| 40 | + | fn template_files(dir: &Path, out: &mut Vec<PathBuf>) { | |
| 41 | + | for entry in fs::read_dir(dir).expect("templates/ is readable") { | |
| 42 | + | let path = entry.expect("readable dir entry").path(); | |
| 43 | + | if path.is_dir() { | |
| 44 | + | template_files(&path, out); | |
| 45 | + | } else if path.extension().is_some_and(|e| e == "html") { | |
| 46 | + | out.push(path); | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Remove `{% block <name> %}...{% endblock %}` from `src`. | |
| 52 | + | /// | |
| 53 | + | /// Askama's only nestable construct that closes with `{% endblock %}` is a | |
| 54 | + | /// block, and no sanctioned override nests one, so pairing each opener with the | |
| 55 | + | /// next `{% endblock %}` is exact here. A nested block inside an override would | |
| 56 | + | /// under-strip and fail the seal, which is the safe direction. | |
| 57 | + | fn strip_block(src: &str, name: &str) -> String { | |
| 58 | + | let opener = format!("{{% block {name} %}}"); | |
| 59 | + | let mut out = String::with_capacity(src.len()); | |
| 60 | + | let mut rest = src; | |
| 61 | + | while let Some(start) = rest.find(&opener) { | |
| 62 | + | out.push_str(&rest[..start]); | |
| 63 | + | let after = &rest[start + opener.len()..]; | |
| 64 | + | match after.find("{% endblock %}") { | |
| 65 | + | Some(end) => rest = &after[end + "{% endblock %}".len()..], | |
| 66 | + | // Unterminated: leave the remainder in place so the assertion sees | |
| 67 | + | // it rather than silently swallowing the rest of the file. | |
| 68 | + | None => { | |
| 69 | + | rest = after; | |
| 70 | + | break; | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | out.push_str(rest); | |
| 75 | + | out | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[test] | |
| 79 | + | fn no_template_restates_a_tag_base_html_owns() { | |
| 80 | + | let dir = templates_dir(); | |
| 81 | + | let base = dir.join("base.html"); | |
| 82 | + | let base_src = fs::read_to_string(&base).expect("base.html is readable"); | |
| 83 | + | ||
| 84 | + | // The seal is only meaningful if base still emits what it claims to own. | |
| 85 | + | for (tag, block) in OWNED { | |
| 86 | + | assert!( | |
| 87 | + | base_src.contains(tag), | |
| 88 | + | "base.html no longer emits {tag}, but this seal still forbids pages \ | |
| 89 | + | from emitting it. Update OWNED or restore the tag." | |
| 90 | + | ); | |
| 91 | + | if let Some(block) = block { | |
| 92 | + | assert!( | |
| 93 | + | base_src.contains(&format!("{{% block {block} %}}")), | |
| 94 | + | "base.html emits {tag} outside a `{block}` block, so a page has \ | |
| 95 | + | no sanctioned way to override it. Wrap it in the block." | |
| 96 | + | ); | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | let mut files = Vec::new(); | |
| 101 | + | template_files(&dir, &mut files); | |
| 102 | + | files.sort(); | |
| 103 | + | ||
| 104 | + | let mut offenders = Vec::new(); | |
| 105 | + | for path in files { | |
| 106 | + | if path == base { | |
| 107 | + | continue; | |
| 108 | + | } | |
| 109 | + | let src = fs::read_to_string(&path).expect("template is readable"); | |
| 110 | + | // Only children of base.html inherit its head; standalone templates | |
| 111 | + | // (partials, embeds) own their own markup. | |
| 112 | + | if !src.contains(r#"{% extends "base.html" %}"#) { | |
| 113 | + | continue; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | let rel = path | |
| 117 | + | .strip_prefix(&dir) | |
| 118 | + | .unwrap_or(&path) | |
| 119 | + | .display() | |
| 120 | + | .to_string(); | |
| 121 | + | for (tag, block) in OWNED { | |
| 122 | + | let searchable = match block { | |
| 123 | + | Some(block) => strip_block(&src, block), | |
| 124 | + | None => src.clone(), | |
| 125 | + | }; | |
| 126 | + | if searchable.contains(tag) { | |
| 127 | + | offenders.push(match block { | |
| 128 | + | Some(block) => format!( | |
| 129 | + | "{rel} emits {tag} outside `{{% block {block} %}}`; base.html \ | |
| 130 | + | already emits it, so the page ships two and the first wins" | |
| 131 | + | ), | |
| 132 | + | None => { | |
| 133 | + | format!("{rel} emits {tag}, which base.html already emits for every page") | |
| 134 | + | } | |
| 135 | + | }); | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | assert!( | |
| 141 | + | offenders.is_empty(), | |
| 142 | + | "templates restate head tags base.html owns:\n {}", | |
| 143 | + | offenders.join("\n ") | |
| 144 | + | ); | |
| 145 | + | } |