Files
padelnomics/docs/CMS.md
Deeman 4ae00b35d1 refactor: flatten padelnomics/padelnomics/ → repo root
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>
2026-02-22 00:44:40 +01:00

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:

  1. Define a template — URL pattern, title pattern, meta description pattern, and a Jinja2 body
  2. Upload data rows (CSV or single entry via admin UI) that fill the template variables
  3. Generate — the system runs the financial calculator, writes HTML to disk, and inserts articles rows
  4. 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:

  1. Merge row data with calculator DEFAULTS
  2. Run financial calculator → produce state_json + calc_json
  3. Create a published_scenarios record with an auto-derived slug
  4. Render Jinja2 patterns (URL, title, meta description, body) using row data + scenario slug
  5. Convert body Markdown → HTML via mistune
  6. Replace [scenario:slug(:section)?] markers with rendered HTML partials
  7. Write final HTML to data/content/_build/{slug}.html
  8. Back up Markdown source to data/content/articles/{slug}.md
  9. Create an articles record
  10. Update template_data row with scenario_id and article_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 country and region
  • Full-text search via FTS5 (articles_fts) across title, meta_description, country, region
  • HTMX partial at /markets/results handles 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 berlinindoor-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 name fields in input_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:

  1. Merges calculator fields from the row into DEFAULTS, validates and runs calc(state)
  2. Creates a published_scenarios row
  3. Injects scenario_slug into the render context
  4. Renders URL, title, meta, and body via Jinja2
  5. Converts body Markdown → HTML, replaces [scenario:…] markers with rendered cards
  6. Writes data/content/_build/{slug}.html
  7. Inserts the articles row (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:

  1. Admin → Articles → (article) → Rebuild
  2. The system re-renders the body template with the current data, regenerates the scenario card HTML, and overwrites the _build/{slug}.html file
  3. The articles row 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_at in 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 articles without going through the admin, run INSERT 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.