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,
|
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
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
"admin",
|
"admin",
|
||||||
@@ -1716,6 +1752,7 @@ async def article_new():
|
|||||||
country = form.get("country", "").strip()
|
country = form.get("country", "").strip()
|
||||||
region = form.get("region", "").strip()
|
region = form.get("region", "").strip()
|
||||||
body = form.get("body", "").strip()
|
body = form.get("body", "").strip()
|
||||||
|
language = form.get("language", "en").strip() or "en"
|
||||||
status = form.get("status", "draft")
|
status = form.get("status", "draft")
|
||||||
published_at = form.get("published_at", "").strip()
|
published_at = form.get("published_at", "").strip()
|
||||||
|
|
||||||
@@ -1731,9 +1768,9 @@ async def article_new():
|
|||||||
body_html = mistune.html(body)
|
body_html = mistune.html(body)
|
||||||
body_html = await bake_scenario_cards(body_html)
|
body_html = await bake_scenario_cards(body_html)
|
||||||
|
|
||||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
build_dir = BUILD_DIR / language
|
||||||
build_path = BUILD_DIR / f"{article_slug}.html"
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
build_path.write_text(body_html)
|
(build_dir / f"{article_slug}.html").write_text(body_html)
|
||||||
|
|
||||||
# Save markdown source
|
# Save markdown source
|
||||||
md_dir = Path("data/content/articles")
|
md_dir = Path("data/content/articles")
|
||||||
@@ -1741,14 +1778,15 @@ async def article_new():
|
|||||||
(md_dir / f"{article_slug}.md").write_text(body)
|
(md_dir / f"{article_slug}.md").write_text(body)
|
||||||
|
|
||||||
pub_dt = published_at or datetime.utcnow().isoformat()
|
pub_dt = published_at or datetime.utcnow().isoformat()
|
||||||
|
seo_head = _build_article_seo_head(url_path, title, meta_description, language, pub_dt)
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
"""INSERT INTO articles
|
"""INSERT INTO articles
|
||||||
(url_path, slug, title, meta_description, og_image_url,
|
(url_path, slug, title, meta_description, og_image_url,
|
||||||
country, region, status, published_at)
|
country, region, language, status, published_at, seo_head)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(url_path, article_slug, title, meta_description, og_image_url,
|
(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
|
from ..sitemap import invalidate_sitemap_cache
|
||||||
invalidate_sitemap_cache()
|
invalidate_sitemap_cache()
|
||||||
@@ -1780,6 +1818,7 @@ async def article_edit(article_id: int):
|
|||||||
country = form.get("country", "").strip()
|
country = form.get("country", "").strip()
|
||||||
region = form.get("region", "").strip()
|
region = form.get("region", "").strip()
|
||||||
body = form.get("body", "").strip()
|
body = form.get("body", "").strip()
|
||||||
|
language = form.get("language", article.get("language", "en")).strip() or "en"
|
||||||
status = form.get("status", article["status"])
|
status = form.get("status", article["status"])
|
||||||
published_at = form.get("published_at", "").strip()
|
published_at = form.get("published_at", "").strip()
|
||||||
|
|
||||||
@@ -1793,8 +1832,9 @@ async def article_edit(article_id: int):
|
|||||||
if body:
|
if body:
|
||||||
body_html = mistune.html(body)
|
body_html = mistune.html(body)
|
||||||
body_html = await bake_scenario_cards(body_html)
|
body_html = await bake_scenario_cards(body_html)
|
||||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
build_dir = BUILD_DIR / language
|
||||||
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
|
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 = Path("data/content/articles")
|
||||||
md_dir.mkdir(parents=True, exist_ok=True)
|
md_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -1802,14 +1842,16 @@ async def article_edit(article_id: int):
|
|||||||
|
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
pub_dt = published_at or article["published_at"]
|
pub_dt = published_at or article["published_at"]
|
||||||
|
seo_head = _build_article_seo_head(url_path, title, meta_description, language, pub_dt)
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
"""UPDATE articles
|
"""UPDATE articles
|
||||||
SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?,
|
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 = ?""",
|
WHERE id = ?""",
|
||||||
(title, url_path, meta_description, og_image_url,
|
(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")
|
await flash("Article updated.", "success")
|
||||||
return redirect(url_for("admin.articles"))
|
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>
|
<p class="form-hint">Use [scenario:slug] to embed scenario widgets. Sections: :capex, :operating, :cashflow, :returns, :full</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="form-label" for="status">Status</label>
|
<label class="form-label" for="status">Status</label>
|
||||||
<select id="status" name="status" class="form-input">
|
<select id="status" name="status" class="form-input">
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<meta name="description" content="{{ article.meta_description or '' }}">
|
<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 %}
|
{% if article.og_image_url %}
|
||||||
<meta property="og:image" content="{{ article.og_image_url }}">
|
<meta property="og:image" content="{{ article.og_image_url }}">
|
||||||
{% endif %}
|
{% 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">
|
<script type="application/ld+json">
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -18,20 +21,15 @@
|
|||||||
"description": {{ (article.meta_description or '') | tojson }},
|
"description": {{ (article.meta_description or '') | tojson }},
|
||||||
{% if article.og_image_url %}"image": {{ article.og_image_url | tojson }},{% endif %}
|
{% if article.og_image_url %}"image": {{ article.og_image_url | tojson }},{% endif %}
|
||||||
"datePublished": "{{ article.published_at[:10] if article.published_at else '' }}",
|
"datePublished": "{{ article.published_at[:10] if article.published_at else '' }}",
|
||||||
"author": {
|
"author": {"@type": "Organization", "name": "Padelnomics"},
|
||||||
"@type": "Organization",
|
|
||||||
"name": "Padelnomics"
|
|
||||||
},
|
|
||||||
"publisher": {
|
"publisher": {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": "Padelnomics",
|
"name": "Padelnomics",
|
||||||
"logo": {
|
"logo": {"@type": "ImageObject", "url": "{{ url_for('static', filename='images/logo.png', _external=True) }}"}
|
||||||
"@type": "ImageObject",
|
|
||||||
"url": "{{ url_for('static', filename='images/logo.png', _external=True) }}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
Reference in New Issue
Block a user