moosey-cms 0.1.0__py3-none-any.whl → 0.2.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/helpers.py CHANGED
@@ -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,95 @@ 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_weight = 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_weight = 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')
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
+ "weight": sort_weight,
269
+ "group": nav_group,
270
+ "target": target
271
+ })
272
+
273
+ # Sorting: Weight first, then Name
274
+ items.sort(key=lambda x: (x['weight'], x['name']))
275
+
242
276
  except OSError:
243
- pass # Ignore permission errors
277
+ pass
244
278
 
245
279
  return items
246
280
 
moosey_cms/main.py CHANGED
@@ -211,6 +211,12 @@ def init_routes(app, dirs: Dirs, templates, mode, reloader):
211
211
  md_data = helpers.parse_markdown_file(target_file)
212
212
  front_matter = md_data.metadata
213
213
 
214
+ # never render drafts in production
215
+ if front_matter.get("draft") is True and mode != "development":
216
+ return templates.TemplateResponse(
217
+ "404.html", {"request": request}, status_code=404
218
+ )
219
+
214
220
  # Merge front matter
215
221
  template_data = {
216
222
  **template_data,
@@ -219,7 +225,6 @@ def init_routes(app, dirs: Dirs, templates, mode, reloader):
219
225
  "site_code": app.state.site_code,
220
226
  }
221
227
 
222
-
223
228
  # Render jinja inside frontmatter strings
224
229
  for k in front_matter:
225
230
  if isinstance(front_matter[k], str):
@@ -227,7 +232,6 @@ def init_routes(app, dirs: Dirs, templates, mode, reloader):
227
232
  templates, front_matter[k], template_data, False
228
233
  )
229
234
 
230
-
231
235
  html_content = md_data.html
232
236
 
233
237
  # Render jinja inside markdown body
@@ -248,13 +252,14 @@ def init_routes(app, dirs: Dirs, templates, mode, reloader):
248
252
  physical_folder=nav_folder,
249
253
  current_url=current_url,
250
254
  relative_to_path=dirs["content"],
255
+ mode=mode,
251
256
  )
252
257
  breadcrumbs = helpers.get_breadcrumbs(full_path)
253
258
 
254
259
  # 7. Find Template
255
260
  search_path = "" if clean_path == "index" else clean_path
256
261
  template_name = helpers.find_best_template(
257
- templates, search_path, is_index_file=is_index
262
+ templates, search_path, is_index_file=is_index, frontmatter=front_matter
258
263
  )
259
264
 
260
265
  template_data = {**template_data, **md_data}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moosey-cms
3
- Version: 0.1.0
3
+ Version: 0.2.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
 
@@ -1,18 +1,17 @@
1
1
  moosey_cms/.python-version,sha256=e1X45ntWI8S-8_ppEojalDfXnTq6FW3kjUgdsyrH0W0,5
2
- moosey_cms/README.md,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
2
  moosey_cms/__init__.py,sha256=y7gzxC1LB7qRmjqJHJpN4kEqBNAbuIwNc4xeEI2clMY,184
4
3
  moosey_cms/cache.py,sha256=YI6rRb4OVi-Mb1CmMW-jRz0CC9U6YZyszqLmjqLOsq8,2067
5
4
  moosey_cms/file_watcher.py,sha256=ViadvrDD2y0MN9VJlz9Kkp31qFkYBhDCoK7xtdA4tAY,923
6
5
  moosey_cms/filters.py,sha256=QIHeffZAxn4KqQE4zwR4D7njE96L-oeqHc5DYrYgCpw,15983
7
- moosey_cms/helpers.py,sha256=fzBUEt7m3mc9n_Ilyndv13CgJaBqL-FTiqC2XZYFp7Q,8069
6
+ moosey_cms/helpers.py,sha256=nUP9wPrBWyfsyaTYgYSBVaWcvjgz4Gf0H-SE0DXmCeE,9438
8
7
  moosey_cms/hot_reload_script.py,sha256=Dflj5hgHVkVOfjeU7wzEUeVTt684nj22et8jKzVFEGw,2987
9
- moosey_cms/main.py,sha256=xSNZKdv9zjcXsnpEQmD8p4-Ko2W5zCS9WQMQbrUOiaQ,8793
8
+ moosey_cms/main.py,sha256=Xdxx6RpEWRz1t2RGfpYvQRmgt12mfegUWk86drHHHGk,9104
10
9
  moosey_cms/md.py,sha256=m857SKApJkK62wNrMVsypuJAqumbBt5GuPvcnuN1O6w,4970
11
10
  moosey_cms/models.py,sha256=Q4MRJ32Zy9GDwnPuHr0VJOuSCN8PlccpTOOXQaZqYqU,3392
12
11
  moosey_cms/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
12
  moosey_cms/pyproject.toml,sha256=lFRsY2yJOKtDWJc-Mfq9s0teGrhmB3ALDJyMltjBOGg,735
14
13
  moosey_cms/seo.py,sha256=ubuxb9nTGr3a5a6--zUMwxlZfYBOoGDJCZJeZSa0Bo4,5283
15
14
  moosey_cms/static/js/reload-script.js,sha256=hnrVXEWeTK-Y2vLeADmtlZ7fOXpDJMF-0zK09o3mrOA,2247
16
- moosey_cms-0.1.0.dist-info/METADATA,sha256=HT4jvuzgMVoRX43egWiwO-fQJfjmX7wXfvR4OsE6WTE,13096
17
- moosey_cms-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
18
- moosey_cms-0.1.0.dist-info/RECORD,,
15
+ moosey_cms-0.2.0.dist-info/METADATA,sha256=EC4s5kqVGf-fzOVQj6I3_vs8ZXCdiYVORLKUYcj1SqU,10998
16
+ moosey_cms-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
17
+ moosey_cms-0.2.0.dist-info/RECORD,,
moosey_cms/README.md DELETED
File without changes