---
title: "feat: No-code CMS for blog/news/events (EN/MK/SI)"
type: feat
date: 2026-08-08
depth: deep
status: ready
---

# feat: No-code CMS for blog / news / events (EN/MK/SI)

**Target repo:** `msosorg.com` (Gitea `mark/msos`, work branch `kristijan-dev`, PRs target `develop`).

## Summary

Give several **non-technical volunteers** a web dashboard to create and edit **blog posts, news items, and event/project pages** in **English, Macedonian and Slovenian**, and publish them through an **approval step** — with no HTML, git, or local tooling.

The site already has most of the machinery: `tools/build_blog.py` renders Markdown into article pages, `tools/build_listings.py` regenerates the News hub + homepage from detail pages, `tools/seo_inject.py` injects SEO and owns the central date registry, and Gitea Actions already deploys `develop` → production via rsync. So this is **not** a WordPress migration or a new static-site-generator. It is three changes layered on the existing pipeline:

1. Introduce a **structured content source** (Markdown + front-matter, one file per entry per language) as the new source of truth for blog/news/events.
2. **Extend the existing Python build** to render those sources into the detail pages the site already serves, then run the existing listings/SEO/search-index steps.
3. Put a **Git-based CMS (Sveltia, Decap-compatible)** with an **editorial (review) workflow** on top of those source files, and move the full build+deploy into CI so a merge publishes automatically.

The rest of the ~198-page site is untouched and continues to build as it does today.

---

## Problem Frame

Content changes (new blog posts, news, events) currently require someone technical to hand-author or generate HTML, run the `tools/` scripts locally, commit, and push. That bottlenecks a volunteer organisation on the one or two people who can edit code, and risks the detail pages and the auto-generated listings drifting out of sync.

The organisation wants several non-technical volunteers editing the frequently-changing sections directly, safely, in all three languages — while preserving what makes the current site good (fast static hosting, near-zero cost, the hand-tuned SEO/hreflang/JSON-LD, the "for bots" pages, and the offline "Ask MSOS" search index).

**Constraints that shape the plan:**
- Volunteer-run non-profit → **low/zero recurring cost** and **low maintenance** are hard requirements.
- Repo is **self-hosted Gitea** (`git.spletnimojster.si`, owned by Mark) → the CMS auth story needs deliberate handling (see U6 / KTD-5).
- Trilingual **EN/MK/SI**, with MK/SI often needing human translation after the EN draft.
- Production deploy is already **push-to-`develop` → rsync** (`.gitea/workflows/deploy.yml`).

---

## Requirements

- **R1** — Non-technical editors can create and edit blog / news / event entries through a browser UI, with no HTML or git knowledge.
- **R2** — Each entry supports all three languages (EN/MK/SI): title, subtitle, category, date, hero image + caption, body.
- **R3** — Nothing goes live without an **approval step**: an editor drafts; a designated admin reviews and approves; then it publishes.
- **R4** — Publishing an entry automatically updates the **detail page, the News hub, the homepage "Latest news" block, and the bot-links index** — no manual script runs.
- **R5** — The generated pages are **equivalent to today's** in structure, SEO markers, header/footer, layout, and the offline search index (no SEO or accessibility regression).
- **R6** — Editors can **upload images** through the UI; images are optimised into the site's existing format.
- **R7** — The whole thing runs at **no meaningful recurring cost** and content stays **versioned in the repo**.
- **R8** — The site continues to deploy the same way (static files rsynced to the production server); non-content pages are unaffected.

---

## Key Technical Decisions

**KTD-1 — Extend the existing Python build; do NOT adopt a new SSG (Eleventy/Astro/Hugo).**
`build_blog.py` already does Markdown→article HTML by cloning a same-language page for layout, and `build_listings.py`/`seo_inject.py` already own listings + SEO + dates. Introducing a new generator would mean re-expressing 198 hand-tuned pages and the SEO logic in a new template system — high risk, no benefit. We refactor the Python we have. *(Alternatives Considered records the SSG option.)*

**KTD-2 — Content source = Markdown + YAML front-matter, one file per entry per language, under a new `content/` tree.**
Front-matter carries the structured fields (title, subtitle, category, date, hero image, caption, cover, tags); the body is Markdown. This is what `build_blog.py` already parses, and it is exactly the shape Git CMSes model as "collections with i18n." The detail HTML pages become **build output**, not source.

**KTD-3 — The central date registry and listings read from front-matter, not from HTML.**
Today `seo_inject.ARTICLE_DATES` is a hand-maintained registry and `build_listings.py` scrapes detail pages. Both shift to derive dates/metadata from the content front-matter so a single edit in the CMS is the source of truth (satisfies R4 without a second manual step).

**KTD-4 — CMS = Sveltia CMS (config-compatible with Decap CMS), with `editorial_workflow` enabled.**
Sveltia is an actively maintained, drop-in Decap replacement with markedly better editing UX and first-class i18n. Its editorial workflow turns each entry into a branch/PR with Draft → In Review → Ready states — which *is* the approval step (R3) and the review gate the user chose. Decap remains a fallback since the config format is shared.

**KTD-5 — Resolve CMS auth via a GitHub content remote; keep Gitea for code. (Coordination with Mark required.)**
Sveltia/Decap authenticate against GitHub/GitLab, **not** self-hosted Gitea. Recommended wiring: add **GitHub as the content remote**, editors log in via a small GitHub OAuth relay (or GitHub App), CMS commits create PRs there, and a **GitHub Action** runs the full build + rsync to the existing production server (reusing the current deploy secrets). Gitea stays the developers' remote; the two repos are kept in sync by mirroring. Final sync topology (mirror direction, which host runs the deploy) is an **infra decision to make with Mark** — see Open Questions Q1. *(Self-hosted OAuth-against-Gitea is in Alternatives.)*

**KTD-6 — Characterization-first for the build refactor.**
Before changing `build_blog.py`/`build_listings.py`, capture the current generated output of representative pages as golden fixtures, so the refactor can be proven to produce equivalent HTML (R5). This is the safest posture for legacy build code that feeds a live, SEO-sensitive site.

---

## High-Level Technical Design

Editorial + publish flow (the "review step" is the PR gate):

```mermaid
flowchart TD
    E[Volunteer editor] -->|writes EN, uploads image| CMS[Sveltia CMS at /admin]
    T[Translator] -->|fills MK / SI| CMS
    CMS -->|editorial_workflow: Draft -> In Review| PR[Branch + Pull Request on GitHub]
    A[Admin / reviewer] -->|approve + merge| DEV[develop branch]
    DEV --> CI[CI: full build]
    subgraph CI [CI pipeline on merge]
        direction TB
        B1[optimize_images.py] --> B2[build content pages<br/>from content/ front-matter]
        B2 --> B3[build_listings.py<br/>News hub + homepage]
        B3 --> B4[seo_inject.py --all --sitemap]
        B4 --> B5[build_botlinks.py]
        B5 --> B6[build_search_index.py]
    end
    CI -->|rsync static files| PROD[(Production server)]
```

Source-of-truth shift (what changes conceptually):

```mermaid
flowchart LR
    subgraph Today
      H[Hand-authored detail HTML] -->|scraped by| L1[build_listings + ARTICLE_DATES]
    end
    subgraph Target
      MD[content/*.md + front-matter<br/>SOURCE OF TRUTH] --> GEN[content builder]
      GEN --> DH[detail HTML - build output]
      MD --> L2[listings + dates read front-matter]
    end
```

---

## Output Structure

New content source tree (source of truth; detail HTML under `en|mk|si/**` becomes build output):

```text
content/
  blog/
    <slug>/
      en.md          # front-matter (title, subtitle, category, date, hero, caption, cover) + body
      mk.md
      si.md
  news/
    <slug>/
      en.md
      mk.md
      si.md
  events/            # maps to the existing <lang>/projects/ output
    <slug>/
      en.md
      mk.md
      si.md
static/admin/
  index.html         # Sveltia/Decap entry point
  config.yml         # collections, i18n (en/mk/si), media, editorial_workflow
```

Media uploads land in the existing `images/` tree (or an `images/uploads/` subfolder) so `optimize_images.py` and current references keep working.

---

## Implementation Units

Grouped into three phases. Units are dependency-ordered; U-IDs are stable.

### Phase 1 — Content model & build refactor

### U1. Define the content model and migrate existing entries to source files
**Goal:** Establish `content/` as the source of truth and back-fill it from the current blog/news/event detail pages so nothing is lost.
**Requirements:** R2, R4, R5.
**Dependencies:** none.
**Files:**
- `content/blog/**/{en,mk,si}.md`, `content/news/**/…`, `content/events/**/…` (new)
- `docs/content-model.md` (new — the front-matter field spec)
- `tools/migrate_pages_to_content.py` (new — one-shot extractor from existing detail HTML)
- `tools/tests/test_migrate_roundtrip.py` (new)
**Approach:** Define the front-matter schema once (title, subtitle, category, date, hero image, hero caption, cover, tags, slug). Write a one-shot extractor that reads each existing detail page (which `build_listings.py` already parses, so the selectors are known) and emits the per-language Markdown. Migration is a scaffold that a human proofs, not a guaranteed lossless transform — flag any page the extractor can't parse cleanly.
**Patterns to follow:** the parsing selectors in `tools/build_listings.py`; the Markdown conventions `tools/build_blog.py` already expects (metadata lines, `## headings`, hero section).
**Test scenarios:**
- Happy path: a known blog post extracts to `en.md` with title/subtitle/category/date/hero all populated.
- Edge: a page with no subtitle, or with multiple images, still produces valid front-matter (empty optional fields, images preserved in body).
- Edge: MK/SI page with Cyrillic/diacritics round-trips as UTF-8 without mojibake.
- Round-trip: `content` → (U2 builder) → HTML diffs only cosmetically against the original for 3 sample pages per type.
**Verification:** every current blog/news/event detail page has a corresponding `content/` entry; a reviewer can read one and recognise the page.

### U2. Build detail pages from content sources
**Goal:** Turn `content/*.md` into the same detail pages the site serves today.
**Requirements:** R4, R5.
**Dependencies:** U1.
**Files:**
- `tools/build_content.py` (new or refactor of `tools/build_blog.py` generalised to blog/news/events)
- `tools/tests/fixtures/**` (new — golden HTML captured from current pages)
- `tools/tests/test_build_content.py` (new)
**Approach:** Generalise `build_blog.py`'s "clone a same-language page for layout, swap in title/header/hero/body/scrollspy" approach to read from front-matter + Markdown and to cover all three content types. Keep the existing `<!-- SEO:START/END -->`, header/footer markers, and script includes intact so downstream steps and layout parity hold.
**Execution note:** Characterization-first — capture golden fixtures from current pages before refactoring (KTD-6), then make the builder reproduce them.
**Patterns to follow:** `tools/build_blog.py` (`parse_markdown`, `preprocess`, page-cloning); the header/footer marker contract in `update_html.py`.
**Test scenarios:**
- Covers R5. Golden: generated blog page is HTML-equivalent (ignoring insignificant whitespace) to the committed page for 3 samples per type.
- Happy path: a new `content/blog/<slug>/en.md` produces a valid detail page at the expected output path.
- Edge: missing MK/SI file → page is skipped for that language (not a broken half-page); logged.
- Edge: body with links, bold/italic, and `##` sections produces the scrollspy sidebar identically to today.
- Error: malformed front-matter fails the build loudly with the offending file named (never emits a half-page).
**Verification:** running the builder over migrated content regenerates the current pages with no meaningful diff.

### U3. Drive listings, dates, and bot-links from front-matter
**Goal:** Make the News hub, homepage "Latest news", the date registry, and bot-links regenerate from content sources so one CMS edit is enough (no second manual step).
**Requirements:** R4, R5.
**Dependencies:** U1, U2.
**Files:**
- `tools/build_listings.py` (modify — read `content/` front-matter instead of scraping detail HTML)
- `tools/seo_inject.py` (modify — derive `ARTICLE_DATES` from front-matter)
- `tools/build_botlinks.py` (modify if it scrapes pages)
- `tools/tests/test_listings_from_content.py` (new)
**Approach:** Point the "single source of truth" that `build_listings.py` documents at `content/` metadata. Replace the hand-maintained `ARTICLE_DATES` with a loader over front-matter dates (keep a thin compatibility shim if other scripts import it).
**Execution note:** Characterization-first against current `news/index.html` and homepage AUTO blocks.
**Patterns to follow:** existing `<!-- AUTO-NEWS:START/END -->` and `<!-- AUTO-LATEST-NEWS:START/END -->` block regeneration in `build_listings.py`.
**Test scenarios:**
- Covers R4. Adding a `content/` entry with a given date makes it appear in the News hub and homepage top-3 in correct date order.
- Edge: two entries with the same date sort deterministically (stable tiebreak).
- Edge: an entry dated in the future is excluded from "Latest" (or handled per current behaviour — match today).
- Golden: regenerated News hub matches the committed one for the current content set.
**Verification:** News hub + homepage + bot-links reproduce today's output from `content/` alone.

### U4. One-command full build + local preview
**Goal:** A single entry point that runs the whole pipeline and a way to preview locally, used by both humans and CI.
**Requirements:** R4, R5, R6.
**Dependencies:** U2, U3.
**Files:**
- `tools/build_site.py` (new — orchestrates: optimize_images → build_content → build_listings → seo_inject --all --sitemap → build_botlinks → build_search_index)
- `tools/README.md` (update)
- `docs/local-preview.md` (new — how to preview at `localhost:8000`)
**Approach:** Sequence the existing steps in dependency order behind one command; make each step idempotent and safe to re-run. No new web framework — a plain static preview server (as already used in this project) is enough.
**Test scenarios:**
- Idempotence: running the full build twice with no content change produces no git diff.
- Happy path: adding one content entry and running the build touches exactly the expected outputs (detail page, listings, sitemap, search index).
- Error: a failing sub-step aborts the whole build with a clear message (no partial deploy).
**Verification:** one command reproduces a clean, deployable tree; preview shows a new post end-to-end locally.

### Phase 2 — Pipeline & infrastructure

### U5. Move the full build into CI and gate deploy on the review merge
**Goal:** On merge to the deploy branch, CI runs the full build and rsyncs — so approving a PR publishes, with no local steps.
**Requirements:** R3, R4, R8.
**Dependencies:** U4.
**Files:**
- `.gitea/workflows/deploy.yml` and/or `.github/workflows/deploy.yml` (modify/new — depends on U6 host decision)
- `docs/deploy-pipeline.md` (new)
**Approach:** Extend the current deploy job (which today only rebuilds the search index) to run `tools/build_site.py` before the existing rsync. Install Python deps (`beautifulsoup4`, a YAML lib, a Markdown lib if added). Keep the rsync excludes (`.git`, `tools/`, `templates/`, `docs/`, `content/`, `*.py`, `*.sh`) so only built static assets ship. The deploy branch is the merge target of the editorial PRs (the approval gate).
**Test scenarios:**
- Test expectation: none for unit logic — verification is a CI dry-run. Validate the workflow builds a clean tree and that `content/`, `tools/`, and source `*.md` are excluded from rsync.
- A PR merge triggers exactly one build+deploy; a push to a feature branch does not deploy.
**Verification:** a test entry merged via PR appears on production within one CI run, listings included.

### U6. Stand up the CMS repo/auth path (GitHub content remote) — with Mark
**Goal:** Give editors a login and give the CMS a Git backend it supports, without abandoning Gitea.
**Requirements:** R1, R3, R7.
**Dependencies:** U5 (deploy host decision is shared).
**Files:**
- `docs/cms-infra.md` (new — the agreed topology, secrets, mirror direction)
- repo mirror / GitHub App / OAuth relay config (infra, documented not code-committed where secret)
**Approach:** Per KTD-5, add GitHub as the content remote, configure a GitHub OAuth relay or GitHub App for editor login, and decide with Mark: (a) mirror direction between Gitea and GitHub, and (b) which host runs the deploy Action (reusing the current `SSH_*`/`TARGET_DIR` secrets). Default recommendation: GitHub runs the content deploy; Gitea stays the code remote; mirror keeps them aligned.
**Execution note:** This unit is partly **infra coordination**, not code — capture the agreed topology in `docs/cms-infra.md` before wiring U7.
**Test scenarios:**
- Test expectation: none (infra). Verification is manual: a test editor account can authenticate and open `/admin`; a CMS commit lands as a PR on the intended host.
**Verification:** an invited non-admin can log in to `/admin` and see the collections; their save produces a reviewable PR, not a direct-to-production change.

### Phase 3 — CMS configuration & rollout

### U7. Configure Sveltia collections, i18n, media, and the review workflow
**Goal:** The actual editing experience: blog/news/events collections in EN/MK/SI, image upload, and Draft→Review→Publish.
**Requirements:** R1, R2, R3, R6.
**Dependencies:** U1 (schema), U6 (backend/auth).
**Files:**
- `static/admin/config.yml` (new — collections mirroring the U1 front-matter schema; `i18n` with locales en/mk/si; `media_folder` → `images/uploads`; `publish_mode: editorial_workflow`)
- `static/admin/index.html` (new — Sveltia loader)
**Approach:** Define one collection per content type, each field matching the front-matter schema exactly, with i18n so all three languages live in one entry. Media library points at the existing images tree. Editorial workflow on. Confirm the built `/admin` is served but excluded from search/SEO.
**Test scenarios:**
- Covers R2. Creating an entry with EN+MK+SI writes `content/<type>/<slug>/{en,mk,si}.md` with correct front-matter.
- Covers R3. Saving as draft creates a PR in "In Review"; it does not deploy until merged.
- Covers R6. Uploading an image stores it in the media folder and references it correctly in the body/front-matter.
- Edge: saving EN only leaves MK/SI entries absent → build (U2) skips those languages cleanly.
**Verification:** a full entry authored entirely in the UI flows through review to a live, correctly-linked, trilingual set of pages.

### U8. Editor guardrails, roles, and onboarding
**Goal:** Make it safe and learnable for several non-technical volunteers.
**Requirements:** R1, R3, R5, R6.
**Dependencies:** U7.
**Files:**
- `docs/editor-guide.md` (new — short, screenshot-light "how to publish a post" for volunteers, EN + MK/SI as needed)
- `docs/cms-roles.md` (new — who can review/merge vs draft)
**Approach:** Define roles (editors draft; a small admin set reviews/merges). Document the image guidance including the **© watermark** step for MSOS-owned event photos (see the gallery/watermark convention) and the reminder that reused third-party images need attribution (ties to the copyright clause just shipped). Note the MK/SI proofreading expectation as part of the review gate. Include a rollback note (revert the merge → next build restores).
**Test scenarios:**
- Test expectation: none (documentation/config). Verification is a dry-run onboarding.
**Verification:** a volunteer who has never touched the repo can publish a post end-to-end using only the guide, and an admin can reject/roll back a bad entry.

---

## Scope Boundaries

**In scope:** blog, news, and event/project entries; EN/MK/SI; image upload; review-before-publish; wiring the CMS into the existing build+deploy.

### Deferred to Follow-Up Work
- Bringing other sections (Student Guide, Get-to-know pages, static pages) into the CMS — same pattern, later, only if wanted.
- Machine-assisted MK/SI translation drafts inside the editor.
- Automatic watermarking of uploaded event photos as a build step (currently a manual convention).

### Outside this effort
- Migrating the site off static hosting or off Gitea/GitHub to a database-backed CMS (WordPress etc.) — explicitly rejected (see Problem Frame constraints and Alternatives).
- Redesign or restyling of any page.
- The functional page-feedback (`.guide-help`) widget on guide pages — do not touch.

---

## Alternatives Considered

- **WordPress / database CMS** — rejected: heavy hosting + security-patching burden for a volunteer team, loses the hand-tuned SEO and trilingual folder structure, slower pages, recurring cost.
- **New SSG (Eleventy/Astro/Hugo) + Git CMS** — rejected as unnecessary: the existing Python build already renders Markdown and owns listings/SEO/search-index. Re-expressing 198 pages and the SEO logic in a new template engine is high-risk churn (KTD-1).
- **Hosted headless CMS (Storyblok/Sanity)** — viable and has the best editor UX, but content leaves the repo (vendor dependency), adds free-tier limits and a rebuild webhook. Rejected per the user's choice of in-repo/versioned content; kept on record as the fallback if editor UX proves insufficient.
- **Self-hosted OAuth against Gitea (no GitHub)** — keeps everything on Gitea, but Sveltia/Decap don't officially support a Gitea backend, so it means maintaining an unsupported bridge. Rejected as higher-maintenance than the GitHub content remote (KTD-5); revisit if Mark prefers to avoid GitHub entirely.

---

## Risks & Dependencies

- **Migration fidelity (U1/U2)** — extracting clean content from hand-authored HTML may be imperfect. *Mitigation:* characterization/golden tests (KTD-6) and human proofing; migration is a scaffold, not a guaranteed lossless transform.
- **Gitea↔GitHub topology (U6)** — depends on Mark; wrong wiring risks double-deploys or drift. *Mitigation:* agree topology in `docs/cms-infra.md` before U7; one host owns deploy.
- **SEO/search regression (R5)** — the site's value rests on its SEO and offline search. *Mitigation:* golden-diff every generated page type; run `seo_inject --all --sitemap` + `build_search_index.py` in CI every deploy.
- **Editor error reaching production** — mitigated structurally by the review gate (R3) and rollback-by-revert (U8).
- **Secrets** — deploy `SSH_*` secrets must be re-created on whichever host runs the Action; never commit them.
- **Dependencies:** Python 3.x in CI; `beautifulsoup4` (already used), plus a YAML and Markdown library for front-matter/body; Sveltia CMS (CDN or vendored); a GitHub OAuth relay/App.

---

## Open Questions

- **Q1 (blocking U6, needs Mark):** Final Gitea↔GitHub topology — mirror direction, and which host runs the deploy Action. Recommended default: GitHub content remote + GitHub Action deploy; Gitea remains code remote via mirror.
- **Q2 (deferrable):** Do we want a public **staging URL** for reviewers to preview a PR before merge, or is local/`/admin` preview enough for launch?
- **Q3 (deferrable):** Should uploaded event photos be **auto-watermarked** in the build, or keep the current manual convention for now?

---

## Sources & Research

- Repo recon (this session): `.gitea/workflows/deploy.yml` (push-`develop`→rsync), `tools/build_blog.py` (Markdown→article), `tools/build_listings.py` ("single source of truth = detail pages" + AUTO blocks), `tools/seo_inject.py` (`ARTICLE_DATES`, SEO block), `tools/README.md`, `update_html.py` (header/footer markers), content layout under `en|mk|si/{blog,news,projects}/`.
- Prior session context: gallery/watermark convention and the image-copyright clause shipped to the legal/gallery/projects pages (informs U8 image guidance); known pending MK/SI proofreading (informs the review gate).
