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.
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).
- Admin API: Built-in REST API for programmatic content management (create, update, delete files and directories).
- Guided Frontmatter: Add supported metadata in the editor and extend its registry through an automatically discovered project override.
🛠️ Features That Replace Paid Services
| Moosey CMS Feature | Replaces Paid Services |
|---|---|
image() filter with responsive srcset and CDN transforms |
Cloudinary, Imgix |
schema_article() + OpenGraph + Twitter Cards + meta tags |
Yoast SEO, Rank Math |
sanitize() HTML sanitizer (Bleach-based) |
DOMPurify, HTML sanitization APIs |
embed() (YouTube, Twitter/X, Vimeo, CodePen, Gist) |
Embedly, oEmbed API services |
sitemap.xml + robots.txt + RSS/Atom feed |
Google XML Sitemaps, Feedburner |
country_flag, country_name, language_name, currency_name (pycountry) |
RestCountries API, currency data APIs |
dominant_color() from local images |
ColorThief, LCP placeholder services |
inline() + cache_bust() |
Critical CSS tools, Webpack/Gulp cache busting |
headings() + toc_from_html() |
Table of Contents plugins |
markdown with pymdown-extensions |
Contentful, Sanity (content authoring) |
| Hot-reload browser refresh | BrowserSync, LiveReload |
| No-database flat-file CMS | WordPress, Strapi, Ghost |
📦 Installation
Using UV (Recommended)
uv add moosey-cms
Using Pip
pip install moosey-cms
💻 CLI
moosey-cms ships with a CLI for scaffolding sites, installing admin templates, and running servers.
# Scaffold a new site from the example app
moosey-cms init ./my-site
# Initialize or update config for existing project
moosey-cms config
# Install admin templates into your project
moosey-cms admin --templates ./templates
# Run dev server (hot-reload)
moosey-cms dev
# Run production server
moosey-cms prod
See CLI Reference for all commands and options.
🧪 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
)
Admin Setup
Moosey CMS includes a built-in admin UI for editing content directly in the browser. The admin UI features a tabbed editor with WYSIWYG (TUI Editor) and metadata editing (Guifier).
1. Enable admin in init_cms():
init_cms(
app,
# ... other params ...
admin={
"prefix": "admin/content", # Route prefix for admin UI
"templates": "admin" # Subdirectory in templates/ for admin templates
}
)
2. Install admin templates:
# Install admin templates into your project
moosey-cms admin --templates ./templates
This creates templates/admin/ with the default editor and base layout templates.
3. Template structure:
templates/
├── admin/
│ ├── base.html # Admin layout (sidebar, navigation)
│ ├── editor.html # Tabbed file editor (main UI)
│ └── admin.js # Shared admin utilities
├── layout/
│ └── base.html # Public site layout
└── ...
4. Static files (required for file uploads):
from fastapi.staticfiles import StaticFiles
# Mount static files directory
app.mount("/static", StaticFiles(directory="static"), name="static")
The admin UI allows:
- Browsing and editing Markdown files with frontmatter
- Creating new files and directories
- Uploading images and other static assets
- Live preview of content changes
- Metadata editing via JSON schema form
See Admin Documentation for full details.
⚙️ Configuration File
Moosey CMS uses a YAML configuration file (.moosey-cms.yaml) in your project root. This file is auto-generated when you run moosey-cms init.
Default config structure:
server:
host: "0.0.0.0"
port: 8000
reload_delay: 0.25
site:
name: "My Site"
admin:
prefix: "admin/content"
templates: "admin"
crypto:
key: "your-generated-key-here"
cache:
backend: "memory" # "memory" or "redis"
ttl: 2592000 # 30 days in seconds
maxsize: 10000 # max entries (memory only)
redis_url: "redis://localhost:6379/0" # only used when backend="redis"
How it works:
init_cms()automatically loads.moosey-cms.yamlfrom your project root- Python arguments passed to
init_cms()override YAML values - The
crypto.keyis required - the CMS will not start without it
Cache backends:
memory(default): In-process LRU cache. Fastest, resets on restart.redis: Shared cache across workers/processes. Requires a running Redis server. The Redis client is auto-created fromredis_url.
Advanced configuration (uncomment in your config file):
# Image CDN configuration
# image_cdn:
# provider: cloudflare # cloudflare, cloudinary, imgix, imagekit
# base_url: "https://cdn.example.com"
# Image processing defaults
# image_processing:
# quality: 85
# format: webp
# HTML sanitize settings
# sanitize:
# allowed_tags: ["p", "a", "img", "h1", "h2", "h3"]
# allowed_attributes: ["href", "src", "alt"]
Important: Do not change the crypto.key after initialization. Assets encrypted with this key cannot be decrypted with a different key.
📂 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:
- Frontmatter Override: If
post-1.mdcontainstemplate: special.html, that template is used immediately. - Exact Match:
templates/posts/post-1.html. - Singular Parent:
templates/post.html(Perfect for generic blog posts). - Plural Parent:
templates/posts.html(Perfect for section indexes). - 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.
⚙️ 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. |
admin |
dict |
Admin content-editing config with keys prefix (route prefix) and templates (admin templates subdirectory). No admin routes if omitted. |
🛡️ Security & Mitigation
Moosey CMS takes security seriously. We have implemented several layers of protection to ensure your site remains safe:
- Path Traversal Protection: All URL requests are securely resolved against the content root using strict
pathlibchecks. It is impossible to access files outside thecontentdirectory (e.g.,../../etc/passwd). - 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. - 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
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file moosey_cms-0.9.6.tar.gz.
File metadata
- Download URL: moosey_cms-0.9.6.tar.gz
- Upload date:
- Size: 515.5 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0b39a948e2381d819d630c690e8b624f9f0f9c3c461c7102e8434145b6eee48d
|
|
| MD5 |
9ee2dc2e314c4e3d2b589093d240ffd1
|
|
| BLAKE2b-256 |
16695879ca59908d2c86644e0361eb08f96b3596f01fa1ef471aae8dd3e07f00
|
File details
Details for the file moosey_cms-0.9.6-py3-none-any.whl.
File metadata
- Download URL: moosey_cms-0.9.6-py3-none-any.whl
- Upload date:
- Size: 104.7 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
67d30bd3b1f93c8bdf7363a30ebd6396d3e227b534d4ab47acc6e459ecc2c023
|
|
| MD5 |
2bec7304f92c07197e0ce48e8cc4a891
|
|
| BLAKE2b-256 |
df95f37b4ce90636263b7cd956b9965ba1c516d99e568dab188ca1ca66cc979d
|