# CMS & Programmatic SEO
How the content management system works: git-based template definitions, DuckDB data sources, bulk article generation, scenario embedding, SEO pipeline, and how content is served.
---
## Overview
The CMS is an **SSG-inspired programmatic content system**. The core idea:
1. **Templates live in git** — `.md.jinja` files with YAML frontmatter define how articles look
2. **Data lives in DuckDB** — one serving table per pSEO idea provides what articles say
3. **Generation** renders templates with data, bakes in SEO metadata, writes HTML to disk
4. **SQLite** stores routing state only — `articles` and `published_scenarios` tables
5. Articles are **served** by a catch-all route reading pre-built HTML from disk
```
Git repo (content shape) DuckDB (content data) SQLite (routing/state)
──────────────────────── ───────────────────── ──────────────────────
content/templates/ serving.pseo_city_costs_de articles
city-cost-de.md.jinja → city_slug, population, → url_path, slug
market-compare.md.jinja venue_count, rates... → status, published_at
→ template_slug
serving.pseo_market_compare
"How articles look" "What articles say" published_scenarios
→ slug, state_json
→ calc_json
"Where things live"
```
---
## Template file format
Templates are `.md.jinja` files in `web/src/padelnomics/content/templates/`. Each file has YAML frontmatter delimited by `---`, followed by a Jinja2 Markdown body.
### Example
```markdown
---
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 }} has {{ padel_venue_count }} padel venues...
[scenario:{{ scenario_slug }}:capex]
## FAQ
**How much does a padel court cost in {{ city_name }}?**
Based on our financial model, a {{ courts_typical }}-court center requires...
```
### Frontmatter fields
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Human-readable template name (shown in admin) |
| `slug` | Yes | URL-safe identifier, also the filename stem |
| `content_type` | Yes | `calculator` (has financial scenario) or `editorial` |
| `data_table` | Yes | DuckDB serving table, schema-qualified (e.g. `serving.pseo_city_costs_de`) |
| `natural_key` | Yes | Column used as unique row identifier (e.g. `city_slug`) |
| `languages` | Yes | List of language codes to generate (e.g. `[de, en]`) |
| `url_pattern` | Yes | Jinja2 pattern for article URL path |
| `title_pattern` | Yes | Jinja2 pattern for `
` and `og:title` |
| `meta_description_pattern` | Yes | Jinja2 pattern for meta description (aim for 120-155 chars) |
| `schema_type` | No | JSON-LD type(s): `Article` (default), `FAQPage`, or list. See [Structured data](#structured-data) |
| `priority_column` | No | Column to sort by for publish order (highest value first) |
| `related_template_slugs` | No | Other template slugs for cross-linking (future) |
### Body template
Everything after the closing `---` is the body. It's Jinja2 Markdown with access to:
- **All columns** from the DuckDB row (e.g. `{{ city_name }}`, `{{ population }}`)
- **`language`** — current language code (`en`, `de`)
- **`scenario_slug`** — auto-derived slug for `[scenario:]` markers (calculator type only)
The `slugify` filter is available: `{{ country_name_en | slugify }}`.
---
## Database schema
After migration 0018, only two content tables remain in SQLite:
### articles
| Column | Type | Notes |
|--------|------|-------|
| `url_path` | TEXT UNIQUE | e.g. `/en/markets/germany/berlin` |
| `slug` | TEXT UNIQUE | e.g. `city-cost-de-en-berlin` |
| `title` | TEXT | Rendered from `title_pattern` |
| `meta_description` | TEXT | Rendered from `meta_description_pattern` |
| `country` | TEXT | For markets hub grouping |
| `region` | TEXT | For markets hub grouping |
| `status` | TEXT | `'published'` or `'draft'` |
| `published_at` | DATETIME | Future dates suppress the article |
| `template_slug` | TEXT | Links back to git template for regeneration |
| `language` | TEXT | Language code (`en`, `de`) for hreflang |
| `date_modified` | TEXT | ISO timestamp, updated on regeneration |
| `seo_head` | TEXT | Pre-computed HTML: canonical, hreflang, JSON-LD, OG tags |
FTS5 virtual table `articles_fts` syncs `title`, `meta_description`, `country`, `region` via triggers.
### published_scenarios
| Column | Type | Notes |
|--------|------|-------|
| `slug` | TEXT UNIQUE | Referenced in `[scenario:slug]` markers |
| `title` | TEXT | City name |
| `location` | TEXT | City name |
| `country` | TEXT | |
| `venue_type` | TEXT | `indoor` / `outdoor` |
| `ownership` | TEXT | `rent` / `own` |
| `court_config` | TEXT | e.g. `4 double + 2 single` |
| `state_json` | JSON | Calculator input state |
| `calc_json` | JSON | Calculator output (all sections) |
---
## Generation pipeline
### Full flow
```
1. Create serving model in SQLMesh
models/serving/pseo_city_costs_de.sql → sqlmesh plan prod → analytics.duckdb
2. Create template file in git
content/templates/city-cost-de.md.jinja
3. Preview locally
Admin → Templates → pick template → pick city → preview rendered article
4. Commit + push + deploy
5. Admin → Templates → "DE City Padel Costs"
sees data_table, row count from DuckDB, generated count
6. Click "Generate"
start_date, articles_per_day → for each DuckDB row × language:
• render URL, title, meta patterns
• create/update published_scenario (calculator type)
• render body.md.jinja → Markdown → HTML
• bake [scenario:] markers into HTML
• inject SEO head (canonical, hreflang, JSON-LD, OG)
• write HTML to data/content/_build/{lang}/{slug}.html
• upsert article row in SQLite
• stagger published_at dates
7. Articles live at /en/markets/germany/berlin
with links to /planner?scenario=city-cost-de-berlin
8. Data refresh → "Regenerate" in admin
reads fresh DuckDB data, updates HTML + scenarios in-place
```
### What happens per row
For each DuckDB row × each language in `config.languages`:
1. Render `url_pattern`, `title_pattern`, `meta_description_pattern` with row data
2. If `content_type == "calculator"`: extract calc fields → `validate_state()` → `calc()` → upsert `published_scenarios`
3. Render body template with row data + `scenario_slug`
4. Convert Markdown → HTML via `mistune`
5. Replace `[scenario:slug:section]` markers with rendered HTML cards
6. Build SEO head: canonical, hreflang links, JSON-LD, OG tags
7. Write HTML to `data/content/_build/{lang}/{article_slug}.html`
8. Upsert `articles` row in SQLite
9. Stagger `published_at` based on `articles_per_day`
### Scenario markers
Use `[scenario:slug]` or `[scenario:slug:section]` in the body template:
| Section | Shows |
|---------|-------|
| _(none)_ | Default summary card with CTA link to planner |
| `capex` | Capital expenditure breakdown |
| `operating` | Operating cost breakdown |
| `cashflow` | Annual cash flow projection |
| `returns` | ROI / returns summary |
| `full` | All sections combined |
Always use `{{ scenario_slug }}` — never hardcode a slug.
### Scenario slug derivation
```
scenario_slug = template_slug + "-" + natural_key_value
```
Example: template `city-cost-de` + row `city_slug=berlin` → `city-cost-de-berlin`
### Article slug derivation
```
article_slug = template_slug + "-" + language + "-" + natural_key_value
```
Example: `city-cost-de-en-berlin`, `city-cost-de-de-berlin`
---
## SEO pipeline
All SEO metadata is baked into the `seo_head` column at generation time. No runtime computation.
### URL structure
```
/{lang}/markets/{country_name}/{city_slug}
```
- Language prefix for hreflang (`/de/...`, `/en/...`)
- Full country name in path (`/germany/`, not `/de/`) — avoids confusion with lang prefix
- `slugify` filter converts to lowercase hyphenated form
### Hreflang
For each article, links to all language variants + `x-default` (points to English):
```html
```
### Canonical URLs
Self-referencing canonical on every article:
```html
```
### Structured data
JSON-LD `