git mv all tracked files from the nested padelnomics/ workspace directory to the git repo root. Merged .gitignore files. No code changes — pure path rename. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
16 KiB
CMS & Programmatic SEO
How the content management system works: template definitions, bulk article generation, scenario embedding, the build pipeline, and how content is discovered and served.
Overview
The CMS is a template-driven, programmatic content system. The core idea:
- Define a template — URL pattern, title pattern, meta description pattern, and a Jinja2 body
- Upload data rows (CSV or single entry via admin UI) that fill the template variables
- Generate — the system runs the financial calculator, writes HTML to disk, and inserts
articlesrows - Articles are served by a catch-all route that looks up the URL path in the database
This is designed for scale: one template definition can produce hundreds of SEO-targeted articles.
Database schema
articles — Published articles (the SEO output)
article_templates — Template definitions (patterns + Jinja2 body)
template_data — One row per article to be generated; links template → article
published_scenarios — Calculator results embedded in article bodies
articles_fts — FTS5 virtual table for full-text search over articles
articles
| Column | Type | Notes |
|---|---|---|
id |
INTEGER PK | |
url_path |
TEXT UNIQUE | Matches request.path, e.g. /markets/de/berlin |
slug |
TEXT UNIQUE | Used for the build HTML filename |
title |
TEXT | |
meta_description |
TEXT | |
country |
TEXT | Used for grouping in the markets hub |
region |
TEXT | Used for grouping in the markets hub |
og_image_url |
TEXT | |
status |
TEXT | 'published' or 'draft' |
published_at |
DATETIME | Future dates suppress the article until then |
template_data_id |
INTEGER FK | Back-reference to template_data.id |
FTS5 virtual table articles_fts is kept in sync with title, meta_description, country, region.
article_templates
| Column | Type | Notes |
|---|---|---|
id |
INTEGER PK | |
name |
TEXT | Human label shown in admin |
slug |
TEXT UNIQUE | |
content_type |
TEXT | e.g. 'market' |
input_schema |
JSON | Array of field definitions; drives the admin form |
url_pattern |
TEXT | Jinja2, e.g. /markets/{{ country | lower }}/{{ city_slug }} |
title_pattern |
TEXT | Jinja2, e.g. {{ city }} Padel Court Economics |
meta_description_pattern |
TEXT | Jinja2 |
body_template |
TEXT | Jinja2 Markdown with optional [scenario:slug] markers |
template_data
| Column | Type | Notes |
|---|---|---|
id |
INTEGER PK | |
template_id |
INTEGER FK | → article_templates.id |
data_json |
JSON | The variables that fill the template |
scenario_id |
INTEGER FK | → published_scenarios.id (set after generation) |
article_id |
INTEGER FK | → articles.id (set after generation) |
created_at |
DATETIME | |
updated_at |
DATETIME |
published_scenarios
Pre-calculated financial scenarios embedded in articles.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER PK | |
slug |
TEXT UNIQUE | Referenced in [scenario:slug] markers |
title |
TEXT | |
subtitle |
TEXT | |
location |
TEXT | City name |
country |
TEXT | |
venue_type |
TEXT | |
ownership |
TEXT | |
court_config |
TEXT | |
state_json |
JSON | Calculator input state |
calc_json |
JSON | Calculator output (all sections) |
template_data_id |
INTEGER FK |
Programmatic SEO pipeline
Step 1 — Define a template
In admin → Templates → New Template.
URL pattern uses Jinja2 and must produce a unique, clean path:
/markets/{{ country | lower }}/{{ city_slug }}
Title and meta description patterns are also Jinja2:
{{ city }} Padel Court Business Plan — {{ court_count }}-Court Indoor Venue
Annual revenue and ROI analysis for a {{ court_count }}-court padel venue in {{ city }}, {{ country }}.
Body template is Jinja2 Markdown. Access any field from data_json, plus the generated scenario's slug via scenario_slug. Use [scenario:slug] markers to embed calculator sections:
## {{ city }} Market Overview
A {{ court_count }}-court indoor venue in {{ city }} has the following economics:
[scenario:{{ scenario_slug }}]
### Capital Expenditure
[scenario:{{ scenario_slug }}:capex]
### Annual Cash Flow
[scenario:{{ scenario_slug }}:cashflow]
Scenario section options for the [scenario:slug:section] syntax:
| Section | Shows |
|---|---|
| (none) | Default summary card |
capex |
Capital expenditure breakdown |
operating |
Operating cost breakdown |
cashflow |
Annual cash flow |
returns |
ROI / returns summary |
full |
All sections |
Step 2 — Upload data
Admin → Templates → (template) → Data → Upload CSV, or add single rows via the form.
The CSV columns must match the template's input_schema. Each row becomes one template_data record and will generate one article.
Step 3 — Generate
Admin → Templates → (template) → Generate.
What happens for each data row:
- Merge row data with calculator DEFAULTS
- Run financial calculator → produce
state_json+calc_json - Create a
published_scenariosrecord with an auto-derived slug - Render Jinja2 patterns (URL, title, meta description, body) using row data + scenario slug
- Convert body Markdown → HTML via
mistune - Replace
[scenario:slug(:section)?]markers with rendered HTML partials - Write final HTML to
data/content/_build/{slug}.html - Back up Markdown source to
data/content/articles/{slug}.md - Create an
articlesrecord - Update
template_datarow withscenario_idandarticle_id
Staggered publishing: the articles_per_day parameter spreads published_at dates so articles drip out over time rather than all appearing at once.
Step 4 — Serve
The content blueprint registers a catch-all route last, so it only fires if no other blueprint matched:
@bp.route("/<path:url_path>")
async def article_page(url_path: str):
...
Reserved prefixes are short-circuited to avoid conflicts:
RESERVED_PREFIXES = (
"/admin", "/auth", "/planner", "/billing", "/dashboard",
"/directory", "/leads", "/suppliers", "/health",
"/sitemap", "/static", "/markets", "/features", "/feedback",
)
Lookup query:
SELECT * FROM articles
WHERE url_path = ? AND status = 'published' AND published_at <= datetime('now')
If found, the pre-built HTML is read from data/content/_build/{slug}.html and injected into article_detail.html. No runtime Markdown rendering.
SEO meta tags
article_detail.html overrides the base template's defaults with article-specific values:
<title>{{ article.title }} | Padelnomics</title>
<meta name="description" content="{{ article.meta_description }}">
<link rel="canonical" href="{{ config.BASE_URL }}{{ request.path }}">
<meta property="og:title" content="{{ article.title }}">
<meta property="og:description" content="{{ article.meta_description }}">
<meta property="og:image" content="{{ article.og_image_url }}">
<meta property="og:url" content="{{ config.BASE_URL }}{{ request.path }}">
JSON-LD structured data is also injected per article:
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "...",
"datePublished": "2025-01-15",
"author": { "@type": "Organization", "name": "Padelnomics" },
"publisher": { "@type": "Organization", "name": "Padelnomics" }
}
Markets hub
/markets is the discovery index for all published articles.
- Groups articles by
countryandregion - Full-text search via FTS5 (
articles_fts) acrosstitle,meta_description,country,region - HTMX partial at
/markets/resultshandles live filtering (no page reload)
FTS5 query (simplified):
SELECT a.* FROM articles a
JOIN articles_fts ON articles_fts.rowid = a.id
WHERE articles_fts MATCH ?
AND a.status = 'published'
AND a.published_at <= datetime('now')
ORDER BY a.published_at DESC
LIMIT 100
Filter fields: q (full-text), country, region.
Directory structure
src/padelnomics/
├── content/
│ ├── routes.py # Catch-all article serving + markets hub
│ └── templates/
│ ├── article_detail.html # SEO meta tags, JSON-LD, article body injection
│ ├── markets.html # Discovery index
│ └── partials/
│ ├── market_results.html # HTMX search result rows
│ └── scenario_*.html # Scenario cards (capex, cashflow, etc.)
│
├── admin/
│ ├── routes.py # Full CMS admin (templates, data, generate, articles)
│ └── templates/admin/
│ ├── templates.html
│ ├── template_form.html
│ ├── template_data.html
│ ├── generate_form.html
│ ├── articles.html
│ └── article_form.html
│
└── data/
└── content/
├── _build/ # Generated HTML (served at runtime)
│ └── {slug}.html
└── articles/ # Markdown source backup
└── {slug}.md
Adding a new content type
Step 1 — Define the template in admin
Go to Admin → Templates → New Template and fill in:
| Field | Notes |
|---|---|
| Name | Human label, e.g. "Indoor Market Report" |
| Slug | Auto-generated; used to derive scenario slugs (cannot be changed after creation) |
| Content type | "calculator" (the only current option) |
| Input schema | JSON — see below |
| URL pattern | Jinja2 — see below |
| Title pattern | Jinja2 |
| Meta description pattern | Jinja2 (optional) |
| Body template | Jinja2 Markdown |
Step 2 — Write the input_schema
input_schema is a JSON array of field definitions. Each field becomes a column in the CSV and an input in the single-row add form.
[
{ "name": "city", "label": "City", "field_type": "text", "required": true },
{ "name": "city_slug", "label": "City slug", "field_type": "text", "required": true },
{ "name": "country", "label": "Country (ISO 2)", "field_type": "text", "required": true },
{ "name": "region", "label": "Region", "field_type": "text", "required": false },
{ "name": "court_count", "label": "Number of courts", "field_type": "number", "required": false },
{ "name": "ratePeak", "label": "Peak rate (€/hr)", "field_type": "float", "required": false }
]
Field keys:
| Key | Required | Values | Effect |
|---|---|---|---|
name |
yes | any string | Variable name in Jinja2 patterns; CSV column header |
label |
yes | any string | Human label in the admin form |
field_type |
yes | "text", "number", "float" |
Controls input type and coercion during generation |
required |
no | true / false |
Whether the admin form enforces a value |
city_slug is special — the system looks for a field named exactly city_slug to derive the scenario slug. If it's absent, the template_data row ID is used as a fallback.
Calculator field names — any field whose name matches a key in the calculator DEFAULTS dict (e.g. ratePeak, rateOffPeak, dblCourts, loanPct) is automatically passed to the financial model during generation. You don't need to do anything extra; just name the field correctly and the generator merges it into the calc state.
Step 3 — Write the Jinja2 patterns
All patterns are rendered with the same context: every field from the data row, plus scenario_slug (injected by the generator).
URL pattern — must be unique across all articles and must not start with a reserved prefix:
/markets/{{ country | lower }}/{{ city_slug }}
The article slug is derived by stripping the leading / and replacing remaining / with -, so /markets/de/berlin → slug markets-de-berlin.
Title pattern:
{{ city }} Padel Court Business Plan — {{ court_count }}-Court {{ venue_type | title }} Venue
Meta description pattern (aim for ≤ 160 characters):
Revenue and ROI analysis for a {{ court_count }}-court padel venue in {{ city }}, {{ country }}. Includes CapEx, operating costs, and cash flow projections.
Body template — Jinja2 Markdown. Use any data row field, plus scenario_slug to embed calculator results:
## {{ city }} Market Overview
A {{ court_count }}-court indoor venue in {{ city }} ({{ country }}) projects the following economics:
[scenario:{{ scenario_slug }}]
### Capital Expenditure
[scenario:{{ scenario_slug }}:capex]
### Annual Cash Flow
[scenario:{{ scenario_slug }}:cashflow]
### Returns
[scenario:{{ scenario_slug }}:returns]
Step 4 — How scenario_slug is derived
scenario_slug = template_slug + "-" + city_slug
Example: template slug indoor-market + city_slug berlin → indoor-market-berlin.
This slug identifies the published_scenarios row. It's what you reference in [scenario:slug] markers. Always use {{ scenario_slug }} in the body template — never hardcode a slug.
Step 5 — Upload data
CSV upload (Admin → Templates → (template) → Data → Upload CSV):
- Column headers must exactly match the
namefields ininput_schema - One row per article to be generated
- Values are coerced to the declared
field_type(text→ string,number→ int,float→ float)
Example CSV:
city,city_slug,country,region,court_count,ratePeak,rateOffPeak
Berlin,berlin,DE,Brandenburg,6,55,35
Munich,munich,DE,Bavaria,8,60,40
Hamburg,hamburg,DE,Hamburg,4,50,30
Single-row add — use the form in Admin → Templates → (template) → Data → Add Row for one-off entries.
Step 6 — Generate
Admin → Templates → (template) → Generate. Set:
- Start date — first article's
published_at - Articles per day — how many articles share each calendar day (stagger cadence)
The generator processes all data rows that don't yet have an article_id (i.e. not previously generated). For each row it:
- Merges calculator fields from the row into
DEFAULTS, validates and runscalc(state) - Creates a
published_scenariosrow - Injects
scenario_sluginto the render context - Renders URL, title, meta, and body via Jinja2
- Converts body Markdown → HTML, replaces
[scenario:…]markers with rendered cards - Writes
data/content/_build/{slug}.html - Inserts the
articlesrow (status'draft'until publish date passes)
Step 7 — Review and publish
Articles with a published_at in the future are invisible to the catch-all route. Check a few via Admin → Articles → (article) → Preview before the first date arrives. If anything looks wrong, fix the template and use Rebuild (see below) — no need to delete and re-generate.
Rebuilding an article
If you edit a template body or fix an error in a data row:
- Admin → Articles → (article) → Rebuild
- The system re-renders the body template with the current data, regenerates the scenario card HTML, and overwrites the
_build/{slug}.htmlfile - The
articlesrow metadata (title, meta description, URL) is also updated
Gotchas
- Reserved prefixes — if a new blueprint is added at a path that matches a content article's URL, the blueprint wins. Keep all content under
/markets/or add a new reserved prefix. - Slug uniqueness — article slugs drive the build filenames. The slug is derived from the URL path; collisions are caught as a DB UNIQUE constraint violation during generation.
- Future publish dates — articles with
published_atin the future are invisible to the catch-all route and the markets hub. They still exist in the DB and can be previewed via admin. - FTS5 sync — the FTS5 table is populated by triggers. If you manually insert into
articleswithout going through the admin, runINSERT INTO articles_fts(articles_fts) VALUES('rebuild')to resync. - Scenario slugs in templates — use
{{ scenario_slug }}(injected at generation time) rather than hardcoding a slug in the body template. Hardcoded slugs break if you regenerate with different data.