# 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: ```jinja2 /markets/{{ country | lower }}/{{ city_slug }} ``` **Title and meta description patterns** are also Jinja2: ```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: ```markdown ## {{ 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: ```python @bp.route("/") async def article_page(url_path: str): ... ``` Reserved prefixes are short-circuited to avoid conflicts: ```python RESERVED_PREFIXES = ( "/admin", "/auth", "/planner", "/billing", "/dashboard", "/directory", "/leads", "/suppliers", "/health", "/sitemap", "/static", "/markets", "/features", "/feedback", ) ``` Lookup query: ```sql 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: ```html {{ article.title }} | Padelnomics ``` **JSON-LD structured data** is also injected per article: ```json { "@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): ```sql 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. ```json [ { "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: ```jinja2 /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:** ```jinja2 {{ city }} Padel Court Business Plan — {{ court_count }}-Court {{ venue_type | title }} Venue ``` **Meta description pattern** (aim for ≤ 160 characters): ```jinja2 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: ```markdown ## {{ 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 `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: ```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.