Skip to main content

Moosey CMS 🫎

A lightweight, drop-in Markdown CMS for FastAPI.

Moosey CMS transforms your FastAPI application into a content-driven website without the need for a database. It bridges the gap between static site generators and dynamic web servers, offering hot-reloading, intelligent caching, SEO management, and a powerful templating hierarchy.

Example Screenshot

Example Screenshot

Check out the /example for templating and content samples used to generate the images above.


🚀 Features

  • No Database Required: Content is managed via Markdown files with YAML Frontmatter.
  • Intelligent Routing: URL paths automatically map to your content directory structure.
  • Smart Templating: "Waterfall" inheritance logic (Singular/Plural) to automatically find the best layout for every page.
  • Hot Reloading: Instant browser refresh when Content or Templates change (Development mode only).
  • High Performance: Built-in caching (TTL-based) that auto-clears on file changes.
  • SEO Ready: Automatic OpenGraph, Twitter Cards, JSON-LD, and Meta tags generation.
  • Site Management: Built-in sitemap.xml, robots.txt, RSS feeds, and a reusable content index.
  • Rich Markdown: Supports tables, emojis, task lists, and syntax highlighting out of the box.
  • Jinja2 Power: Use Jinja2 logic directly inside your Markdown files (Securely Sandboxed).

📦 Installation

Using UV (Recommended)

uv add moosey-cms

Using Pip

pip install moosey-cms

🧪 Running Tests

# Install dev dependencies (pip)
pip install -e ".[dev]"

# Install dev dependencies (uv)
uv add moosey-cms --dev
uv sync

# Run all tests
pytest

# Run a specific test file
pytest tests/test_schemas.py

# Run a specific test class
pytest tests/test_schemas.py::TestSchemaArticle

# Run with verbose output
pytest -v tests/test_schemas.py

⚡ Quick Start

Integrate Moosey CMS into your existing FastAPI app in just a few lines.

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from moosey_cms import init_cms

app = FastAPI()

# 1. Define your paths
BASE_DIR = Path(__file__).resolve().parent
CONTENT_DIR = BASE_DIR / "content"
TEMPLATES_DIR = BASE_DIR / "templates"

# 2. Mount static files (Optional, but recommended for CSS/Images)
app.mount("/static", StaticFiles(directory="static"), name="static")

# 3. Initialize the CMS
init_cms(
    app,
    host="localhost",
    port=8000,
    dirs={
        "content": CONTENT_DIR, 
        "templates": TEMPLATES_DIR
    },
    mode="development",  # Enables hot-reloading
    site_data={
        "name": "My Awesome Site",
        "description": "A site built with Moosey CMS",
        "author": "Jane Doe",
        "keywords": ["fastapi", "cms", "python"],
        "open_graph": {
             "og_image": "/static/cover.jpg"
        },
        "social": {
            "twitter": "https://x.com/myhandle",
            "github": "https://github.com/myhandle"
        },
        "web": {
            "site_url": "https://example.com",
            "feed": {
                "collection": "/blog",
                "title": "My Awesome Site Feed"
            }
        }
    },
    reload_delay=2.5 # Triggers hot-reload after this duration
)

📂 Directory Structure

Moosey CMS relies on a convention-over-configuration file structure.

.
├── main.py
├── content/               <-- Your Markdown Files
│   ├── index.md           <-- Homepage (/)
│   ├── about.md           <-- About Page (/about)
│   └── blog/
│       ├── index.md       <-- Blog Listing (/blog)
│       ├── post-1.md      <-- Blog Post (/blog/post-1)
│       └── post-2.md
└── templates/ 
    ├── layout          
        ├── base.html          <-- Base layout
    ├── index.html         <-- Home Page layout
    ├── page.html          <-- Default fallback
    ├── blog.html          <-- Layout for /blog (Listing)
    └── post.html          <-- Layout for /blog/post-1 (Single Item)

🎨 Templating Logic (The Waterfall)

When a user visits a URL, Moosey CMS searches for templates in a specific cascading order. This allows you to set global defaults while retaining the ability to customize specific pages or sections.

Example Scenario: A user visits /posts/post-1.

Directory Structure:

.
├── content/
│   └── posts/
│       ├── index.md        <-- Required for the '/posts' listing page to work
│       ├── post-1.md       <-- The article being requested
│       └── post-2.md
└── templates/
    ├── posts/
    │   └── post-1.html     <-- 1. Specific Override
    ├── post.html           <-- 2. Singular (Item) Layout
    ├── posts.html          <-- 3. Plural (Section) Layout
    └── page.html           <-- 4. Global Fallback

Resolution Order:

  1. Frontmatter Override: If post-1.md contains template: special.html, that template is used immediately.
  2. Exact Match: templates/posts/post-1.html.
  3. Singular Parent: templates/post.html (Perfect for generic blog posts).
  4. Plural Parent: templates/posts.html (Perfect for section indexes).
  5. Fallback: templates/page.html.

📝 Frontmatter Configuration

You can control routing, visibility, and layout directly from the Markdown file YAML frontmatter.

Basic Metadata

title: My Amazing Post
date: 2024-01-01
description: A short summary for SEO.

Organization & Navigation

Key Type Description
order int Sort order in sidebars. Lower numbers appear first. Default: 9999.
nav_title str Short title to display in sidebars (if different from title).
visible bool Set to false to hide from sidebars/menus (page remains accessible via URL).
draft bool If true, the page is only visible in development mode.
group str Group sidebar items under a heading (requires template support).

Advanced Routing

Key Type Description
template str Force a specific template file (e.g., template: landing.html).
external_link str The sidebar link will point to this external URL instead of the page itself.
redirect str Alias for external_link.

Publishing, SEO & Feeds

Key Type Description
canonical / canonical_url str Canonical URL used by {{ seo() }}.
noindex bool Adds noindex, nofollow via {{ seo() }} and excludes the page from sitemap/feed output.
sitemap bool or dict Set false to exclude from /sitemap.xml, or provide changefreq / priority.
feed / rss bool Set false to exclude a page from RSS feeds.
date.published date Preferred publish date for sorting and RSS pubDate.

Example:

---
title: API Documentation
nav_title: API Docs
order: 1
group: "Developer Tools"
external_link: "https://api.mysite.com"
---

🕸️ Built-in Website Routes

Moosey automatically registers everyday site-management routes before the content catch-all route:

Route Purpose
/sitemap.xml Autogenerated XML sitemap from publishable Markdown pages.
/robots.txt Environment-aware robots rules with a sitemap pointer.
/feed.xml RSS 2.0 feed generated from your content index.
/rss.xml Alias for /feed.xml unless disabled.

Configure them in site_data.web:

site_data = {
    "name": "My Site",
    "web": {
        "site_url": "https://example.com",
        "sitemap": {
            "default_changefreq": "weekly",
            "default_priority": "0.5",
        },
        "robots": {
            "production": {"allow": ["/"], "disallow": []},
            "staging": {"disallow": ["/"]},
            "testing": {"disallow": ["/"]},
        },
        "feed": {
            "collection": "/blog",
            "limit": 50,
            "title": "My Site Blog",
            "description": "Latest articles from My Site",
        },
    },
}

Set any feature to false to disable it, for example "feed": false.


🧩 Custom Filters & Logic

Moosey CMS comes packed with a comprehensive library of Jinja2 filters to help you format your data effortlessly.

Date & Time

Filter Usage Output
fancy_date {{ date | fancy_date }} 13th Jan, 2026 at 6:00 PM
short_date {{ date | short_date }} Jan 13, 2026
iso_date {{ date | iso_date }} 2026-01-13
time_only {{ date | time_only }} 6:00 PM
relative_time {{ date | relative_time }} 2 hours ago / yesterday
rfc822_date {{ date | rfc822_date }} Thu, 15 Jan 2026 00:00:00 GMT

Currency & Numbers

Filter Usage Output
currency {{ 1234.5 | currency('USD') }} $1,234.50
compact_currency {{ 1500000 | compact_currency }} $1.5M
currency_name {{ 'KES' | currency_name }} Kenyan Shilling
number_format {{ 1000 | number_format }} 1,000
percentage {{ 50.5 | percentage }} 50.5%
ordinal {{ 3 | ordinal }} 3rd

Geography & Locale

Filter Usage Output
country_flag {{ 'US' | country_flag }} 🇺🇸
country_name {{ 'DE' | country_name }} Germany
language_name {{ 'fr' | language_name }} French

Text Formatting

Filter Usage Output
truncate_words {{ text | truncate_words(10) }} Truncates text to 10 words...
excerpt {{ text | excerpt(150) }} Smart excerpt breaking at sentences.
read_time {{ content | read_time }} 5 min read
slugify {{ 'Hello World' | slugify }} hello-world
title_case {{ 'a tale of two cities' | title_case }} A Tale of Two Cities
smart_quotes {{ '"Hello"' | smart_quotes }} “Hello”
strip_html {{ content | strip_html }} Plain text without HTML tags
markdown {{ bio | markdown | safe }} Renders Markdown to HTML (inline mode: markdown(inline=True))

Utilities

Filter Usage Output
filesize {{ 1024 | filesize }} 1.0 KB
yesno {{ True | yesno }} Yes
default_if_none {{ val | default_if_none('N/A') }} Returns default if None
absolute_url {{ '/about' | absolute_url }} Absolute URL using site_data.web.site_url or the request base URL

🛡 Sanitize

Filter Usage Notes
sanitize {{ html | sanitize | safe }} Run bleach.clean with sane CMS defaults. Always on for rendered Markdown bodies. Override via site_data.sanitize; opt out with site_data.sanitize = False.

🔧 SEO & Data

Filter Usage Output
json_ld {{ schema_article(...) | json_ld | safe }} Renders a Python dict as a <script type="application/ld+json"> block. Schema builders (schema_article, schema_breadcrumbs, schema_faqpage, schema_howto, schema_localbusiness, schema_product, schema_event, schema_organization, schema_website, schema_person) are registered as Jinja globals - see docs/seo-advanced.md.
cache_bust {{ '/static/site.css' | cache_bust }} Appends ?v=<mtime> so browsers re-fetch after every change.
pluralize {{ 'review' | pluralize(reviews_count) }} 1 review / 2 reviews. Custom: pluralize(count, 'mice').
word_count {{ body | word_count }} Number of words (strips HTML if any).
inline {{ '/static/logo.svg' | inline | safe }} Inline the contents of a static asset into the page. Pass encode='data-uri' for base64.

🖼 Images

Filter Description Install
img_attrs Build src … loading=… decoding=… attribute string. core
lazy_image Inject lazy/async attrs into existing <img>. core
image Build an image URL (simple) or a full <img srcset sizes> tag (with widths). moosey-cms[images]
image_dimensions Read width="…" height="…" from local image. moosey-cms[images]
dominant_color Most-common hex color (for LQIP backgrounds). moosey-cms[images]
image_cdn URL-rewriting adapter for Cloudflare / Cloudinary / imgix / ImageKit. core

Enabling on-disk processing requires passing "static": <path> in dirs to init_cms. Full reference: docs/images.md. Face detection via focus=face requires moosey-cms[faces] (~30MB).

Path convention: image source paths passed to image should omit the /static/ prefix. Since the static directory is already configured in dirs, use paths relative to it - e.g. /images/team/martin.jpg instead of /static/images/team/martin.jpg. The filter will resolve these against the configured static_dir automatically.

🔗 Content Helpers

Filter Usage Output
embed {{ 'https://youtu.be/...' | embed | safe }} oEmbed-lite for YouTube/Vimeo/Twitter/Gist/CodePen. Unknown URLs fall back to a plain <a>.
headings {{ content | headings }} [(id, text, level), ...] for in-page TOC.
toc_from_html {{ content | toc_from_html | safe }} Renders a <nav class="prose-toc"><ul>…</ul></nav>.
gravatar {{ user.email | gravatar(size=200, default='mp') }} Gravatar URL.

More On Filters and how to use some interesting ones such as stripping comments.

Advanced Features and how to use some interesting ones such as stripping comments.

⚙️ Configuration Reference

The init_cms function accepts the following parameters:

Parameter Type Description
app FastAPI Your FastAPI application instance.
host str Server host (used for hot-reload script injection).
port int Server port.
dirs dict Dictionary containing content and templates Paths.
mode str "development" (enables hot reload/no cache), "production", "staging", or "testing".
site_data dict Global data (name, author, social links, optional web config for sitemap/robots/RSS).
reload_delay float Seconds to delay hot-reload broadcast after a file change. Useful when a build step runs post-save. Default: 0 (immediate). Development mode only.

🛡️ Security & Mitigation

Moosey CMS takes security seriously. We have implemented several layers of protection to ensure your site remains safe:

  1. Path Traversal Protection: All URL requests are securely resolved against the content root using strict pathlib checks. It is impossible to access files outside the content directory (e.g., ../../etc/passwd).
  2. SSTI Sandbox: While we allow Jinja2 logic inside Markdown files, this is executed in a Sandboxed Environment. Dangerous attributes (like __class__, __subclasses__) are stripped, preventing Remote Code Execution (RCE) attacks.
  3. DoS Prevention: The Hot-Reload middleware includes size checks to prevent memory exhaustion attacks from large file uploads/downloads.

🐛 Bug Reporting

Security is an ongoing process. If you discover a vulnerability, bug, or potential risk, please open an issue on our GitHub repository immediately. We appreciate community feedback to keep Moosey secure for everyone.


Documentation

New to moosey-cms? Start here:

  1. Getting Started - Installation, configuration, and your first page.
  2. Filters Reference - All built-in Jinja2 filters for dates, text, numbers, HTML, and more.
  3. Markdown Rendering - Using markdown and markdown_inline filters.
  4. Image Processing - Automatic image resizing, responsive srcset, face detection, CDN support.
  5. Templates - Template syntax, static files, pagination, RSS, sitemaps, collections, and custom pages.
  6. Security - HTML sanitization, Content Security Policy, sandboxing.
  7. Patterns - Real-world project structures and conventions.
  8. SEO - Meta tags, Open Graph, structured data, robots.txt, canonical URLs.

Gratitude

This project is inspired by fastapi-blog by Daniel. Initially, I wanted to use fastapi-blog and it worked really well till I needed features like hot-reloading.

License

MIT License. Copyright (c) 2026 Anthony Mugendi.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

moosey_cms-0.9.4.tar.gz (281.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

moosey_cms-0.9.4-py3-none-any.whl (57.4 kB view details)

Uploaded Python 3

File details

Details for the file moosey_cms-0.9.4.tar.gz.

File metadata

  • Download URL: moosey_cms-0.9.4.tar.gz
  • Upload date:
  • Size: 281.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for moosey_cms-0.9.4.tar.gz
Algorithm Hash digest
SHA256 d7518516ffd02333861b775baa59cc0ccbcdcd37a74d5c5ceb92483bb6345f64
MD5 db5120cbb9ee0b6fb3a7ddaad3528695
BLAKE2b-256 cb8b49d036aeb2a7a14a5280c237adda7332b5f1664f6f7f8aef15ceb371362d

See more details on using hashes here.

File details

Details for the file moosey_cms-0.9.4-py3-none-any.whl.

File metadata

  • Download URL: moosey_cms-0.9.4-py3-none-any.whl
  • Upload date:
  • Size: 57.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for moosey_cms-0.9.4-py3-none-any.whl
Algorithm Hash digest
SHA256 7e1ff7a6bc7b5f54327ff2c15df51b64b95e85d5fba86e77290e00a89e8133d7
MD5 8671eef144b4633ed84f1dda9854a4c2
BLAKE2b-256 d4e54a9ac6733e8ded13fecba3d511600005b3bbc97871801f7e5a9176c81108

See more details on using hashes here.

Release history Release notifications | RSS feed

0.9.9

2 files

0.9.8

2 files

0.9.7

2 files

0.9.6

2 files

0.9.5

2 files

This release

0.9.4 This release

2 files

0.9.3

2 files

0.9.2

2 files

0.9.1

2 files

0.9.0

2 files

0.8.2

2 files

0.8.1

2 files

0.8.0

2 files

0.7.2

2 files

0.7.1

2 files

0.7.0

2 files

0.6.0

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page