moosey-cms 0.1.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (37) hide show
  1. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/PKG-INFO +35 -110
  2. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/README.md +34 -109
  3. moosey_cms-0.3.0/example/content/about.md +18 -0
  4. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/templates/layout/base.html +3 -3
  5. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/pyproject.toml +1 -1
  6. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/helpers.py +127 -70
  7. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/main.py +13 -11
  8. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/models.py +1 -7
  9. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/seo.py +0 -2
  10. moosey_cms-0.1.0/example/content/about.md +0 -51
  11. moosey_cms-0.1.0/src/moosey_cms/README.md +0 -0
  12. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/.gitignore +0 -0
  13. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/.python-version +0 -0
  14. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/assets/example-1.jpeg +0 -0
  15. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/assets/example-2.jpeg +0 -0
  16. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/content/index.md +0 -0
  17. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/content/pages/features.md +0 -0
  18. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/content/posts/building-modern-apps.md +0 -0
  19. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/content/posts/index.md +0 -0
  20. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/main.py +0 -0
  21. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/templates/404.html +0 -0
  22. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/templates/components/sidebar.html +0 -0
  23. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/templates/index.html +0 -0
  24. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/templates/page.html +0 -0
  25. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/templates/post.html +0 -0
  26. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/example/templates/posts.html +0 -0
  27. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/.python-version +0 -0
  28. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/__init__.py +0 -0
  29. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/cache.py +0 -0
  30. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/file_watcher.py +0 -0
  31. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/filters.py +0 -0
  32. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/hot_reload_script.py +0 -0
  33. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/md.py +0 -0
  34. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/py.typed +0 -0
  35. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/pyproject.toml +0 -0
  36. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/src/moosey_cms/static/js/reload-script.js +0 -0
  37. {moosey_cms-0.1.0 → moosey_cms-0.3.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moosey-cms
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: cachetools>=6.2.4
@@ -162,129 +162,55 @@ A user visits **`/posts/post-1`**.
162
162
 
163
163
  **Resolution Order:**
164
164
 
165
- 1. **`templates/posts/post-1.html`** (Exact Match):
166
- Checked first. Use this if a specific article requires a unique design completely different from other posts.
167
-
168
- 2. **`templates/post.html`** (Singular Parent):
169
- The system automatically "singularizes" the parent folder name (`posts` → `post`). This is the standard template used to render individual blog items.
170
-
171
- 3. **`templates/posts.html`** (Plural Parent):
172
- If no singular template exists, the system looks for the folder's name. This allows articles to inherit the layout of their parent section if desired.
173
-
174
- 4. **`templates/page.html`** (Global Fallback):
175
- If no specific, singular, or plural templates are found, the system defaults to the generic page layout.
176
-
177
- **Important Notes:**
178
-
179
- * **The Index File:** For a directory route like `/posts` to work, a **`content/posts/index.md`** file must exist. This tells the CMS that the folder is a navigable section containing content. Without it, accessing `/posts` will return a 404 error.
180
- * **Navigation:** If `content/posts/index.md` is missing, the `posts` folder will be omitted from auto-generated menus and sidebars (`nav_items`).
181
-
182
- ### Inside a Template
183
-
184
- Your templates have access to powerful context variables:
185
-
186
- * `content`: The rendered HTML from your Markdown.
187
- * `metadata`: The YAML frontmatter from the markdown file.
188
- * `site_data`: Global site configuration.
189
- * `breadcrumbs`: Auto-generated breadcrumb navigation.
190
- * `nav_items`: List of sibling pages/folders for sidebar navigation.
191
-
192
- **Example `page.html`:**
193
-
194
- ```html
195
- {% extends "base.html" %}
196
-
197
- {% block content %}
198
- <h1>{{ title }}</h1>
199
-
200
- <!-- Render Breadcrumbs -->
201
- <nav>
202
- {% for crumb in breadcrumbs %}
203
- <a href="{{ crumb.url }}">{{ crumb.name }}</a> /
204
- {% endfor %}
205
- </nav>
206
-
207
- <!-- Render Content -->
208
- <article>
209
- {{ content | safe }}
210
- </article>
211
-
212
- <!-- Automatic Sidebar -->
213
- <aside>
214
- {% for item in nav_items %}
215
- <a href="{{ item.url }}" class="{% if item.is_active %}active{% endif %}">
216
- {{ item.name }}
217
- </a>
218
- {% endfor %}
219
- </aside>
220
- {% endblock %}
221
- ```
165
+ 1. **Frontmatter Override:** If `post-1.md` contains `template: special.html`, that template is used immediately.
166
+ 2. **Exact Match:** `templates/posts/post-1.html`.
167
+ 3. **Singular Parent:** `templates/post.html` (Perfect for generic blog posts).
168
+ 4. **Plural Parent:** `templates/posts.html` (Perfect for section indexes).
169
+ 5. **Fallback:** `templates/page.html`.
222
170
 
223
171
  ---
224
172
 
225
- ## 📝 Markdown Features
173
+ ## 📝 Frontmatter Configuration
226
174
 
227
- ### Frontmatter
228
- You can define metadata at the top of any Markdown file. These values are passed to your template.
175
+ You can control routing, visibility, and layout directly from the Markdown file YAML frontmatter.
229
176
 
230
- ```markdown
231
- ---
177
+ ### Basic Metadata
178
+ ```yaml
232
179
  title: My Amazing Post
233
180
  date: 2024-01-01
234
- tags: [fastapi, python]
235
- ---
236
-
237
- # Hello World
238
-
239
- This is content.
181
+ description: A short summary for SEO.
240
182
  ```
241
183
 
242
- ### Dynamic Content in Markdown
243
- You can use Jinja2 syntax **inside** your Markdown content! This is powered by a **Sandboxed Environment**, making it safe to use variables without exposing your server to vulnerabilities (SSTI).
244
-
245
- **Example `about.md`:**
246
- ```markdown
247
- # Welcome to {{ site_data.name }}
248
-
249
- This page was generated by **{{ site_data.author }}**.
250
- Today is {{ metadata.date.created | fancy_date }}.
251
- ```
252
-
253
- **Allowed Context:**
254
- * `site_data`: Global configuration (Name, Author, etc.)
255
- * `site_code`: Global code snippets.
256
- * `metadata`: The frontmatter of the current file.
257
- * **Filters:** All standard Moosey filters (`fancy_date`, `read_time`, etc.) are available.
258
-
259
- ### Included Extensions
260
- Moosey includes `pymdown-extensions` to provide:
261
- * Tables
262
- * Task Lists `[x]`
263
- * Emojis `:smile:`
264
- * Code Fences with highlighting
265
- * Admonitions (Alerts/Callouts)
266
- * Math/Arithmatex
184
+ ### Organization & Navigation
185
+ | Key | Type | Description |
186
+ | :--- | :--- | :--- |
187
+ | `order` | `int` | Sort order in sidebars. Lower numbers appear first. Default: `9999`. |
188
+ | `nav_title` | `str` | Short title to display in sidebars (if different from `title`). |
189
+ | `visible` | `bool` | Set to `false` to hide from sidebars/menus (page remains accessible via URL). |
190
+ | `draft` | `bool` | If `true`, the page is only visible in `development` mode. |
191
+ | `group` | `str` | Group sidebar items under a heading (requires template support). |
192
+
193
+ ### Advanced Routing
194
+ | Key | Type | Description |
195
+ | :--- | :--- | :--- |
196
+ | `template` | `str` | Force a specific template file (e.g., `template: landing.html`). |
197
+ | `external_link` | `str` | The sidebar link will point to this external URL instead of the page itself. |
198
+ | `redirect` | `str` | Alias for `external_link`. |
267
199
 
200
+ **Example:**
201
+ ```yaml
202
+ ---
203
+ title: API Documentation
204
+ nav_title: API Docs
205
+ weight: 1
206
+ group: "Developer Tools"
207
+ external_link: "https://api.mysite.com"
268
208
  ---
269
-
270
- ## 🛠️ SEO & Metadata
271
-
272
- Moosey CMS includes a robust SEO helper. In your `base.html` `<head>`, simply add:
273
-
274
- ```html
275
- <head>
276
- <!-- Automatically generates Title, Meta Description, OpenGraph,
277
- Twitter Cards, and JSON-LD Structured Data -->
278
- {{ seo() }}
279
-
280
- <!-- Or override specific values -->
281
- {{ seo(title="Custom Title", image="/static/custom.jpg") }}
282
- </head>
283
209
  ```
284
210
 
285
211
  ---
286
212
 
287
- ## 🧩 Custom Filters
213
+ ## 🧩 Custom Filters & Logic
288
214
 
289
215
  Moosey CMS comes packed with a comprehensive library of Jinja2 filters to help you format your data effortlessly.
290
216
 
@@ -345,7 +271,6 @@ The `init_cms` function accepts the following parameters:
345
271
  | `dirs` | `dict` | Dictionary containing `content` and `templates` Paths. |
346
272
  | `mode` | `str` | `"development"` (enables hot reload/no cache) or `"production"`. |
347
273
  | `site_data` | `dict` | Global data (Name, Author, Social Links). |
348
- | `site_code` | `dict` | Inject custom HTML (e.g., analytics) via `{{ site_code.footer_code }}`. |
349
274
 
350
275
  ---
351
276
 
@@ -145,129 +145,55 @@ A user visits **`/posts/post-1`**.
145
145
 
146
146
  **Resolution Order:**
147
147
 
148
- 1. **`templates/posts/post-1.html`** (Exact Match):
149
- Checked first. Use this if a specific article requires a unique design completely different from other posts.
150
-
151
- 2. **`templates/post.html`** (Singular Parent):
152
- The system automatically "singularizes" the parent folder name (`posts` → `post`). This is the standard template used to render individual blog items.
153
-
154
- 3. **`templates/posts.html`** (Plural Parent):
155
- If no singular template exists, the system looks for the folder's name. This allows articles to inherit the layout of their parent section if desired.
156
-
157
- 4. **`templates/page.html`** (Global Fallback):
158
- If no specific, singular, or plural templates are found, the system defaults to the generic page layout.
159
-
160
- **Important Notes:**
161
-
162
- * **The Index File:** For a directory route like `/posts` to work, a **`content/posts/index.md`** file must exist. This tells the CMS that the folder is a navigable section containing content. Without it, accessing `/posts` will return a 404 error.
163
- * **Navigation:** If `content/posts/index.md` is missing, the `posts` folder will be omitted from auto-generated menus and sidebars (`nav_items`).
164
-
165
- ### Inside a Template
166
-
167
- Your templates have access to powerful context variables:
168
-
169
- * `content`: The rendered HTML from your Markdown.
170
- * `metadata`: The YAML frontmatter from the markdown file.
171
- * `site_data`: Global site configuration.
172
- * `breadcrumbs`: Auto-generated breadcrumb navigation.
173
- * `nav_items`: List of sibling pages/folders for sidebar navigation.
174
-
175
- **Example `page.html`:**
176
-
177
- ```html
178
- {% extends "base.html" %}
179
-
180
- {% block content %}
181
- <h1>{{ title }}</h1>
182
-
183
- <!-- Render Breadcrumbs -->
184
- <nav>
185
- {% for crumb in breadcrumbs %}
186
- <a href="{{ crumb.url }}">{{ crumb.name }}</a> /
187
- {% endfor %}
188
- </nav>
189
-
190
- <!-- Render Content -->
191
- <article>
192
- {{ content | safe }}
193
- </article>
194
-
195
- <!-- Automatic Sidebar -->
196
- <aside>
197
- {% for item in nav_items %}
198
- <a href="{{ item.url }}" class="{% if item.is_active %}active{% endif %}">
199
- {{ item.name }}
200
- </a>
201
- {% endfor %}
202
- </aside>
203
- {% endblock %}
204
- ```
148
+ 1. **Frontmatter Override:** If `post-1.md` contains `template: special.html`, that template is used immediately.
149
+ 2. **Exact Match:** `templates/posts/post-1.html`.
150
+ 3. **Singular Parent:** `templates/post.html` (Perfect for generic blog posts).
151
+ 4. **Plural Parent:** `templates/posts.html` (Perfect for section indexes).
152
+ 5. **Fallback:** `templates/page.html`.
205
153
 
206
154
  ---
207
155
 
208
- ## 📝 Markdown Features
156
+ ## 📝 Frontmatter Configuration
209
157
 
210
- ### Frontmatter
211
- You can define metadata at the top of any Markdown file. These values are passed to your template.
158
+ You can control routing, visibility, and layout directly from the Markdown file YAML frontmatter.
212
159
 
213
- ```markdown
214
- ---
160
+ ### Basic Metadata
161
+ ```yaml
215
162
  title: My Amazing Post
216
163
  date: 2024-01-01
217
- tags: [fastapi, python]
218
- ---
219
-
220
- # Hello World
221
-
222
- This is content.
164
+ description: A short summary for SEO.
223
165
  ```
224
166
 
225
- ### Dynamic Content in Markdown
226
- You can use Jinja2 syntax **inside** your Markdown content! This is powered by a **Sandboxed Environment**, making it safe to use variables without exposing your server to vulnerabilities (SSTI).
227
-
228
- **Example `about.md`:**
229
- ```markdown
230
- # Welcome to {{ site_data.name }}
231
-
232
- This page was generated by **{{ site_data.author }}**.
233
- Today is {{ metadata.date.created | fancy_date }}.
234
- ```
235
-
236
- **Allowed Context:**
237
- * `site_data`: Global configuration (Name, Author, etc.)
238
- * `site_code`: Global code snippets.
239
- * `metadata`: The frontmatter of the current file.
240
- * **Filters:** All standard Moosey filters (`fancy_date`, `read_time`, etc.) are available.
241
-
242
- ### Included Extensions
243
- Moosey includes `pymdown-extensions` to provide:
244
- * Tables
245
- * Task Lists `[x]`
246
- * Emojis `:smile:`
247
- * Code Fences with highlighting
248
- * Admonitions (Alerts/Callouts)
249
- * Math/Arithmatex
167
+ ### Organization & Navigation
168
+ | Key | Type | Description |
169
+ | :--- | :--- | :--- |
170
+ | `order` | `int` | Sort order in sidebars. Lower numbers appear first. Default: `9999`. |
171
+ | `nav_title` | `str` | Short title to display in sidebars (if different from `title`). |
172
+ | `visible` | `bool` | Set to `false` to hide from sidebars/menus (page remains accessible via URL). |
173
+ | `draft` | `bool` | If `true`, the page is only visible in `development` mode. |
174
+ | `group` | `str` | Group sidebar items under a heading (requires template support). |
175
+
176
+ ### Advanced Routing
177
+ | Key | Type | Description |
178
+ | :--- | :--- | :--- |
179
+ | `template` | `str` | Force a specific template file (e.g., `template: landing.html`). |
180
+ | `external_link` | `str` | The sidebar link will point to this external URL instead of the page itself. |
181
+ | `redirect` | `str` | Alias for `external_link`. |
250
182
 
183
+ **Example:**
184
+ ```yaml
185
+ ---
186
+ title: API Documentation
187
+ nav_title: API Docs
188
+ weight: 1
189
+ group: "Developer Tools"
190
+ external_link: "https://api.mysite.com"
251
191
  ---
252
-
253
- ## 🛠️ SEO & Metadata
254
-
255
- Moosey CMS includes a robust SEO helper. In your `base.html` `<head>`, simply add:
256
-
257
- ```html
258
- <head>
259
- <!-- Automatically generates Title, Meta Description, OpenGraph,
260
- Twitter Cards, and JSON-LD Structured Data -->
261
- {{ seo() }}
262
-
263
- <!-- Or override specific values -->
264
- {{ seo(title="Custom Title", image="/static/custom.jpg") }}
265
- </head>
266
192
  ```
267
193
 
268
194
  ---
269
195
 
270
- ## 🧩 Custom Filters
196
+ ## 🧩 Custom Filters & Logic
271
197
 
272
198
  Moosey CMS comes packed with a comprehensive library of Jinja2 filters to help you format your data effortlessly.
273
199
 
@@ -328,7 +254,6 @@ The `init_cms` function accepts the following parameters:
328
254
  | `dirs` | `dict` | Dictionary containing `content` and `templates` Paths. |
329
255
  | `mode` | `str` | `"development"` (enables hot reload/no cache) or `"production"`. |
330
256
  | `site_data` | `dict` | Global data (Name, Author, Social Links). |
331
- | `site_code` | `dict` | Inject custom HTML (e.g., analytics) via `{{ site_code.footer_code }}`. |
332
257
 
333
258
  ---
334
259
 
@@ -0,0 +1,18 @@
1
+ ---
2
+ title: About Us
3
+ description: We are a team of digital nomads building tools for creators.
4
+ ---
5
+
6
+ ## Our Mission
7
+
8
+ We believe that content management should be simple, fast, and fun. We stripped away the databases, the complicated dashboards, and the plugin hell to bring you **Moosey CMS**.
9
+
10
+ ### The Team
11
+
12
+ ![Office](https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&q=80)
13
+
14
+ We are a distributed team working from 4 different continents.
15
+
16
+ * **Anthony:** Lead Developer
17
+ * **Sarah:** Design System
18
+ * **Mike:** Devops
@@ -48,9 +48,9 @@
48
48
 
49
49
  <!-- Desktop Menu -->
50
50
  <div class="hidden md:flex space-x-8 items-center">
51
- <a href="/pages/features" class="text-sm font-medium text-slate-600 hover:text-primary transition">Features</a>
52
- <a href="/posts" class="text-sm font-medium text-slate-600 hover:text-primary transition">Blog</a>
53
- <a href="/about" class="text-sm font-medium text-slate-600 hover:text-primary transition">About</a>
51
+ <a href="/about" class="text-sm font-medium text-slate-600 hover:text-primary transition">About</a>
52
+ <a href="/pages/features" class="text-sm font-medium text-slate-600 hover:text-primary transition">Features</a>
53
+ <a href="/posts" class="text-sm font-medium text-slate-600 hover:text-primary transition">Blog</a>
54
54
  <a href="https://github.com/mugendi/moosey-cms" class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-full hover:bg-blue-700 transition shadow-lg shadow-blue-500/30">Get Started</a>
55
55
  </div>
56
56
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "moosey-cms"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -8,7 +8,7 @@ https://opensource.org/licenses/MIT
8
8
  import os
9
9
  import frontmatter
10
10
  from pathlib import Path
11
- from typing import List, Dict, Any
11
+ from typing import List, Dict, Any, Optional
12
12
  from jinja2 import TemplateNotFound
13
13
  from jinja2.sandbox import SandboxedEnvironment
14
14
  from datetime import datetime
@@ -82,63 +82,55 @@ def get_secure_target(user_path: str, relative_to_path: Path) -> Path:
82
82
 
83
83
 
84
84
  @cache_fn(debug=cache_debug)
85
- def find_best_template(templates, path_str: str, is_index_file: bool = False) -> str:
85
+ def find_best_template(templates, path_str: str, is_index_file: bool = False, frontmatter: Optional[dict] = None) -> str:
86
86
  """
87
- Determines the best template based on the path hierarchy.
88
- path_str: The clean relative path (e.g. 'posts/stories/my-story')
89
- is_index_file: True if we are rendering a directory index (e.g. 'posts/stories/index.md')
87
+ Determines the best template based on hierarchy or Frontmatter override.
90
88
  """
89
+
90
+ # 0. Check Frontmatter Override First
91
+ if frontmatter and frontmatter.get('template'):
92
+ candidate = frontmatter.get('template')
93
+ # Ensure it ends with .html if user forgot
94
+ if not candidate.endswith('.html'):
95
+ candidate += '.html'
96
+
97
+ if template_exists(templates, candidate):
98
+ return candidate
91
99
 
92
100
  parts = [p for p in path_str.strip("/").split("/") if p]
93
101
 
94
- # use index,html for home...
95
- if len(parts)==0:
102
+ if len(parts) == 0:
96
103
  index_candidate = 'index.html'
97
104
  if template_exists(templates, index_candidate):
98
105
  return index_candidate
99
106
 
100
-
101
- # 1. Exact Match (Specific File Override)
102
- # We skip this for index files, as their "Exact Match" is essentially
103
- # the folder name check in step 2B.
107
+ # 1. Exact Match
104
108
  if not is_index_file:
105
109
  candidate = "/".join(parts) + ".html"
106
-
107
110
  if template_exists(templates, candidate):
108
111
  return candidate
109
-
110
- # If we didn't find specific 'my-story.html',
111
- # pop the filename so we start searching from parent 'stories'
112
112
  if parts:
113
113
  parts.pop()
114
114
 
115
115
  # 2. Recursive Parent Search
116
116
  while len(parts) > 0:
117
- current_folder = parts[-1] # e.g. "stories"
118
- parent_path = parts[:-1] # e.g. ["posts"]
117
+ current_folder = parts[-1]
118
+ parent_path = parts[:-1]
119
119
 
120
- # A. Singular Check (The "Item" Template)
121
- # e.g. "posts/story.html"
122
- # Only valid if we are NOT rendering a directory list (index file)
120
+ # A. Singular Check
123
121
  if not is_index_file:
124
122
  singular_name = singularize(current_folder)
125
123
  singular_candidate = "/".join(parent_path + [singular_name]) + ".html"
126
-
127
- print('>>>>parts', parts)
128
-
129
124
  if template_exists(templates, singular_candidate):
130
125
  return singular_candidate
131
126
 
132
- # B. Plural/Folder Check (The "Section" Template)
133
- # e.g. "posts/stories.html"
127
+ # B. Plural/Folder Check
134
128
  plural_candidate = "/".join(parts) + ".html"
135
129
  if template_exists(templates, plural_candidate):
136
130
  return plural_candidate
137
131
 
138
- # Traverse up
139
132
  parts.pop()
140
133
 
141
-
142
134
  # 3. Final Fallback
143
135
  return "page.html"
144
136
 
@@ -147,17 +139,16 @@ def find_best_template(templates, path_str: str, is_index_file: bool = False) ->
147
139
  def parse_markdown_file(file):
148
140
  data = frontmatter.load(file)
149
141
  stats = file.stat()
150
- # Last date
151
- data.metadata["date"] = {
152
- "updated": datetime.fromtimestamp(stats.st_mtime),
153
- "created": datetime.fromtimestamp(stats.st_ctime),
154
- }
155
- # add slug
142
+
143
+ # Ensure date metadata exists
144
+ if "date" not in data.metadata or not isinstance(data.metadata["date"], dict):
145
+ data.metadata["date"] = {}
146
+
147
+ data.metadata["date"]["updated"] = datetime.fromtimestamp(stats.st_mtime)
148
+ data.metadata["date"]["created"] = datetime.fromtimestamp(stats.st_ctime)
156
149
  data.metadata["slug"] = slugify(str(file.stem))
157
150
 
158
151
  data.html = parse_markdown(data.content)
159
-
160
-
161
152
  return data
162
153
 
163
154
 
@@ -176,8 +167,7 @@ def ensure_sandbox_filters(main_templates):
176
167
  # template_render_content only in sandbox mode
177
168
  @cache_fn(debug=cache_debug)
178
169
  def template_render_content(templates, content, data, safe=True):
179
- if not content:
180
- return ""
170
+ if not content: return ""
181
171
 
182
172
  try:
183
173
  # Sync filters/globals from the main app to our sandbox
@@ -196,51 +186,118 @@ def template_render_content(templates, content, data, safe=True):
196
186
 
197
187
  @cache_fn(debug=cache_debug)
198
188
  def get_directory_navigation(
199
- physical_folder: Path, current_url: str, relative_to_path: Path
189
+ physical_folder: Path, current_url: str, relative_to_path: Path, mode: str = "production"
200
190
  ) -> List[Dict[str, Any]]:
201
191
  """
202
- Scans the folder containing the current file to generate a sidebar menu.
192
+ Scans folder for sidebar menu. Supports advanced frontmatter features.
203
193
  """
204
194
  if not physical_folder.exists() or not physical_folder.is_dir():
205
195
  return []
206
196
 
207
197
  items = []
208
198
  try:
209
- # Iterate over files in the folder
210
- for entry in sorted(
211
- physical_folder.iterdir(), key=lambda x: (not x.is_dir(), x.name)
212
- ):
213
- if entry.name.startswith("."):
214
- continue # Skip hidden
215
-
216
- # Skip self-reference if inside index
217
- if entry.name == "index.md":
218
- continue
219
-
220
- # if dir only list if it has an index.md
221
- if entry.is_dir() and not (entry / 'index.md').exists() :
222
- continue
199
+ for entry in physical_folder.iterdir():
200
+ if entry.name.startswith("."): continue
201
+ if entry.name == "index.md": continue
202
+ if entry.is_dir() and not (entry / 'index.md').exists(): continue
203
+
204
+ # Determine Metadata Source
205
+ meta_file = entry / 'index.md' if entry.is_dir() else entry
206
+
207
+ # Defaults
208
+ sort_order = 9999
209
+ display_title = entry.stem.replace("-", " ").title()
210
+ nav_group = None
211
+ external_url = None
212
+ is_visible = True
213
+ target = "_self"
223
214
 
215
+ try:
216
+ # Load minimal metadata
217
+ post = frontmatter.load(meta_file)
218
+ meta = post.metadata
219
+
220
+ # 1. Visibility & Draft Check
221
+ if meta.get('visible') is False:
222
+ is_visible = False
223
+
224
+ if meta.get('draft') is True and mode != 'development':
225
+ is_visible = False
226
+
227
+
228
+ if not is_visible:
229
+ continue
230
+
231
+ # 2. Ordering
232
+ if 'order' in meta: sort_order = int(meta['order'])
233
+
234
+ # 3. Titles & Grouping
235
+ if 'nav_title' in meta: display_title = meta['nav_title']
236
+ elif 'title' in meta: display_title = meta['title']
237
+
238
+ nav_group = meta.get('group') or ""
239
+
240
+ # 4. External Links
241
+ if 'external_link' in meta:
242
+ external_url = meta['external_link']
243
+ target = "_blank"
244
+ elif 'redirect' in meta:
245
+ external_url = meta['redirect']
246
+
247
+ except Exception:
248
+ pass
224
249
 
225
250
  # Build URL
226
- try:
227
- rel_path = entry.relative_to(relative_to_path)
228
- # Strip .md for URL, keep pure for directories
229
- url_slug = str(rel_path).replace(".md", "").replace("\\", "/")
230
- entry_url = f"/{url_slug}"
231
- except ValueError:
232
- continue
233
-
234
- items.append(
235
- {
236
- "name": entry.stem.replace("-", " ").title(),
237
- "url": entry_url,
238
- "is_active": entry_url == current_url,
239
- "is_dir": entry.is_dir(),
240
- }
241
- )
251
+ if external_url:
252
+ entry_url = external_url
253
+ is_active = False # External links are never 'active' page
254
+ else:
255
+ try:
256
+ rel_path = entry.relative_to(relative_to_path)
257
+ url_slug = str(rel_path).replace(".md", "").replace("\\", "/")
258
+ entry_url = f"/{url_slug}"
259
+ is_active = (entry_url == current_url)
260
+ except ValueError:
261
+ continue
262
+
263
+ items.append({
264
+ "name": display_title,
265
+ "url": entry_url,
266
+ "is_active": is_active,
267
+ "is_dir": entry.is_dir(),
268
+ "order": sort_order,
269
+ "group": nav_group,
270
+ "target": target
271
+ })
272
+
273
+ # Sorting: order first, then Name
274
+ # items.sort(key=lambda x: (x['order'], x['name']))
275
+ group_min_orders = {}
276
+
277
+ for item in items:
278
+ g = item['group']
279
+ w = item['order']
280
+ # If we haven't seen this group, or if this item is lighter (more important)
281
+ if g not in group_min_orders or w < group_min_orders[g]:
282
+ group_min_orders[g] = w
283
+
284
+ # 2. Sort the list with a Tuple Key
285
+ items.sort(key=lambda x: (
286
+ # Primary: Group order (Groups with important items float to top)
287
+ group_min_orders[x['group']],
288
+
289
+ # Secondary: Group Name (Keep groups clustered together)
290
+ x['group'],
291
+
292
+ # Tertiary: Item order (Sort items inside the group)
293
+ x['order'],
294
+
295
+ # Quaternary: Item Name (Alphabetical fallback)
296
+ x['name']
297
+ ))
298
+
242
299
  except OSError:
243
- pass # Ignore permission errors
300
+ pass
244
301
 
245
302
  return items
246
303
 
@@ -46,7 +46,7 @@ class ConnectionManager:
46
46
  self.disconnect(connection)
47
47
 
48
48
 
49
- from .models import CMSConfig, Dirs, SiteCode, SiteData
49
+ from .models import CMSConfig, Dirs, SiteData
50
50
 
51
51
 
52
52
  def init_cms(
@@ -56,7 +56,6 @@ def init_cms(
56
56
  dirs: Dirs,
57
57
  mode: str,
58
58
  site_data: SiteData = {},
59
- site_code: SiteCode = {},
60
59
  ):
61
60
 
62
61
  # validate dirs inputs
@@ -65,8 +64,7 @@ def init_cms(
65
64
  port=port,
66
65
  dirs=dirs,
67
66
  mode=mode,
68
- site_data=site_data,
69
- site_code=site_code,
67
+ site_data=site_data
70
68
  )
71
69
 
72
70
  # resolve paths
@@ -77,13 +75,11 @@ def init_cms(
77
75
  templates = Jinja2Templates(directory=str(dirs["templates"]), extensions=[])
78
76
 
79
77
  # Important for filters like seo to access them
80
- app.state.site_code = site_code
81
78
  app.state.site_data = site_data
82
79
  app.state.mode = mode
83
80
 
84
81
  # This ensures site_data is available in 404.html and base.html automatically
85
82
  templates.env.globals["site_data"] = site_data
86
- templates.env.globals["site_code"] = site_code
87
83
  templates.env.globals["mode"] = mode
88
84
 
89
85
  # Register all custom filters once
@@ -211,15 +207,19 @@ def init_routes(app, dirs: Dirs, templates, mode, reloader):
211
207
  md_data = helpers.parse_markdown_file(target_file)
212
208
  front_matter = md_data.metadata
213
209
 
210
+ # never render drafts in production
211
+ if front_matter.get("draft") is True and mode != "development":
212
+ return templates.TemplateResponse(
213
+ "404.html", {"request": request}, status_code=404
214
+ )
215
+
214
216
  # Merge front matter
215
217
  template_data = {
216
218
  **template_data,
217
219
  **front_matter,
218
- "site_data": app.state.site_data,
219
- "site_code": app.state.site_code,
220
+ "site_data": app.state.site_data
220
221
  }
221
222
 
222
-
223
223
  # Render jinja inside frontmatter strings
224
224
  for k in front_matter:
225
225
  if isinstance(front_matter[k], str):
@@ -227,7 +227,6 @@ def init_routes(app, dirs: Dirs, templates, mode, reloader):
227
227
  templates, front_matter[k], template_data, False
228
228
  )
229
229
 
230
-
231
230
  html_content = md_data.html
232
231
 
233
232
  # Render jinja inside markdown body
@@ -248,17 +247,20 @@ def init_routes(app, dirs: Dirs, templates, mode, reloader):
248
247
  physical_folder=nav_folder,
249
248
  current_url=current_url,
250
249
  relative_to_path=dirs["content"],
250
+ mode=mode,
251
251
  )
252
252
  breadcrumbs = helpers.get_breadcrumbs(full_path)
253
253
 
254
254
  # 7. Find Template
255
255
  search_path = "" if clean_path == "index" else clean_path
256
256
  template_name = helpers.find_best_template(
257
- templates, search_path, is_index_file=is_index
257
+ templates, search_path, is_index_file=is_index, frontmatter=front_matter
258
258
  )
259
259
 
260
260
  template_data = {**template_data, **md_data}
261
261
 
262
+ # pprint(nav_items)
263
+
262
264
  # 8. Render
263
265
  return templates.TemplateResponse(
264
266
  template_name,
@@ -44,12 +44,6 @@ class SiteData(BaseModel):
44
44
  social: Optional[SocialConfig] = Field(..., description="Social media links")
45
45
 
46
46
 
47
- class SiteCode(BaseModel):
48
- """Custom HTML/code snippets for the site"""
49
- styled_name: Optional[str] = Field(None, description="Styled HTML name")
50
- header_code: Optional[str] = Field(None, description="Code to inject in header")
51
- footer_code: Optional[str] = Field(None, description="Code to inject in footer")
52
-
53
47
 
54
48
  class Dirs(BaseModel):
55
49
  """Directory paths configuration"""
@@ -76,4 +70,4 @@ class CMSConfig(BaseModel):
76
70
  description="Application mode"
77
71
  )
78
72
  site_data: Optional[SiteData] = Field(..., description="Site metadata")
79
- site_code: Optional[SiteCode] = Field(..., description="Custom site code")
73
+ # site_code: Optional[SiteCode] = Field(..., description="Custom site code")
@@ -29,8 +29,6 @@ def seo_tags(
29
29
  app = request.app
30
30
 
31
31
  site_data = app.state.site_data
32
- site_code = app.state.site_code
33
-
34
32
 
35
33
  site_name = site_data.get("name")
36
34
  site_keywords = site_data.get("keywords")
@@ -1,51 +0,0 @@
1
- ---
2
- title: Features
3
- description: Discover why Moosey CMS is the perfect choice for your next project.
4
- date: 2026-01-21
5
- ---
6
-
7
- ## Why Choose Moosey?
8
-
9
- Moosey isn't just another CMS. It's a **hybrid static-dynamic engine**. It combines the developer experience of a Static Site Generator (like Jekyll or Hugo) with the power of a live Python server.
10
-
11
- ### ⚡ 1. Hot Reloading
12
- Change a template. Change a Markdown file. Change a CSS file.
13
- **Boom.** The browser updates instantly without a full page refresh. We use WebSockets to inject changes directly into the DOM.
14
-
15
- ### 🎨 2. Smart Templating
16
- Stop worrying about which template to use. Our **Waterfall Logic** figures it out:
17
-
18
- 1. Look for specific file override (e.g., `features.html`)
19
- 2. Look for folder-level layout
20
- 3. Fallback to `page.html`
21
-
22
- ### 📝 3. Rich Content Support
23
-
24
- We support GitHub Flavored Markdown and more out of the box.
25
-
26
- #### Task Lists
27
- Keep track of your deployment status directly in your docs:
28
- - [x] Install Python 3.12
29
- - [x] Install Moosey CMS
30
- - [x] Configure `main.py`
31
- - [ ] Deploy to production
32
-
33
- #### Data Tables
34
- Perfect for comparison or pricing pages.
35
-
36
- | Feature | Moosey CMS | WordPress | Static Gen |
37
- | :--- | :---: | :---: | :---: |
38
- | **Database** | ❌ No | ✅ Yes | ❌ No |
39
- | **Dynamic** | ✅ Yes | ✅ Yes | ❌ No |
40
- | **Speed** | 🚀 Fast | 🐌 Slow | 🚀 Fast |
41
- | **Python** | 🐍 Yes | 🐘 No | 🤷 Maybe |
42
-
43
- #### Admonitions & Alerts
44
- Call out important information to your users.
45
-
46
- !!! tip "Pro Tip"
47
- You can use **Jinja2 variables** inside your Markdown content!
48
- For example, this site is managed by: **{{ site_data.author }}**.
49
-
50
- !!! warning "Heads Up"
51
- Because there is no database, all content must be committed to Git. This is a feature, not a bug
File without changes
File without changes
File without changes
File without changes
File without changes