feat: SSG-inspired pSEO CMS — git templates + DuckDB direct reads

Replace the old CSV-upload-based CMS with an SSG architecture where
templates live in git as .md.jinja files with YAML frontmatter and
data comes directly from DuckDB serving tables. Only articles and
published_scenarios remain in SQLite for routing/state.

- Content module: discover, load, generate, preview functions
- Migration 0018: drop article_templates + template_data, recreate
  articles + published_scenarios without FK references, add
  template_slug/language/date_modified/seo_head columns
- Admin routes: read-only template views with generate/regenerate/preview
- SEO pipeline: canonical URLs, hreflang (EN+DE), JSON-LD (Article,
  FAQPage, BreadcrumbList), Open Graph tags baked at generation time
- Example template: city-cost-de.md.jinja for German city market data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-23 12:25:44 +01:00
parent 5b6c4182f7
commit f1181342ad
15 changed files with 1153 additions and 899 deletions

View File

@@ -1,8 +1,6 @@
"""
Admin domain: role-based admin panel for managing users, tasks, etc.
"""
import csv
import io
import json
from datetime import date, datetime, timedelta
from pathlib import Path
@@ -828,424 +826,140 @@ async def feedback():
# =============================================================================
# Article Template Management
# Content Templates (read-only — templates live in git as .md.jinja files)
# =============================================================================
@bp.route("/templates")
@role_required("admin")
async def templates():
"""List article templates."""
template_list = await fetch_all(
"SELECT * FROM article_templates ORDER BY created_at DESC"
)
# Attach data row counts
"""List content templates scanned from disk."""
from ..content import discover_templates, fetch_template_data
template_list = discover_templates()
# Attach DuckDB row counts
for t in template_list:
count_rows = await fetch_template_data(t["data_table"], limit=501)
t["data_count"] = len(count_rows)
# Count generated articles for this template
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ?", (t["id"],)
)
t["data_count"] = row["cnt"] if row else 0
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ? AND article_id IS NOT NULL",
(t["id"],),
"SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?",
(t["slug"],),
)
t["generated_count"] = row["cnt"] if row else 0
return await render_template("admin/templates.html", templates=template_list)
@bp.route("/templates/new", methods=["GET", "POST"])
@bp.route("/templates/<slug>")
@role_required("admin")
@csrf_protect
async def template_new():
"""Create a new article template."""
if request.method == "POST":
form = await request.form
name = form.get("name", "").strip()
template_slug = form.get("slug", "").strip() or slugify(name)
content_type = form.get("content_type", "calculator")
input_schema = form.get("input_schema", "[]").strip()
url_pattern = form.get("url_pattern", "").strip()
title_pattern = form.get("title_pattern", "").strip()
meta_description_pattern = form.get("meta_description_pattern", "").strip()
body_template = form.get("body_template", "").strip()
async def template_detail(slug: str):
"""Template detail: config (read-only), columns, sample data, actions."""
from ..content import fetch_template_data, get_table_columns, load_template
if not name or not url_pattern or not title_pattern or not body_template:
await flash("Name, URL pattern, title pattern, and body template are required.", "error")
return await render_template("admin/template_form.html", data=dict(form), editing=False)
# Validate input_schema is valid JSON
try:
json.loads(input_schema)
except json.JSONDecodeError:
await flash("Input schema must be valid JSON.", "error")
return await render_template("admin/template_form.html", data=dict(form), editing=False)
existing = await fetch_one(
"SELECT 1 FROM article_templates WHERE slug = ?", (template_slug,)
)
if existing:
await flash(f"Slug '{template_slug}' already exists.", "error")
return await render_template("admin/template_form.html", data=dict(form), editing=False)
template_id = await execute(
"""INSERT INTO article_templates
(name, slug, content_type, input_schema, url_pattern,
title_pattern, meta_description_pattern, body_template)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(name, template_slug, content_type, input_schema, url_pattern,
title_pattern, meta_description_pattern, body_template),
)
await flash(f"Template '{name}' created.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
return await render_template("admin/template_form.html", data={}, editing=False)
@bp.route("/templates/<int:template_id>/edit", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def template_edit(template_id: int):
"""Edit an article template."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
try:
config = load_template(slug)
except (AssertionError, FileNotFoundError):
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
if request.method == "POST":
form = await request.form
name = form.get("name", "").strip()
input_schema = form.get("input_schema", "[]").strip()
url_pattern = form.get("url_pattern", "").strip()
title_pattern = form.get("title_pattern", "").strip()
meta_description_pattern = form.get("meta_description_pattern", "").strip()
body_template = form.get("body_template", "").strip()
columns = await get_table_columns(config["data_table"])
sample_rows = await fetch_template_data(config["data_table"], limit=10)
if not name or not url_pattern or not title_pattern or not body_template:
await flash("Name, URL pattern, title pattern, and body template are required.", "error")
return await render_template(
"admin/template_form.html", data=dict(form), editing=True, template_id=template_id,
)
try:
json.loads(input_schema)
except json.JSONDecodeError:
await flash("Input schema must be valid JSON.", "error")
return await render_template(
"admin/template_form.html", data=dict(form), editing=True, template_id=template_id,
)
now = datetime.utcnow().isoformat()
await execute(
"""UPDATE article_templates
SET name = ?, input_schema = ?, url_pattern = ?,
title_pattern = ?, meta_description_pattern = ?,
body_template = ?, updated_at = ?
WHERE id = ?""",
(name, input_schema, url_pattern, title_pattern,
meta_description_pattern, body_template, now, template_id),
)
await flash("Template updated.", "success")
return redirect(url_for("admin.templates"))
# Count generated articles
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?", (slug,),
)
generated_count = row["cnt"] if row else 0
return await render_template(
"admin/template_form.html", data=dict(template), editing=True, template_id=template_id,
"admin/template_detail.html",
config_data=config,
columns=columns,
sample_rows=sample_rows,
generated_count=generated_count,
)
@bp.route("/templates/<int:template_id>/delete", methods=["POST"])
@bp.route("/templates/<slug>/preview/<row_key>")
@role_required("admin")
@csrf_protect
async def template_delete(template_id: int):
"""Delete an article template."""
await execute("DELETE FROM article_templates WHERE id = ?", (template_id,))
await flash("Template deleted.", "success")
return redirect(url_for("admin.templates"))
async def template_preview(slug: str, row_key: str):
"""Preview a single article rendered from template + DuckDB row."""
from ..content import preview_article
lang = request.args.get("lang", "en")
try:
result = await preview_article(slug, row_key, lang=lang)
except (AssertionError, Exception) as exc:
await flash(f"Preview error: {exc}", "error")
return redirect(url_for("admin.template_detail", slug=slug))
# =============================================================================
# Template Data Management
# =============================================================================
@bp.route("/templates/<int:template_id>/data")
@role_required("admin")
async def template_data(template_id: int):
"""View data rows for a template."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
data_rows = await fetch_all(
"""SELECT td.*, a.title as article_title, a.url_path as article_url,
ps.slug as scenario_slug
FROM template_data td
LEFT JOIN articles a ON a.id = td.article_id
LEFT JOIN published_scenarios ps ON ps.id = td.scenario_id
WHERE td.template_id = ?
ORDER BY td.created_at DESC""",
(template_id,),
)
# Pre-parse data_json for display in template
for row in data_rows:
try:
row["parsed_data"] = json.loads(row["data_json"])
except (json.JSONDecodeError, TypeError):
row["parsed_data"] = {}
schema = json.loads(template["input_schema"])
return await render_template(
"admin/template_data.html",
template=template,
data_rows=data_rows,
schema=schema,
"admin/template_preview.html",
config={"slug": slug},
preview=result,
lang=lang,
)
@bp.route("/templates/<int:template_id>/data/add", methods=["POST"])
@bp.route("/templates/<slug>/generate", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def template_data_add(template_id: int):
"""Add a single data row."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
async def template_generate(slug: str):
"""Generate articles from template + DuckDB data."""
from ..content import fetch_template_data, generate_articles, load_template
try:
config = load_template(slug)
except (AssertionError, FileNotFoundError):
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
form = await request.form
schema = json.loads(template["input_schema"])
data = {}
for field in schema:
val = form.get(field["name"], "").strip()
if field.get("field_type") in ("number", "float"):
try:
data[field["name"]] = float(val) if val else 0
except ValueError:
data[field["name"]] = 0
else:
data[field["name"]] = val
await execute(
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
(template_id, json.dumps(data)),
)
await flash("Data row added.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
@bp.route("/templates/<int:template_id>/data/upload", methods=["POST"])
@role_required("admin")
@csrf_protect
async def template_data_upload(template_id: int):
"""Bulk upload data rows from CSV."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
files = await request.files
csv_file = files.get("csv_file")
if not csv_file:
await flash("No CSV file uploaded.", "error")
return redirect(url_for("admin.template_data", template_id=template_id))
content = (await csv_file.read()).decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(content))
rows_added = 0
for row in reader:
data = {k.strip(): v.strip() for k, v in row.items() if k and v}
if data:
await execute(
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
(template_id, json.dumps(data)),
)
rows_added += 1
await flash(f"{rows_added} data rows imported from CSV.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
@bp.route("/templates/<int:template_id>/data/<int:data_id>/delete", methods=["POST"])
@role_required("admin")
@csrf_protect
async def template_data_delete(template_id: int, data_id: int):
"""Delete a single data row."""
await execute("DELETE FROM template_data WHERE id = ? AND template_id = ?", (data_id, template_id))
await flash("Data row deleted.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
# =============================================================================
# Bulk Generation
# =============================================================================
def _render_jinja_string(template_str: str, context: dict) -> str:
"""Render a Jinja2 template string with the given context."""
from jinja2 import Environment
env = Environment()
tmpl = env.from_string(template_str)
return tmpl.render(**context)
@bp.route("/templates/<int:template_id>/generate", methods=["GET", "POST"])
@role_required("admin")
@csrf_protect
async def template_generate(template_id: int):
"""Bulk-generate scenarios + articles from template data."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
pending_count = await fetch_one(
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ? AND article_id IS NULL",
(template_id,),
)
pending = pending_count["cnt"] if pending_count else 0
data_rows = await fetch_template_data(config["data_table"], limit=501)
row_count = len(data_rows)
if request.method == "POST":
form = await request.form
start_date_str = form.get("start_date", "")
articles_per_day = int(form.get("articles_per_day", 2) or 2)
articles_per_day = int(form.get("articles_per_day", 3) or 3)
if not start_date_str:
start_date = date.today()
else:
start_date = date.fromisoformat(start_date_str)
assert articles_per_day > 0, "articles_per_day must be positive"
generated = await _generate_from_template(template, start_date, articles_per_day)
generated = await generate_articles(
slug, start_date, articles_per_day, limit=500,
)
await flash(f"Generated {generated} articles with staggered publish dates.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
return redirect(url_for("admin.articles"))
return await render_template(
"admin/generate_form.html",
template=template,
pending_count=pending,
config_data=config,
row_count=row_count,
today=date.today().isoformat(),
)
async def _generate_from_template(template: dict, start_date: date, articles_per_day: int) -> int:
"""Generate scenarios + articles for all un-generated data rows."""
from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path
from ..planner.calculator import DEFAULTS, calc, validate_state
@bp.route("/templates/<slug>/regenerate", methods=["POST"])
@role_required("admin")
@csrf_protect
async def template_regenerate(slug: str):
"""Re-generate all articles for a template with fresh DuckDB data."""
from ..content import generate_articles, load_template
data_rows = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ? AND article_id IS NULL",
(template["id"],),
)
try:
load_template(slug)
except (AssertionError, FileNotFoundError):
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
publish_date = start_date
published_today = 0
generated = 0
for row in data_rows:
data = json.loads(row["data_json"])
# Separate calc fields from display fields
lang = data.get("language", "en")
calc_overrides = {k: v for k, v in data.items() if k in DEFAULTS}
state = validate_state(calc_overrides)
d = calc(state, lang=lang)
# Build scenario slug
city_slug = data.get("city_slug", str(row["id"]))
scenario_slug = template["slug"] + "-" + city_slug
# Court config label
dbl = state.get("dblCourts", 0)
sgl = state.get("sglCourts", 0)
court_config = f"{dbl} double + {sgl} single"
# Create published scenario
scenario_id = await execute(
"""INSERT OR IGNORE INTO published_scenarios
(slug, title, subtitle, location, country, venue_type, ownership,
court_config, state_json, calc_json, template_data_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
scenario_slug,
data.get("city", scenario_slug),
data.get("subtitle", ""),
data.get("city", ""),
data.get("country", state.get("country", "")),
state.get("venue", "indoor"),
state.get("own", "rent"),
court_config,
json.dumps(state),
json.dumps(d),
row["id"],
),
)
if not scenario_id:
# Slug already exists, fetch existing
existing = await fetch_one(
"SELECT id FROM published_scenarios WHERE slug = ?", (scenario_slug,)
)
scenario_id = existing["id"] if existing else None
if not scenario_id:
continue
# Fill template patterns
data["scenario_slug"] = scenario_slug
title = _render_jinja_string(template["title_pattern"], data)
url_path = _render_jinja_string(template["url_pattern"], data)
article_slug = template["slug"] + "-" + city_slug
meta_desc = ""
if template["meta_description_pattern"]:
meta_desc = _render_jinja_string(template["meta_description_pattern"], data)
# Validate url_path
if is_reserved_path(url_path):
continue
# Render body
body_md = _render_jinja_string(template["body_template"], data)
body_html = mistune.html(body_md)
body_html = await bake_scenario_cards(body_html, lang=lang)
# Write to disk
BUILD_DIR.mkdir(parents=True, exist_ok=True)
build_path = BUILD_DIR / f"{article_slug}.html"
build_path.write_text(body_html)
# Stagger publish date
publish_dt = datetime(publish_date.year, publish_date.month, publish_date.day, 8, 0, 0)
# Create article
article_id = await execute(
"""INSERT OR IGNORE INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at, template_data_id)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?)""",
(
url_path, article_slug, title, meta_desc,
data.get("country", ""), data.get("region", ""),
publish_dt.isoformat(), row["id"],
),
)
if article_id:
# Link data row
now = datetime.utcnow().isoformat()
await execute(
"UPDATE template_data SET scenario_id = ?, article_id = ?, updated_at = ? WHERE id = ?",
(scenario_id, article_id, now, row["id"]),
)
generated += 1
# Stagger dates
published_today += 1
if published_today >= articles_per_day:
published_today = 0
publish_date += timedelta(days=1)
return generated
# Use today as start date, keep existing publish dates via upsert
generated = await generate_articles(slug, date.today(), articles_per_day=500)
await flash(f"Regenerated {generated} articles from fresh data.", "success")
return redirect(url_for("admin.template_detail", slug=slug))
# =============================================================================
@@ -1659,46 +1373,31 @@ async def rebuild_all():
async def _rebuild_article(article_id: int):
"""Re-render a single article from its source (template+data or markdown)."""
"""Re-render a single article from its source."""
from ..content.routes import BUILD_DIR, bake_scenario_cards
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
if not article:
return
if article["template_data_id"]:
# Generated article: re-render from template + data
td = await fetch_one(
"""SELECT td.*, at.body_template, at.title_pattern, at.meta_description_pattern
FROM template_data td
JOIN article_templates at ON at.id = td.template_id
WHERE td.id = ?""",
(article["template_data_id"],),
)
if not td:
if article["template_slug"]:
# SSG-generated article: regenerate via the content module
from ..content import generate_articles, load_template
try:
load_template(article["template_slug"])
except (AssertionError, FileNotFoundError):
return
data = json.loads(td["data_json"])
# Re-fetch scenario for fresh calc_json
if td["scenario_id"]:
scenario = await fetch_one(
"SELECT slug FROM published_scenarios WHERE id = ?", (td["scenario_id"],)
)
if scenario:
data["scenario_slug"] = scenario["slug"]
body_md = _render_jinja_string(td["body_template"], data)
body_html = mistune.html(body_md)
lang = data.get("language", "en")
# Regenerate all articles for this template (upserts, so safe)
await generate_articles(
article["template_slug"], date.today(), articles_per_day=500,
)
else:
# Manual article: re-render from markdown file
md_path = Path("data/content/articles") / f"{article['slug']}.md"
if not md_path.exists():
return
body_html = mistune.html(md_path.read_text())
lang = "en"
body_html = await bake_scenario_cards(body_html, lang=lang)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
lang = article.get("language", "en") if hasattr(article, "get") else "en"
body_html = await bake_scenario_cards(body_html, lang=lang)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)

View File

@@ -1,17 +1,22 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}Generate Articles - {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block title %}Generate Articles - {{ config_data.name }} - Admin{% endblock %}
{% block admin_content %}
<div style="max-width: 32rem; margin: 0 auto;">
<a href="{{ url_for('admin.template_data', template_id=template.id) }}" class="text-sm text-slate">&larr; Back to {{ template.name }}</a>
<a href="{{ url_for('admin.template_detail', slug=config_data.slug) }}" class="text-sm text-slate">&larr; Back to {{ config_data.name }}</a>
<h1 class="text-2xl mt-4 mb-2">Generate Articles</h1>
<p class="text-slate text-sm mb-6">{{ pending_count }} pending data row{{ 's' if pending_count != 1 }} ready to generate.</p>
<p class="text-slate text-sm mb-6">
{{ row_count }} data row{{ 's' if row_count != 1 }} available in
<code>{{ config_data.data_table }}</code>
&times; {{ config_data.languages | length }} language{{ 's' if config_data.languages | length != 1 }}
= <strong>{{ row_count * config_data.languages | length }}</strong> articles.
</p>
{% if pending_count == 0 %}
{% if row_count == 0 %}
<div class="card">
<p class="text-slate text-sm">All data rows have already been generated. Add more data rows first.</p>
<p class="text-slate text-sm">No data rows found. Run the data pipeline to populate <code>{{ config_data.data_table }}</code>.</p>
</div>
{% else %}
<form method="post" class="card">
@@ -25,20 +30,23 @@
<div class="mb-4">
<label class="form-label" for="articles_per_day">Articles Per Day</label>
<input type="number" id="articles_per_day" name="articles_per_day" value="2" min="1" max="50" class="form-input" required>
<input type="number" id="articles_per_day" name="articles_per_day" value="3" min="1" max="50" class="form-input" required>
<p class="form-hint">How many articles to publish per day. Remaining articles get staggered to following days.</p>
</div>
<div class="card" style="background: var(--color-soft-white); border: 1px dashed var(--color-mid-gray); margin-bottom: 1rem;">
<p class="text-sm text-slate">
This will generate <strong class="text-navy">{{ pending_count }}</strong> articles
over <strong class="text-navy" id="days-estimate">{{ ((pending_count + 1) // 2) }}</strong> days,
each with its own financial scenario computed from the data row's input values.
This will generate up to <strong class="text-navy">{{ row_count * config_data.languages | length }}</strong> articles
({{ row_count }} rows &times; {{ config_data.languages | length }} languages).
Existing articles with the same URL will be updated in-place.
{% if config_data.priority_column %}
Articles are ordered by <code>{{ config_data.priority_column }}</code> (highest first).
{% endif %}
</p>
</div>
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate {{ pending_count }} articles? This cannot be undone.')">
Generate {{ pending_count }} Articles
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate articles? Existing articles will be updated.')">
Generate Articles
</button>
</form>
{% endif %}

View File

@@ -1,103 +0,0 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}Data: {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<div>
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Back to templates</a>
<h1 class="text-2xl mt-2">{{ template.name }}</h1>
<p class="text-slate text-sm">{{ data_rows | length }} data row{{ 's' if data_rows | length != 1 }} · <span class="mono">{{ template.slug }}</span></p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.template_generate', template_id=template.id) }}" class="btn">Generate Articles</a>
<a href="{{ url_for('admin.template_edit', template_id=template.id) }}" class="btn-outline">Edit Template</a>
</div>
</header>
<!-- Add Single Row -->
<div class="card mb-6">
<h3 class="text-base font-semibold mb-4">Add Data Row</h3>
<form method="post" action="{{ url_for('admin.template_data_add', template_id=template.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.75rem;" class="mb-4">
{% for field in schema %}
<div>
<label class="form-label">{{ field.label }}</label>
<input type="{{ 'number' if field.get('field_type') in ('number', 'float') else 'text' }}"
name="{{ field.name }}" class="form-input"
{% if field.get('field_type') == 'float' %}step="any"{% endif %}
{% if field.get('required') %}required{% endif %}>
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-sm">Add Row</button>
</form>
</div>
<!-- CSV Upload -->
<div class="card mb-6">
<h3 class="text-base font-semibold mb-4">Bulk Upload (CSV)</h3>
<form method="post" action="{{ url_for('admin.template_data_upload', template_id=template.id) }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="flex items-end gap-3">
<div style="flex: 1;">
<label class="form-label" for="csv_file">CSV File</label>
<input type="file" id="csv_file" name="csv_file" accept=".csv" class="form-input" required>
</div>
<button type="submit" class="btn btn-sm">Upload</button>
</div>
<p class="form-hint mt-1">CSV headers should match field names: {{ schema | map(attribute='name') | join(', ') }}</p>
</form>
</div>
<!-- Data Rows -->
<div class="card">
<h3 class="text-base font-semibold mb-4">Data Rows</h3>
{% if data_rows %}
<div class="scenario-widget__table-wrap">
<table class="table">
<thead>
<tr>
<th>#</th>
{% for field in schema[:5] %}
<th>{{ field.label }}</th>
{% endfor %}
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in data_rows %}
<tr>
<td class="mono text-sm">{{ row.id }}</td>
{% for field in schema[:5] %}
<td class="text-sm">{{ row.parsed_data.get(field.name, '') }}</td>
{% endfor %}
<td>
{% if row.article_id %}
<span class="badge-success">Generated</span>
{% if row.article_url %}
<a href="{{ row.article_url }}" class="text-xs ml-1">View</a>
{% endif %}
{% else %}
<span class="badge-warning">Pending</span>
{% endif %}
</td>
<td class="text-right">
<form method="post" action="{{ url_for('admin.template_data_delete', template_id=template.id, data_id=row.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this data row?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-slate text-sm">No data rows yet. Add some above or upload a CSV.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,111 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}{{ config_data.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Templates</a>
<header class="flex justify-between items-center mt-4 mb-6">
<div>
<h1 class="text-2xl">{{ config_data.name }}</h1>
<p class="text-slate text-sm mono">{{ config_data.slug }}</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.template_generate', slug=config_data.slug) }}" class="btn">Generate Articles</a>
<form method="post" action="{{ url_for('admin.template_regenerate', slug=config_data.slug) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" onclick="return confirm('Regenerate all articles for this template with fresh data?')">
Regenerate
</button>
</form>
</div>
</header>
{# Config section #}
<div class="card mb-6">
<h2 class="text-lg mb-4">Configuration (read-only)</h2>
<table class="table">
<tbody>
<tr><td class="font-semibold" style="width:200px">Content Type</td><td><span class="badge">{{ config_data.content_type }}</span></td></tr>
<tr><td class="font-semibold">Data Table</td><td class="mono">{{ config_data.data_table }}</td></tr>
<tr><td class="font-semibold">Natural Key</td><td class="mono">{{ config_data.natural_key }}</td></tr>
<tr><td class="font-semibold">Languages</td><td>{{ config_data.languages | join(', ') }}</td></tr>
<tr><td class="font-semibold">URL Pattern</td><td class="mono text-sm">{{ config_data.url_pattern }}</td></tr>
<tr><td class="font-semibold">Title Pattern</td><td class="text-sm">{{ config_data.title_pattern }}</td></tr>
<tr><td class="font-semibold">Meta Description</td><td class="text-sm">{{ config_data.meta_description_pattern }}</td></tr>
<tr><td class="font-semibold">Schema Types</td><td>{{ config_data.schema_type | join(', ') }}</td></tr>
{% if config_data.priority_column %}
<tr><td class="font-semibold">Priority Column</td><td class="mono">{{ config_data.priority_column }}</td></tr>
{% endif %}
</tbody>
</table>
<p class="text-slate text-sm mt-4">Edit this template in the repo: <code>content/templates/{{ config_data.slug }}.md.jinja</code></p>
</div>
{# Stats #}
<div class="flex gap-4 mb-6">
<div class="card" style="flex:1; text-align:center">
<div class="text-2xl font-bold">{{ columns | length }}</div>
<div class="text-slate text-sm">Columns</div>
</div>
<div class="card" style="flex:1; text-align:center">
<div class="text-2xl font-bold">{{ sample_rows | length }}{% if sample_rows | length >= 10 %}+{% endif %}</div>
<div class="text-slate text-sm">Data Rows</div>
</div>
<div class="card" style="flex:1; text-align:center">
<div class="text-2xl font-bold">{{ generated_count }}</div>
<div class="text-slate text-sm">Generated</div>
</div>
</div>
{# Columns #}
<div class="card mb-6">
<h2 class="text-lg mb-4">Available Columns</h2>
{% if columns %}
<div style="display:flex; flex-wrap:wrap; gap:8px">
{% for col in columns %}
<span class="badge" title="{{ col.type }}">{{ col.name }} <span class="text-slate">({{ col.type }})</span></span>
{% endfor %}
</div>
{% else %}
<p class="text-slate text-sm">No columns found. Is the DuckDB table <code>{{ config_data.data_table }}</code> available?</p>
{% endif %}
</div>
{# Sample data #}
<div class="card mb-6">
<h2 class="text-lg mb-4">Sample Data (first 10 rows)</h2>
{% if sample_rows %}
<div style="overflow-x:auto">
<table class="table text-sm">
<thead>
<tr>
{% for col in columns %}
<th>{{ col.name }}</th>
{% endfor %}
<th>Preview</th>
</tr>
</thead>
<tbody>
{% for row in sample_rows %}
<tr>
{% for col in columns %}
<td class="mono" style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
{{ row[col.name] }}
</td>
{% endfor %}
<td>
<a href="{{ url_for('admin.template_preview', slug=config_data.slug, row_key=row[config_data.natural_key]) }}"
class="btn-outline btn-sm">Preview</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-slate text-sm">No data available. Run the data pipeline first.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,70 +0,0 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Template - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<div style="max-width: 48rem; margin: 0 auto;">
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Back to templates</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article Template</h1>
<form method="post" class="card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="name">Name</label>
<input type="text" id="name" name="name" value="{{ data.get('name', '') }}" class="form-input" required>
</div>
<div>
<label class="form-label" for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
placeholder="auto-generated from name" {% if editing %}readonly{% endif %}>
</div>
</div>
<div class="mb-4">
<label class="form-label" for="content_type">Content Type</label>
<select id="content_type" name="content_type" class="form-input" {% if editing %}disabled{% endif %}>
<option value="calculator" {% if data.get('content_type') == 'calculator' %}selected{% endif %}>Calculator</option>
<option value="map" {% if data.get('content_type') == 'map' %}selected{% endif %}>Map (future)</option>
</select>
</div>
<div class="mb-4">
<label class="form-label" for="input_schema">Input Schema (JSON)</label>
<textarea id="input_schema" name="input_schema" rows="6" class="form-input" style="font-family: var(--font-mono); font-size: 0.8125rem;">{{ data.get('input_schema', '[{"name": "city", "label": "City", "field_type": "text", "required": true}, {"name": "city_slug", "label": "City Slug", "field_type": "text", "required": true}, {"name": "country", "label": "Country", "field_type": "text", "required": true}, {"name": "region", "label": "Region", "field_type": "text", "required": false}]') }}</textarea>
<p class="form-hint">JSON array of field definitions: [{name, label, field_type, required}]</p>
</div>
<div class="mb-4">
<label class="form-label" for="url_pattern">URL Pattern</label>
<input type="text" id="url_pattern" name="url_pattern" value="{{ data.get('url_pattern', '') }}"
class="form-input" placeholder="/padel-court-cost-{{ '{{' }} city_slug {{ '}}' }}" required>
<p class="form-hint">Jinja2 template string. Use {{ '{{' }} variable {{ '}}' }} placeholders from data rows.</p>
</div>
<div class="mb-4">
<label class="form-label" for="title_pattern">Title Pattern</label>
<input type="text" id="title_pattern" name="title_pattern" value="{{ data.get('title_pattern', '') }}"
class="form-input" placeholder="Padel Center Cost in {{ '{{' }} city {{ '}}' }}" required>
</div>
<div class="mb-4">
<label class="form-label" for="meta_description_pattern">Meta Description Pattern</label>
<input type="text" id="meta_description_pattern" name="meta_description_pattern"
value="{{ data.get('meta_description_pattern', '') }}" class="form-input"
placeholder="How much does it cost to build a padel center in {{ '{{' }} city {{ '}}' }}?">
</div>
<div class="mb-4">
<label class="form-label" for="body_template">Body Template (Markdown + Jinja2)</label>
<textarea id="body_template" name="body_template" rows="20" class="form-input"
style="font-family: var(--font-mono); font-size: 0.8125rem;" required>{{ data.get('body_template', '') }}</textarea>
<p class="form-hint">Markdown with {{ '{{' }} variable {{ '}}' }} placeholders. Use [scenario:{{ '{{' }} scenario_slug {{ '}}' }}] to embed financial widgets. Sections: [scenario:slug:capex], [scenario:slug:operating], [scenario:slug:cashflow], [scenario:slug:returns], [scenario:slug:full].</p>
</div>
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update{% else %}Create{% endif %} Template</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}Preview - {{ preview.title }} - Admin{% endblock %}
{% block admin_content %}
<a href="{{ url_for('admin.template_detail', slug=config.slug) }}" class="text-sm text-slate">&larr; Back to template</a>
<header class="mt-4 mb-6">
<h1 class="text-2xl">Article Preview</h1>
<p class="text-slate text-sm">Language: {{ lang }} | URL: <code>{{ preview.url_path }}</code></p>
</header>
{# Meta preview #}
<div class="card mb-6">
<h2 class="text-lg mb-2">SEO Preview</h2>
<div style="font-family: Arial, sans-serif; max-width: 600px;">
<div style="color: #1a0dab; font-size: 20px; line-height: 1.3;">{{ preview.title }}</div>
<div style="color: #006621; font-size: 14px; margin: 2px 0;">{{ preview.url_path }}</div>
<div style="color: #545454; font-size: 14px; line-height: 1.58;">{{ preview.meta_description }}</div>
</div>
</div>
{# Rendered article #}
<div class="card">
<h2 class="text-lg mb-4">Rendered HTML</h2>
<div class="prose" style="max-width: none;">
{{ preview.html | safe }}
</div>
</div>
{% endblock %}

View File

@@ -1,18 +1,15 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "templates" %}
{% block title %}Article Templates - Admin - {{ config.APP_NAME }}{% endblock %}
{% block title %}Content Templates - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">Article Templates</h1>
<p class="text-slate text-sm">{{ templates | length }} template{{ 's' if templates | length != 1 }}</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.template_new') }}" class="btn">New Template</a>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
<h1 class="text-2xl">Content Templates</h1>
<p class="text-slate text-sm">{{ templates | length }} template{{ 's' if templates | length != 1 }} (scanned from git)</p>
</div>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
</header>
<div class="card">
@@ -23,29 +20,32 @@
<th>Name</th>
<th>Slug</th>
<th>Type</th>
<th>Data Table</th>
<th>Data Rows</th>
<th>Generated</th>
<th>Languages</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in templates %}
<tr>
<td><a href="{{ url_for('admin.template_data', template_id=t.id) }}">{{ t.name }}</a></td>
<td><a href="{{ url_for('admin.template_detail', slug=t.slug) }}">{{ t.name }}</a></td>
<td class="mono text-sm">{{ t.slug }}</td>
<td><span class="badge">{{ t.content_type }}</span></td>
<td class="mono text-sm">{{ t.data_table }}</td>
<td class="mono">{{ t.data_count }}</td>
<td class="mono">{{ t.generated_count }}</td>
<td class="text-sm">{{ t.languages | join(', ') }}</td>
<td class="text-right">
<a href="{{ url_for('admin.template_edit', template_id=t.id) }}" class="btn-outline btn-sm">Edit</a>
<a href="{{ url_for('admin.template_generate', template_id=t.id) }}" class="btn btn-sm">Generate</a>
<a href="{{ url_for('admin.template_generate', slug=t.slug) }}" class="btn btn-sm">Generate</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No templates yet. Create one to get started.</p>
<p class="text-slate text-sm">No templates found. Add <code>.md.jinja</code> files to <code>content/templates/</code> in the repo.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,501 @@
"""
SSG-inspired pSEO content engine.
Templates live in git as .md.jinja files with YAML frontmatter.
Data comes from DuckDB serving tables. Only articles + published_scenarios
are stored in SQLite (routing / application state).
"""
import json
import re
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
import mistune
import yaml
from jinja2 import Environment
from ..analytics import fetch_analytics
from ..core import execute, fetch_one, slugify
# ── Constants ────────────────────────────────────────────────────────────────
TEMPLATES_DIR = Path(__file__).parent / "templates"
BUILD_DIR = Path("data/content/_build")
_REQUIRED_FRONTMATTER = {
"name", "slug", "content_type", "data_table",
"natural_key", "languages", "url_pattern", "title_pattern",
"meta_description_pattern",
}
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
# FAQ extraction: **bold question** followed by answer paragraph(s)
_FAQ_RE = re.compile(
r"\*\*(.+?)\*\*\s*\n((?:(?!\*\*).+\n?)+)",
re.MULTILINE,
)
# ── Template discovery & loading ─────────────────────────────────────────────
def discover_templates() -> list[dict]:
"""Scan TEMPLATES_DIR for .md.jinja files, return parsed frontmatter list."""
templates = []
if not TEMPLATES_DIR.exists():
return templates
for path in sorted(TEMPLATES_DIR.glob("*.md.jinja")):
try:
config = _parse_frontmatter(path.read_text())
config["_path"] = str(path)
templates.append(config)
except (ValueError, yaml.YAMLError):
continue
return templates
def load_template(slug: str) -> dict:
"""Load a single template by slug. Returns frontmatter + body_template."""
path = TEMPLATES_DIR / f"{slug}.md.jinja"
assert path.exists(), f"Template not found: {slug}"
text = path.read_text()
config = _parse_frontmatter(text)
# Everything after the closing --- is the body template
match = _FRONTMATTER_RE.match(text)
assert match, f"No frontmatter in {slug}"
config["body_template"] = text[match.end():]
return config
def _parse_frontmatter(text: str) -> dict:
"""Extract YAML frontmatter from a template file."""
match = _FRONTMATTER_RE.match(text)
if not match:
raise ValueError("No YAML frontmatter found")
config = yaml.safe_load(match.group(1))
assert isinstance(config, dict), "Frontmatter must be a YAML mapping"
missing = _REQUIRED_FRONTMATTER - set(config.keys())
assert not missing, f"Missing frontmatter keys: {missing}"
# Normalize schema_type to list
schema_type = config.get("schema_type", "Article")
if isinstance(schema_type, str):
schema_type = [schema_type]
config["schema_type"] = schema_type
return config
# ── DuckDB data access ───────────────────────────────────────────────────────
async def get_table_columns(data_table: str) -> list[dict]:
"""Query DuckDB information_schema for a serving table's columns."""
assert "." in data_table, "data_table must be schema-qualified (e.g. serving.xxx)"
schema, table = data_table.split(".", 1)
rows = await fetch_analytics(
"""SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = ? AND table_name = ?
ORDER BY ordinal_position""",
[schema, table],
)
return [{"name": r["column_name"], "type": r["data_type"]} for r in rows]
async def fetch_template_data(
data_table: str,
order_by: str | None = None,
limit: int = 500,
) -> list[dict]:
"""Fetch all rows from a DuckDB serving table."""
assert "." in data_table, "data_table must be schema-qualified"
_validate_table_name(data_table)
order_clause = f"ORDER BY {order_by} DESC" if order_by else ""
return await fetch_analytics(
f"SELECT * FROM {data_table} {order_clause} LIMIT ?",
[limit],
)
def _validate_table_name(data_table: str) -> None:
"""Guard against SQL injection in table names."""
assert re.match(r"^[a-z_][a-z0-9_.]*$", data_table), (
f"Invalid table name: {data_table}"
)
# ── Rendering helpers ────────────────────────────────────────────────────────
def _render_pattern(pattern: str, context: dict) -> str:
"""Render a Jinja2 pattern string with context variables."""
env = Environment()
env.filters["slugify"] = slugify
return env.from_string(pattern).render(**context)
def _extract_faq_pairs(markdown: str) -> list[dict]:
"""Extract FAQ Q&A pairs from a ## FAQ section in markdown."""
# Find the FAQ section
faq_start = markdown.find("## FAQ")
if faq_start == -1:
return []
# Take content until next ## heading or end
rest = markdown[faq_start:]
next_h2 = rest.find("\n## ", 1)
faq_block = rest[:next_h2] if next_h2 > 0 else rest
pairs = []
for match in _FAQ_RE.finditer(faq_block):
question = match.group(1).strip()
answer = match.group(2).strip()
if question and answer:
pairs.append({"question": question, "answer": answer})
return pairs
# ── JSON-LD structured data ──────────────────────────────────────────────────
def build_jsonld(
schema_types: list[str],
*,
title: str,
description: str,
url: str,
published_at: str,
date_modified: str,
language: str,
breadcrumbs: list[dict],
faq_pairs: list[dict] | None = None,
) -> list[dict]:
"""Build JSON-LD structured data objects for an article."""
objects = []
# BreadcrumbList — always present
objects.append({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": i + 1,
"name": bc["name"],
"item": bc["url"],
}
for i, bc in enumerate(breadcrumbs)
],
})
# Article
if "Article" in schema_types:
objects.append({
"@context": "https://schema.org",
"@type": "Article",
"headline": title[:110],
"description": description[:200],
"url": url,
"inLanguage": language,
"datePublished": published_at,
"dateModified": date_modified,
"author": {
"@type": "Organization",
"name": "Padelnomics",
"url": "https://padelnomics.io",
},
"publisher": {
"@type": "Organization",
"name": "Padelnomics",
"url": "https://padelnomics.io",
},
})
# FAQPage
if "FAQPage" in schema_types and faq_pairs:
objects.append({
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": faq["question"],
"acceptedAnswer": {
"@type": "Answer",
"text": faq["answer"],
},
}
for faq in faq_pairs
],
})
return objects
def _build_breadcrumbs(url_path: str, base_url: str) -> list[dict]:
"""Build breadcrumb list from URL path segments."""
parts = [p for p in url_path.strip("/").split("/") if p]
crumbs = [{"name": "Home", "url": base_url + "/"}]
for i, part in enumerate(parts):
label = part.replace("-", " ").title()
path = "/" + "/".join(parts[: i + 1])
crumbs.append({"name": label, "url": base_url + path})
return crumbs
# ── Article generation pipeline ──────────────────────────────────────────────
async def generate_articles(
slug: str,
start_date: date,
articles_per_day: int,
*,
limit: int = 500,
base_url: str = "https://padelnomics.io",
) -> int:
"""
Generate articles from a git template + DuckDB data.
For each row in the DuckDB table x each language:
- render patterns (url, title, meta)
- create/update published_scenario if calculator type
- render body markdown -> HTML
- bake scenario cards
- inject SEO head (canonical, hreflang, JSON-LD, OG)
- write HTML to disk
- upsert article row in SQLite
Returns count of articles generated.
"""
from ..planner.calculator import DEFAULTS, calc, validate_state
from .routes import bake_scenario_cards, is_reserved_path
assert articles_per_day > 0, "articles_per_day must be positive"
config = load_template(slug)
order_by = config.get("priority_column")
rows = await fetch_template_data(config["data_table"], order_by=order_by, limit=limit)
if not rows:
return 0
publish_date = start_date
published_today = 0
generated = 0
now_iso = datetime.now(UTC).isoformat()
for row in rows:
for lang in config["languages"]:
# Build render context: row data + language
ctx = {**row, "language": lang}
# Render URL pattern
url_path = f"/{lang}" + _render_pattern(config["url_pattern"], ctx)
if is_reserved_path(url_path):
continue
title = _render_pattern(config["title_pattern"], ctx)
meta_desc = _render_pattern(config["meta_description_pattern"], ctx)
article_slug = slug + "-" + lang + "-" + str(row[config["natural_key"]])
# Calculator content type: create scenario
scenario_slug = None
if config["content_type"] == "calculator":
calc_overrides = {k: v for k, v in row.items() if k in DEFAULTS}
state = validate_state(calc_overrides)
d = calc(state, lang=lang)
scenario_slug = slug + "-" + str(row[config["natural_key"]])
dbl = state.get("dblCourts", 0)
sgl = state.get("sglCourts", 0)
court_config = f"{dbl} double + {sgl} single"
city = row.get("city_name", row.get("city", ""))
country = row.get("country", state.get("country", ""))
# Upsert published scenario
existing = await fetch_one(
"SELECT id FROM published_scenarios WHERE slug = ?",
(scenario_slug,),
)
if existing:
await execute(
"""UPDATE published_scenarios
SET state_json = ?, calc_json = ?, updated_at = ?
WHERE slug = ?""",
(json.dumps(state), json.dumps(d), now_iso, scenario_slug),
)
else:
await execute(
"""INSERT INTO published_scenarios
(slug, title, location, country, venue_type, ownership,
court_config, state_json, calc_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
scenario_slug, city, city, country,
state.get("venue", "indoor"),
state.get("own", "rent"),
court_config,
json.dumps(state), json.dumps(d), now_iso,
),
)
ctx["scenario_slug"] = scenario_slug
# Render body template
body_md = _render_pattern(config["body_template"], ctx)
body_html = mistune.html(body_md)
body_html = await bake_scenario_cards(body_html, lang=lang)
# Extract FAQ pairs for structured data
faq_pairs = _extract_faq_pairs(body_md)
# Build SEO metadata
full_url = base_url + url_path
publish_dt = datetime(
publish_date.year, publish_date.month, publish_date.day,
8, 0, 0,
).isoformat()
# Hreflang links
hreflang_links = []
for alt_lang in config["languages"]:
alt_url = f"/{alt_lang}" + _render_pattern(config["url_pattern"], {**row, "language": alt_lang})
hreflang_links.append(
f'<link rel="alternate" hreflang="{alt_lang}" href="{base_url}{alt_url}" />'
)
# x-default points to English (or first language)
default_lang = "en" if "en" in config["languages"] else config["languages"][0]
default_url = f"/{default_lang}" + _render_pattern(config["url_pattern"], {**row, "language": default_lang})
hreflang_links.append(
f'<link rel="alternate" hreflang="x-default" href="{base_url}{default_url}" />'
)
# JSON-LD
breadcrumbs = _build_breadcrumbs(url_path, base_url)
jsonld_objects = build_jsonld(
config["schema_type"],
title=title,
description=meta_desc,
url=full_url,
published_at=publish_dt,
date_modified=now_iso,
language=lang,
breadcrumbs=breadcrumbs,
faq_pairs=faq_pairs,
)
# Build SEO head block
seo_head = "\n".join([
f'<link rel="canonical" href="{full_url}" />',
*hreflang_links,
f'<meta property="og:title" content="{_escape_attr(title)}" />',
f'<meta property="og:description" content="{_escape_attr(meta_desc)}" />',
f'<meta property="og:url" content="{full_url}" />',
'<meta property="og:type" content="article" />',
*[
f'<script type="application/ld+json">{json.dumps(obj, ensure_ascii=False)}</script>'
for obj in jsonld_objects
],
])
# Write HTML to disk
build_dir = BUILD_DIR / lang
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / f"{article_slug}.html").write_text(body_html)
# Upsert article in SQLite
existing_article = await fetch_one(
"SELECT id FROM articles WHERE url_path = ?", (url_path,),
)
if existing_article:
await execute(
"""UPDATE articles
SET title = ?, meta_description = ?, template_slug = ?,
language = ?, date_modified = ?, updated_at = ?,
seo_head = ?
WHERE url_path = ?""",
(title, meta_desc, slug, lang, now_iso, now_iso, seo_head, url_path),
)
else:
await execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at, template_slug, language, date_modified,
seo_head, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?)""",
(
url_path, article_slug, title, meta_desc,
row.get("country", ""), row.get("region", ""),
publish_dt, slug, lang, now_iso, seo_head, now_iso,
),
)
generated += 1
# Stagger dates
published_today += 1
if published_today >= articles_per_day:
published_today = 0
publish_date += timedelta(days=1)
return generated
async def preview_article(
slug: str,
row_key: str,
lang: str = "en",
base_url: str = "https://padelnomics.io",
) -> dict:
"""
Render one article in-memory for admin preview.
No disk write, no DB insert. Returns {title, url_path, html, meta_description}.
"""
from ..planner.calculator import DEFAULTS, calc, validate_state
from .routes import bake_scenario_cards
config = load_template(slug)
# Fetch one row by natural key
_validate_table_name(config["data_table"])
natural_key = config["natural_key"]
rows = await fetch_analytics(
f"SELECT * FROM {config['data_table']} WHERE {natural_key} = ? LIMIT 1",
[row_key],
)
assert rows, f"No row found for {natural_key}={row_key}"
row = rows[0]
ctx = {**row, "language": lang}
url_path = f"/{lang}" + _render_pattern(config["url_pattern"], ctx)
title = _render_pattern(config["title_pattern"], ctx)
meta_desc = _render_pattern(config["meta_description_pattern"], ctx)
# Calculator: compute scenario in-memory
if config["content_type"] == "calculator":
calc_overrides = {k: v for k, v in row.items() if k in DEFAULTS}
state = validate_state(calc_overrides)
calc(state, lang=lang) # validate state produces valid output
ctx["scenario_slug"] = slug + "-" + str(row[natural_key])
body_md = _render_pattern(config["body_template"], ctx)
body_html = mistune.html(body_md)
body_html = await bake_scenario_cards(body_html, lang=lang)
return {
"title": title,
"url_path": url_path,
"meta_description": meta_desc,
"html": body_html,
}
def _escape_attr(text: str) -> str:
"""Escape text for use in HTML attribute values."""
return text.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;")

View File

@@ -215,7 +215,12 @@ async def article_page(url_path: str):
if not article:
abort(404)
build_path = BUILD_DIR / f"{article['slug']}.html"
# SSG articles: language-prefixed build path
lang = article["language"] if article.get("language") else "en"
build_path = BUILD_DIR / lang / f"{article['slug']}.html"
if not build_path.exists():
# Fallback: flat build path (legacy manual articles)
build_path = BUILD_DIR / f"{article['slug']}.html"
if not build_path.exists():
abort(404)

View File

@@ -0,0 +1,64 @@
---
name: "DE City Padel Costs"
slug: city-cost-de
content_type: calculator
data_table: serving.pseo_city_costs_de
natural_key: city_slug
languages: [de, en]
url_pattern: "/markets/{{ country_name_en | lower | slugify }}/{{ city_slug }}"
title_pattern: "Padel in {{ city_name }} — Market Analysis & Costs"
meta_description_pattern: "How much does it cost to build a padel center in {{ city_name }}? {{ padel_venue_count }} venues, pricing data & financial model."
schema_type: [Article, FAQPage]
priority_column: population
---
# Padel in {{ city_name }}
{{ city_name }} ({{ country_name_en }}) is home to **{{ padel_venue_count }}** padel venues, serving a population of {{ population | int | default(0) }} residents. That gives the city a venue density of {{ venues_per_100k | round(1) }} venues per 100,000 people.
## Market Overview
The padel market in {{ city_name }} shows a market score of **{{ market_score | round(1) }}** based on our analysis of venue density, pricing, and occupancy data.
| Metric | Value |
|--------|-------|
| Venues | {{ padel_venue_count }} |
| Venues per 100k | {{ venues_per_100k | round(1) }} |
| Market Score | {{ market_score | round(1) }} |
| Data Confidence | {{ data_confidence }} |
## Pricing
Court rental rates in {{ city_name }}:
- **Peak hours**: {{ median_peak_rate | round(0) | int }} per hour
- **Off-peak hours**: {{ median_offpeak_rate | round(0) | int }} per hour
- **Average hourly rate**: {{ median_hourly_rate | round(0) | int }} per hour
## What Would It Cost to Build?
Based on current market data for {{ city_name }}, here is what a padel center investment looks like:
[scenario:{{ scenario_slug }}:capex]
## Revenue Potential
[scenario:{{ scenario_slug }}:operating]
## Financial Returns
[scenario:{{ scenario_slug }}:returns]
## FAQ
**How much does it cost to build a padel center in {{ city_name }}?**
Based on our financial model, building a padel center in {{ city_name }} with typical court configurations requires a total investment that depends on venue type (indoor vs outdoor), land costs, and construction standards in {{ country_name_en }}.
**How many padel courts are there in {{ city_name }}?**
{{ city_name }} currently has {{ padel_venue_count }} padel venues. With a population of {{ population | int | default(0) }}, this translates to {{ venues_per_100k | round(1) }} venues per 100,000 residents.
**Is {{ city_name }} a good location for a padel center?**
{{ city_name }} has a market score of {{ market_score | round(1) }} based on our analysis. Factors include current venue density, pricing levels, and estimated occupancy rates.
**What are typical padel court rental prices in {{ city_name }}?**
Peak hour rates average around {{ median_peak_rate | round(0) | int }} per hour, while off-peak rates are approximately {{ median_offpeak_rate | round(0) | int }} per hour.

View File

@@ -0,0 +1,125 @@
"""Drop old CMS intermediary tables, recreate articles + published_scenarios.
article_templates and template_data are replaced by git-based .md.jinja
template files + direct DuckDB reads. Nothing was published yet so
this is a clean-slate migration.
published_scenarios and articles had FK references to template_data(id).
SQLite requires full table recreation to remove FK columns, so we do
the standard create-copy-drop-rename dance for both tables.
"""
def up(conn):
# ── 1. Drop articles FTS triggers + virtual table ──────────────────
conn.execute("DROP TRIGGER IF EXISTS articles_ai")
conn.execute("DROP TRIGGER IF EXISTS articles_ad")
conn.execute("DROP TRIGGER IF EXISTS articles_au")
conn.execute("DROP TABLE IF EXISTS articles_fts")
# ── 2. Drop old intermediary tables ────────────────────────────────
conn.execute("DROP TABLE IF EXISTS template_data")
conn.execute("DROP TABLE IF EXISTS article_templates")
# ── 3. Recreate published_scenarios without template_data_id FK ────
conn.execute("""
CREATE TABLE published_scenarios_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
location TEXT NOT NULL,
country TEXT NOT NULL,
venue_type TEXT NOT NULL DEFAULT 'indoor',
ownership TEXT NOT NULL DEFAULT 'rent',
court_config TEXT NOT NULL,
state_json TEXT NOT NULL,
calc_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
""")
conn.execute("""
INSERT INTO published_scenarios_new
(id, slug, title, subtitle, location, country,
venue_type, ownership, court_config, state_json, calc_json,
created_at, updated_at)
SELECT id, slug, title, subtitle, location, country,
venue_type, ownership, court_config, state_json, calc_json,
created_at, updated_at
FROM published_scenarios
""")
conn.execute("DROP TABLE published_scenarios")
conn.execute("ALTER TABLE published_scenarios_new RENAME TO published_scenarios")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pub_scenarios_slug"
" ON published_scenarios(slug)"
)
# ── 4. Recreate articles without template_data_id, add SSG columns ─
conn.execute("""
CREATE TABLE articles_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_path TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
meta_description TEXT,
country TEXT,
region TEXT,
og_image_url TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TEXT,
template_slug TEXT,
language TEXT NOT NULL DEFAULT 'en',
date_modified TEXT,
seo_head TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
""")
conn.execute("""
INSERT INTO articles_new
(id, url_path, slug, title, meta_description, country, region,
og_image_url, status, published_at, created_at, updated_at)
SELECT id, url_path, slug, title, meta_description, country, region,
og_image_url, status, published_at, created_at, updated_at
FROM articles
""")
conn.execute("DROP TABLE articles")
conn.execute("ALTER TABLE articles_new RENAME TO articles")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_articles_status"
" ON articles(status, published_at)"
)
# ── 5. Recreate articles FTS + triggers ────────────────────────────
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title, meta_description, country, region,
content='articles', content_rowid='id'
)
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")