moosey-cms 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
moosey_cms/md.py ADDED
@@ -0,0 +1,192 @@
1
+ """
2
+ Copyright (c) 2026 Anthony Mugendi
3
+
4
+ This software is released under the MIT License.
5
+ https://opensource.org/licenses/MIT
6
+ """
7
+
8
+ import markdown as md
9
+ from markdown.inlinepatterns import InlineProcessor
10
+ from markdown.extensions import Extension
11
+ import xml.etree.ElementTree as etree
12
+
13
+
14
+ extensions = [
15
+ "markdown.extensions.tables",
16
+ "markdown.extensions.toc",
17
+ "pymdownx.magiclink",
18
+ "pymdownx.betterem",
19
+ "pymdownx.tilde",
20
+ "pymdownx.emoji",
21
+ "pymdownx.tasklist",
22
+ "pymdownx.superfences",
23
+ "pymdownx.saneheaders",
24
+ "pymdownx.arithmatex", # <--- ADD THIS for math support
25
+ ]
26
+
27
+ extension_configs = {
28
+ "markdown.extensions.toc": {
29
+ "title": "Table of Contents",
30
+ "permalink": False,
31
+ "permalink_leading": True,
32
+ },
33
+ "pymdownx.magiclink": {
34
+ "repo_url_shortener": True,
35
+ "repo_url_shorthand": True,
36
+ "provider": "github",
37
+ "user": "facelessuser",
38
+ "repo": "pymdown-extensions",
39
+ },
40
+ "pymdownx.tilde": {"subscript": False},
41
+ "pymdownx.arithmatex": { # <--- ADD THIS configuration
42
+ "generic": True, # Use generic mode for flexibility with MathJax/KaTeX
43
+ },
44
+ }
45
+
46
+
47
+ import re
48
+ from markdown.inlinepatterns import InlineProcessor
49
+ from markdown.extensions import Extension
50
+ import xml.etree.ElementTree as etree
51
+
52
+ # 1. THE EXTENSIVE DICTIONARY
53
+ EXTENDED_EMOTICONS = {
54
+ # Happy / Smile
55
+ ":-)": "🙂",
56
+ ":)": "🙂",
57
+ "=)": "🙂",
58
+ ":]": "🙂",
59
+ ":-]": "🙂",
60
+ # Grinning / Laughing
61
+ ":-D": "😃",
62
+ ":D": "😃",
63
+ "=D": "😃",
64
+ "xD": "😆",
65
+ "XD": "😆",
66
+ "8-D": "😃",
67
+ # Sad / Frowning
68
+ ":-(": "🙁",
69
+ ":(": "🙁",
70
+ "=(": "🙁",
71
+ ":[": "🙁",
72
+ ":-[": "🙁",
73
+ # Crying
74
+ ":'(": "😢",
75
+ ":'-(": "😢",
76
+ # Wink
77
+ ";-)": "😉",
78
+ ";)": "😉",
79
+ "*-)": "😉",
80
+ "*)": "😉",
81
+ ";-]": "😉",
82
+ # Tongue Out (Playful)
83
+ ":-P": "😛",
84
+ ":P": "😛",
85
+ "=P": "😛",
86
+ ":-p": "😛",
87
+ ":p": "😛",
88
+ ":b": "😛",
89
+ # Surprise / Shock
90
+ ":-O": "😮",
91
+ ":O": "😮",
92
+ ":-o": "😮",
93
+ ":o": "😮",
94
+ "8-0": "😮",
95
+ "=O": "😮",
96
+ # Cool / Sunglasses
97
+ "8-)": "😎",
98
+ "B-)": "😎",
99
+ "B)": "😎",
100
+ # Love / Affection
101
+ "<3": "❤️",
102
+ "&lt;3": "❤️", # Handle HTML escaped version just in case
103
+ "</3": "💔",
104
+ "&lt;/3": "💔",
105
+ ":-*": "😘",
106
+ ":*": "😘",
107
+ # Confused / Skeptical / Annoyed
108
+ # Note: We avoid ':/' because it breaks http:// URLs. We use ':-/' instead.
109
+ ":-/": "😕",
110
+ ":-\\": "😕",
111
+ ":-|": "😐",
112
+ ":|": "😐",
113
+ # Angry
114
+ ">:(": "😠",
115
+ ">:-(": "😠",
116
+ # Embarrassed / Blush
117
+ ":$": "😳",
118
+ ":-$": "😳",
119
+ # Misc
120
+ "O:-)": "😇",
121
+ "0:-)": "😇", # Angel
122
+ ">:)": "😈",
123
+ ">:-)": "😈", # Devil
124
+ "D:<": "😨", # Horror
125
+ }
126
+
127
+
128
+ class EmoticonInlineProcessor(InlineProcessor):
129
+ def __init__(self, pattern, md, emoticons):
130
+ super().__init__(pattern, md)
131
+ self.emoticons = emoticons
132
+
133
+ def handleMatch(self, m, data):
134
+ emoticon = m.group(1)
135
+ # Handle case sensitivity for things like xD vs XD if needed,
136
+ # but the dictionary keys usually handle it.
137
+ emoji_char = self.emoticons.get(emoticon)
138
+
139
+ if not emoji_char:
140
+ # Fallback for HTML escaped variants if necessary
141
+ emoji_char = self.emoticons.get(emoticon.replace("&lt;", "<"))
142
+
143
+ if emoji_char:
144
+ el = etree.Element("span")
145
+ el.text = emoji_char
146
+ el.set("class", "emoji")
147
+ el.set("title", emoticon) # Adds hover text showing original syntax
148
+ return el, m.start(0), m.end(0)
149
+ return None, None, None
150
+
151
+
152
+ class EmoticonExtension(Extension):
153
+ def extendMarkdown(self, md):
154
+ # 2. SORT BY LENGTH DESCENDING
155
+ # Critical: Ensures ':-((' matches before ':-('
156
+ sorted_keys = sorted(EXTENDED_EMOTICONS.keys(), key=len, reverse=True)
157
+
158
+ # 3. BUILD REGEX
159
+ # We escape the keys to handle characters like (, ), *, | safely
160
+ pattern_str = (
161
+ r"(" + "|".join(re.escape(k) for k in sorted_keys if len(k) > 2) + r")"
162
+ )
163
+
164
+ # 4. REGISTER
165
+ # Priority 175 is generally safe.
166
+ # If you find it breaking links (http://), lower it to < 120.
167
+ md.inlinePatterns.register(
168
+ EmoticonInlineProcessor(pattern_str, md, EXTENDED_EMOTICONS),
169
+ "emoticons",
170
+ 175,
171
+ )
172
+
173
+
174
+
175
+ # Initialize extensions ONCE at module level
176
+ markdown_extensions = extensions + [EmoticonExtension()]
177
+
178
+ # Create a global instance
179
+ _markdowner = md.Markdown(
180
+ extensions=markdown_extensions,
181
+ extension_configs=extension_configs
182
+ )
183
+
184
+ def parse_markdown(data):
185
+ """
186
+ Returns HTML string.
187
+ Note: We must reset the instance to clear state (like footnotes) between converts.
188
+ """
189
+ _markdowner.reset()
190
+ html = _markdowner.convert(data)
191
+
192
+ return html
moosey_cms/models.py ADDED
@@ -0,0 +1,73 @@
1
+ """
2
+ Copyright (c) 2026 Anthony Mugendi
3
+
4
+ This software is released under the MIT License.
5
+ https://opensource.org/licenses/MIT
6
+ """
7
+
8
+ from pydantic import BaseModel, Field, field_validator
9
+ from typing import Dict, Literal, Optional
10
+ from pathlib import Path
11
+
12
+
13
+ class OpenGraphConfig(BaseModel):
14
+ """Open Graph metadata configuration"""
15
+ og_image: str = Field(..., description="Path to Open Graph image")
16
+ og_title: Optional[str] = Field(None, description="Open Graph title")
17
+ og_description: Optional[str] = Field(None, description="Open Graph description")
18
+ og_url: Optional[str] = Field(None, description="Open Graph URL")
19
+
20
+
21
+ class SocialConfig(BaseModel):
22
+ """Social media links configuration"""
23
+ twitter: Optional[str] = Field(None, description="Twitter/X profile URL")
24
+ facebook: Optional[str] = Field(None, description="Facebook profile URL")
25
+ linkedin: Optional[str] = Field(None, description="LinkedIn profile URL")
26
+ instagram: Optional[str] = Field(None, description="Instagram profile URL")
27
+ github: Optional[str] = Field(None, description="GitHub profile URL")
28
+
29
+ @field_validator('twitter', 'facebook', 'linkedin', 'instagram', 'github')
30
+ @classmethod
31
+ def validate_url(cls, v: Optional[str]) -> Optional[str]:
32
+ if v and not v.startswith(('http://', 'https://')):
33
+ raise ValueError('Social media links must be valid URLs starting with http:// or https://')
34
+ return v
35
+
36
+
37
+ class SiteData(BaseModel):
38
+ """Site metadata and configuration"""
39
+ name: Optional[str] = Field(..., description="Site name")
40
+ keywords: Optional[list[str]] = Field(..., description="SEO keywords")
41
+ description: Optional[str] = Field(..., description="Site description")
42
+ author: Optional[str] = Field(..., description="Site author")
43
+ open_graph: Optional[OpenGraphConfig] = Field(..., description="Open Graph configuration")
44
+ social: Optional[SocialConfig] = Field(..., description="Social media links")
45
+
46
+
47
+
48
+ class Dirs(BaseModel):
49
+ """Directory paths configuration"""
50
+ content: Path = Field(..., description="Content directory path")
51
+ templates: Path = Field(..., description="Templates directory path")
52
+
53
+ @field_validator('content', 'templates')
54
+ @classmethod
55
+ def validate_path_exists(cls, v: Path) -> Path:
56
+ if not v.exists():
57
+ raise ValueError(f'Directory does not exist: {v}')
58
+ if not v.is_dir():
59
+ raise ValueError(f'Path is not a directory: {v}')
60
+ return v
61
+
62
+
63
+ class CMSConfig(BaseModel):
64
+ """Complete CMS initialization configuration"""
65
+ host: str = Field(..., description="Server host address")
66
+ port: int = Field(..., ge=1, le=65535, description="Server port number")
67
+ dirs: Dirs = Field(..., description="Directory configuration")
68
+ mode: Literal["development", "production", "staging", "testing"] = Field(
69
+ ...,
70
+ description="Application mode"
71
+ )
72
+ site_data: Optional[SiteData] = Field(..., description="Site metadata")
73
+ # site_code: Optional[SiteCode] = Field(..., description="Custom site code")
moosey_cms/py.typed ADDED
File without changes
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "moosey-cms"
3
+ version = "0.1.0"
4
+ description = "A drop-in Markdown CMS for FastAPI with Hot Reloading"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "cachetools>=6.2.4",
9
+ "inflection>=0.5.1",
10
+ "jinja2>=3.1.6",
11
+ "markdown>=3.10",
12
+ "pymdown-extensions>=10.20",
13
+ "python-frontmatter>=1.1.0",
14
+ "python-slugify>=8.0.4",
15
+ "slugify>=0.0.1",
16
+ ]
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ # --- CRITICAL: INCLUDE STATIC FILES ---
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/moosey_cms"]
25
+
26
+ # This forces the inclusion of non-python files inside the package
27
+ [tool.hatch.build.targets.wheel.force-include]
28
+ "src/simple_cms/static" = "moosey_cms/static"
moosey_cms/seo.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ Copyright (c) 2026 Anthony Mugendi
3
+
4
+ This software is released under the MIT License.
5
+ https://opensource.org/licenses/MIT
6
+ """
7
+
8
+ from jinja2 import pass_context
9
+ from markupsafe import Markup, escape
10
+ from typing import Optional
11
+
12
+
13
+ @pass_context
14
+ def seo_tags(
15
+ context,
16
+ title: Optional[str] = None,
17
+ description: Optional[str] = None,
18
+ image: Optional[str] = None,
19
+ canonical_url: Optional[str] = None,
20
+ keywords: Optional[str] = None,
21
+ author: Optional[str] = None,
22
+ publish_date: Optional[str] = None, # ISO 8601 format YYYY-MM-DD
23
+ noindex: bool = False,
24
+ ):
25
+ """
26
+ Renders full suite of SEO, OpenGraph, and Twitter Card meta tags.
27
+ """
28
+ request = context.get("request")
29
+ app = request.app
30
+
31
+ site_data = app.state.site_data
32
+
33
+ site_name = site_data.get("name")
34
+ site_keywords = site_data.get("keywords")
35
+ site_social = site_data.get("social")
36
+ open_graph = site_data.get("open_graph")
37
+
38
+ if site_social and len(site_social.keys()) > 0:
39
+ twitter_handle = site_social["twitter"] if "twitter" in site_social else None
40
+
41
+ if open_graph:
42
+ og_image = open_graph["og_image"] if "og_image" in open_graph else None
43
+
44
+ # 1. Resolve Data (Priority: Explicit Arg > Context Variable > Default)
45
+ meta_title = title or context.get("title") or site_name
46
+ meta_desc = description or context.get("description") or ""
47
+ meta_author = author or context.get("author") or site_name
48
+ meta_keywords = (
49
+ keywords or context.get("keywords") or site_keywords or context.get("tags")
50
+ )
51
+
52
+ meta_keywords = (
53
+ ", ".join(meta_keywords)
54
+ if isinstance(meta_keywords, list)
55
+ else str(meta_keywords)
56
+ )
57
+
58
+ # 2. Handle URLs (Absolute URLs are required for SEO/Social)
59
+ base_url = str(request.base_url).rstrip("/") if request else ""
60
+
61
+ # Resolve Image
62
+ meta_image = None
63
+ if og_image:
64
+
65
+ raw_image = image or context.get("image") or og_image
66
+ if raw_image and raw_image.startswith("http"):
67
+ meta_image = raw_image
68
+ else:
69
+ # Ensure path starts with /
70
+ if not raw_image.startswith("/"):
71
+ raw_image = "/" + raw_image
72
+ meta_image = f"{base_url}{raw_image}"
73
+
74
+ # Resolve Current URL & Canonical
75
+ current_url = str(request.url) if request else ""
76
+ final_canonical = canonical_url or current_url
77
+
78
+ # 3. Determine Content Type
79
+ # If a publish date exists, Google treats it as an Article
80
+ og_type = "article" if publish_date else "website"
81
+
82
+ # 4. Build Tags List
83
+ tags = []
84
+
85
+ # --- Standard SEO ---
86
+ tags.append(f"<title>{escape(meta_title)}</title>")
87
+ tags.append(f'<meta name="description" content="{escape(meta_desc)}">')
88
+ if meta_keywords:
89
+ tags.append(f'<meta name="keywords" content="{escape(meta_keywords)}">')
90
+ tags.append(f'<meta name="author" content="{escape(meta_author)}">')
91
+ tags.append(f'<link rel="canonical" href="{final_canonical}">')
92
+
93
+ # Robots (Block indexing if needed, e.g., for search results pages)
94
+ if noindex:
95
+ tags.append('<meta name="robots" content="noindex, nofollow">')
96
+ else:
97
+ tags.append('<meta name="robots" content="index, follow">')
98
+
99
+ # --- Open Graph (Facebook/LinkedIn) ---
100
+ if site_name:
101
+ tags.append(f'<meta property="og:site_name" content="{site_name}">')
102
+
103
+ tags.append(f'<meta property="og:type" content="{og_type}">')
104
+ tags.append(f'<meta property="og:title" content="{escape(meta_title)}">')
105
+ tags.append(f'<meta property="og:description" content="{escape(meta_desc)}">')
106
+ tags.append(f'<meta property="og:url" content="{current_url}">')
107
+
108
+ if meta_image:
109
+ tags.append(f'<meta property="og:image" content="{meta_image}">')
110
+
111
+ # --- Twitter Cards ---
112
+
113
+ tags.append('<meta name="twitter:card" content="summary_large_image">')
114
+ tags.append(f'<meta name="twitter:site" content="{twitter_handle}">')
115
+ tags.append(f'<meta name="twitter:title" content="{escape(meta_title)}">')
116
+ tags.append(f'<meta name="twitter:description" content="{escape(meta_desc)}">')
117
+
118
+ if meta_image:
119
+ tags.append(f'<meta name="twitter:image" content="{meta_image}">')
120
+
121
+ # --- Article Specifics ---
122
+ if publish_date:
123
+ tags.append(
124
+ f'<meta property="article:published_time" content="{publish_date}">'
125
+ )
126
+ tags.append(f'<meta property="article:author" content="{escape(meta_author)}">')
127
+
128
+ # --- JSON-LD Structured Data (The "Pro" Touch) ---
129
+ # This helps Google understand the page structure explicitly
130
+ json_ld_type = "Article" if og_type == "article" else "WebSite"
131
+ json_ld = f"""
132
+ <script type="application/ld+json">
133
+ {{
134
+ "@context": "https://schema.org",
135
+ "@type": "{json_ld_type}",
136
+ "headline": "{escape(meta_title)}",
137
+ "image": "{meta_image}",
138
+ "author": {{
139
+ "@type": "Person",
140
+ "name": "{escape(meta_author)}"
141
+ }},
142
+ "publisher": {{
143
+ "@type": "Organization",
144
+ "name": "{site_name}"
145
+ }},
146
+ "description": "{escape(meta_desc)}"
147
+ {f', "datePublished": "{publish_date}"' if publish_date else ''}
148
+ }}
149
+ </script>
150
+ """
151
+ tags.append(json_ld.strip())
152
+
153
+ return Markup("\n ".join(tags))
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Copyright (c) 2026 Anthony Mugendi
3
+ *
4
+ * This software is released under the MIT License.
5
+ * https://opensource.org/licenses/MIT
6
+ */
7
+
8
+ (function () {
9
+ // Configuration
10
+ var MAX_RECONNECT_DELAY = 30000; // Max 30 seconds between attempts
11
+ var INITIAL_RECONNECT_DELAY = 1000; // Start with 1 second
12
+
13
+ var reconnectDelay = INITIAL_RECONNECT_DELAY;
14
+ var reconnectTimeout;
15
+ var ws;
16
+
17
+ // Dynamic protocol (ws or wss) and host resolution
18
+ var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
19
+ var ws_url = `${protocol}//${window.location.host}/ws/hot-reload`;
20
+
21
+ function connect() {
22
+ console.log('Connecting to:', ws_url);
23
+ ws = new WebSocket(ws_url);
24
+
25
+ ws.onopen = function (event) {
26
+ console.log('Connected! Ready for hot-reloading');
27
+ // Reset reconnect delay on successful connection
28
+ reconnectDelay = INITIAL_RECONNECT_DELAY;
29
+ };
30
+
31
+ ws.onmessage = function (event) {
32
+ console.log('Received:', event.data);
33
+
34
+ // OPTION A: If you want to Reload the page (Hot Reload behavior)
35
+ if (event.data === 'reload') {
36
+ window.location.reload();
37
+ return;
38
+ }
39
+
40
+ // OPTION B: Your original logic (Append to DOM)
41
+ var messages = document.getElementById('messages');
42
+ if (messages) {
43
+ var message = document.createElement('li');
44
+ var content = document.createTextNode(event.data);
45
+ message.appendChild(content);
46
+ messages.appendChild(message);
47
+ }
48
+ };
49
+
50
+ ws.onclose = function (event) {
51
+ console.log('WebSocket Disconnected. Attempting to reconnect in ' + (reconnectDelay / 1000) + 's...');
52
+ scheduleReconnect();
53
+ };
54
+
55
+ ws.onerror = function (error) {
56
+ console.error('WebSocket error:', error);
57
+ ws.close();
58
+ };
59
+ }
60
+
61
+ function scheduleReconnect() {
62
+ // Clear any existing reconnect timeout
63
+ if (reconnectTimeout) {
64
+ clearTimeout(reconnectTimeout);
65
+ }
66
+
67
+ // Schedule reconnection with current delay
68
+ reconnectTimeout = setTimeout(function () {
69
+ connect();
70
+ // Increase delay for next attempt (exponential backoff)
71
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
72
+ }, reconnectDelay);
73
+ }
74
+
75
+ // Initial connection
76
+ connect();
77
+ })();