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>
This commit is contained in:
445
docs/CMS.md
Normal file
445
docs/CMS.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# 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("/<path:url_path>")
|
||||
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
|
||||
<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:
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user