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/.python-version +1 -0
- moosey_cms/__init__.py +12 -0
- moosey_cms/cache.py +68 -0
- moosey_cms/file_watcher.py +31 -0
- moosey_cms/filters.py +522 -0
- moosey_cms/helpers.py +313 -0
- moosey_cms/hot_reload_script.py +91 -0
- moosey_cms/main.py +283 -0
- moosey_cms/md.py +192 -0
- moosey_cms/models.py +73 -0
- moosey_cms/py.typed +0 -0
- moosey_cms/pyproject.toml +28 -0
- moosey_cms/seo.py +153 -0
- moosey_cms/static/js/reload-script.js +77 -0
- moosey_cms-0.3.0.dist-info/METADATA +295 -0
- moosey_cms-0.3.0.dist-info/RECORD +17 -0
- moosey_cms-0.3.0.dist-info/WHEEL +4 -0
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
|
+
"<3": "❤️", # Handle HTML escaped version just in case
|
|
103
|
+
"</3": "💔",
|
|
104
|
+
"</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("<", "<"))
|
|
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
|
+
})();
|