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:
@@ -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("&", "&").replace('"', """).replace("<", "<")
|
||||
|
||||
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"))
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user