24 KiB
| title | type | date | depth | status |
|---|---|---|---|---|
| feat: No-code CMS for blog/news/events (EN/MK/SI) | feat | 2026-08-08 | deep | 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:
- Introduce a structured content source (Markdown + front-matter, one file per entry per language) as the new source of truth for blog/news/events.
- 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.
- 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):
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):
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):
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 (whichbuild_listings.pyalready 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 intools/build_listings.py; the Markdown conventionstools/build_blog.pyalready expects (metadata lines,## headings, hero section). Test scenarios:- Happy path: a known blog post extracts to
en.mdwith 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 correspondingcontent/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 oftools/build_blog.pygeneralised to blog/news/events)tools/tests/fixtures/**(new — golden HTML captured from current pages)tools/tests/test_build_content.py(new) Approach: Generalisebuild_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 inupdate_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.mdproduces 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 — readcontent/front-matter instead of scraping detail HTML)tools/seo_inject.py(modify — deriveARTICLE_DATESfrom 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" thatbuild_listings.pydocuments atcontent/metadata. Replace the hand-maintainedARTICLE_DATESwith a loader over front-matter dates (keep a thin compatibility shim if other scripts import it). Execution note: Characterization-first against currentnews/index.htmland homepage AUTO blocks. Patterns to follow: existing<!-- AUTO-NEWS:START/END -->and<!-- AUTO-LATEST-NEWS:START/END -->block regeneration inbuild_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 atlocalhost: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.ymland/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 runtools/build_site.pybefore 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*.mdare 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_DIRsecrets). 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 indocs/cms-infra.mdbefore 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/adminand 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;i18nwith 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/adminis served but excluded from search/SEO. Test scenarios:- Covers R2. Creating an entry with EN+MK+SI writes
content/<type>/<slug>/{en,mk,si}.mdwith 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.mdbefore 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.pyin 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/
/adminpreview 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 underen|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).