feat(cms): add language field + seo_head to manual article creation

- Add language selector (en/de) to article create/edit form
- Store language and generated seo_head in articles table on CREATE and UPDATE
- Write HTML build to BUILD_DIR/{lang}/{slug}.html (consistent with pSEO)
- article_detail.html: render article.seo_head when present (canonical,
  hreflang, OG, JSON-LD Article) — falls back to inline for legacy articles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-25 13:25:17 +01:00
parent a35036807e
commit 6cb0fb32ec
3 changed files with 69 additions and 22 deletions

View File

@@ -31,6 +31,42 @@ from ..core import (
slugify,
)
def _build_article_seo_head(
url_path: str,
title: str,
meta_desc: str,
language: str,
published_at: str,
*,
base_url: str = "https://padelnomics.io",
) -> str:
"""Build SEO head block (canonical, OG, JSON-LD) for a manually created article."""
def _esc(text: str) -> str:
return text.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;")
full_url = f"{base_url}/{language}{url_path}"
jsonld = json.dumps({
"@context": "https://schema.org",
"@type": "Article",
"headline": title[:110],
"description": meta_desc[:200],
"url": full_url,
"inLanguage": language,
"datePublished": published_at,
"dateModified": published_at,
"author": {"@type": "Organization", "name": "Padelnomics", "url": "https://padelnomics.io"},
"publisher": {"@type": "Organization", "name": "Padelnomics", "url": "https://padelnomics.io"},
}, ensure_ascii=False)
return "\n".join([
f'<link rel="canonical" href="{full_url}" />',
f'<meta property="og:title" content="{_esc(title)}" />',
f'<meta property="og:description" content="{_esc(meta_desc)}" />',
f'<meta property="og:url" content="{full_url}" />',
'<meta property="og:type" content="article" />',
f'<script type="application/ld+json">{jsonld}</script>',
])
# Blueprint with its own template folder
bp = Blueprint(
"admin",
@@ -1716,6 +1752,7 @@ async def article_new():
country = form.get("country", "").strip()
region = form.get("region", "").strip()
body = form.get("body", "").strip()
language = form.get("language", "en").strip() or "en"
status = form.get("status", "draft")
published_at = form.get("published_at", "").strip()
@@ -1731,9 +1768,9 @@ async def article_new():
body_html = mistune.html(body)
body_html = await bake_scenario_cards(body_html)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
build_path = BUILD_DIR / f"{article_slug}.html"
build_path.write_text(body_html)
build_dir = BUILD_DIR / language
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / f"{article_slug}.html").write_text(body_html)
# Save markdown source
md_dir = Path("data/content/articles")
@@ -1741,14 +1778,15 @@ async def article_new():
(md_dir / f"{article_slug}.md").write_text(body)
pub_dt = published_at or datetime.utcnow().isoformat()
seo_head = _build_article_seo_head(url_path, title, meta_description, language, pub_dt)
await execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, og_image_url,
country, region, status, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
country, region, language, status, published_at, seo_head)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(url_path, article_slug, title, meta_description, og_image_url,
country, region, status, pub_dt),
country, region, language, status, pub_dt, seo_head),
)
from ..sitemap import invalidate_sitemap_cache
invalidate_sitemap_cache()
@@ -1780,6 +1818,7 @@ async def article_edit(article_id: int):
country = form.get("country", "").strip()
region = form.get("region", "").strip()
body = form.get("body", "").strip()
language = form.get("language", article.get("language", "en")).strip() or "en"
status = form.get("status", article["status"])
published_at = form.get("published_at", "").strip()
@@ -1793,8 +1832,9 @@ async def article_edit(article_id: int):
if body:
body_html = mistune.html(body)
body_html = await bake_scenario_cards(body_html)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
build_dir = BUILD_DIR / language
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / f"{article['slug']}.html").write_text(body_html)
md_dir = Path("data/content/articles")
md_dir.mkdir(parents=True, exist_ok=True)
@@ -1802,14 +1842,16 @@ async def article_edit(article_id: int):
now = datetime.utcnow().isoformat()
pub_dt = published_at or article["published_at"]
seo_head = _build_article_seo_head(url_path, title, meta_description, language, pub_dt)
await execute(
"""UPDATE articles
SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?,
country = ?, region = ?, status = ?, published_at = ?, updated_at = ?
country = ?, region = ?, language = ?, status = ?, published_at = ?,
seo_head = ?, updated_at = ?
WHERE id = ?""",
(title, url_path, meta_description, og_image_url,
country, region, status, pub_dt, now, article_id),
country, region, language, status, pub_dt, seo_head, now, article_id),
)
await flash("Article updated.", "success")
return redirect(url_for("admin.articles"))

View File

@@ -60,7 +60,14 @@
<p class="form-hint">Use [scenario:slug] to embed scenario widgets. Sections: :capex, :operating, :cashflow, :returns, :full</p>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="language">Language</label>
<select id="language" name="language" class="form-input">
<option value="en" {% if data.get('language', 'en') == 'en' %}selected{% endif %}>English (en)</option>
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>German (de)</option>
</select>
</div>
<div>
<label class="form-label" for="status">Status</label>
<select id="status" name="status" class="form-input">

View File

@@ -4,12 +4,15 @@
{% block head %}
<meta name="description" content="{{ article.meta_description or '' }}">
<meta property="og:title" content="{{ article.title }}">
<meta property="og:description" content="{{ article.meta_description or '' }}">
<meta property="og:type" content="article">
{% if article.og_image_url %}
<meta property="og:image" content="{{ article.og_image_url }}">
{% endif %}
{% if article.seo_head %}
{{ article.seo_head | safe }}
{% else %}
<meta property="og:title" content="{{ article.title }}">
<meta property="og:description" content="{{ article.meta_description or '' }}">
<meta property="og:type" content="article">
<script type="application/ld+json">
{
"@context": "https://schema.org",
@@ -18,20 +21,15 @@
"description": {{ (article.meta_description or '') | tojson }},
{% if article.og_image_url %}"image": {{ article.og_image_url | tojson }},{% endif %}
"datePublished": "{{ article.published_at[:10] if article.published_at else '' }}",
"author": {
"@type": "Organization",
"name": "Padelnomics"
},
"author": {"@type": "Organization", "name": "Padelnomics"},
"publisher": {
"@type": "Organization",
"name": "Padelnomics",
"logo": {
"@type": "ImageObject",
"url": "{{ url_for('static', filename='images/logo.png', _external=True) }}"
}
"logo": {"@type": "ImageObject", "url": "{{ url_for('static', filename='images/logo.png', _external=True) }}"}
}
}
</script>
{% endif %}
{% endblock %}
{% block content %}