vyasa 0.3.6__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.
vyasa/helpers.py ADDED
@@ -0,0 +1,349 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import tomllib
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ import frontmatter
8
+ from loguru import logger
9
+
10
+ def slug_to_title(s: str, abbreviations=None) -> str:
11
+ abbreviations = abbreviations or []
12
+ abbrev_set = {str(word).strip().lower() for word in abbreviations if str(word).strip()}
13
+ words = s.replace('-', ' ').replace('_', ' ').split()
14
+ titled = []
15
+ for word in words:
16
+ lowered = word.lower()
17
+ if lowered in abbrev_set:
18
+ titled.append(word.upper())
19
+ elif word.isupper():
20
+ titled.append(word)
21
+ else:
22
+ titled.append(word[0].upper() + word[1:])
23
+ return ' '.join(titled)
24
+
25
+ def _strip_inline_markdown(text: str) -> str:
26
+ cleaned = text or ""
27
+ cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned)
28
+ cleaned = re.sub(r'\[([^\]]+)\]\[[^\]]*\]', r'\1', cleaned)
29
+ cleaned = re.sub(r'`([^`]+)`', r'\1', cleaned)
30
+ cleaned = re.sub(r'\*\*([^*]+)\*\*', r'\1', cleaned)
31
+ cleaned = re.sub(r'__([^_]+)__', r'\1', cleaned)
32
+ cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
33
+ cleaned = re.sub(r'_([^_]+)_', r'\1', cleaned)
34
+ cleaned = re.sub(r'~~([^~]+)~~', r'\1', cleaned)
35
+ return cleaned
36
+
37
+ def _plain_text_from_html(text: str) -> str:
38
+ import html
39
+ cleaned = re.sub(r'<[^>]+>', '', text or "")
40
+ return html.unescape(cleaned)
41
+
42
+ def text_to_anchor(text: str) -> str:
43
+ """Convert text to anchor slug"""
44
+ cleaned = _strip_inline_markdown(text)
45
+ cleaned = _plain_text_from_html(cleaned)
46
+ return re.sub(r'[^\w\s-]', '', cleaned.lower()).replace(' ', '-')
47
+
48
+ def _unique_anchor(base: str, counts: dict[str, int]) -> str:
49
+ if not base:
50
+ base = "section"
51
+ current = counts.get(base, 0) + 1
52
+ counts[base] = current
53
+ return base if current == 1 else f"{base}-{current}"
54
+
55
+ _frontmatter_cache: dict[str, tuple[float, tuple[dict, str]]] = {}
56
+
57
+ def parse_frontmatter(file_path: str | Path):
58
+ """Parse frontmatter from a markdown file with caching"""
59
+ import time
60
+ start_time = time.time()
61
+
62
+ file_path = Path(file_path)
63
+ cache_key = str(file_path)
64
+ mtime = file_path.stat().st_mtime
65
+
66
+ if cache_key in _frontmatter_cache:
67
+ cached_mtime, cached_data = _frontmatter_cache[cache_key]
68
+ if cached_mtime == mtime:
69
+ elapsed = (time.time() - start_time) * 1000
70
+ logger.debug(f"[DEBUG] parse_frontmatter CACHE HIT for {file_path.name} ({elapsed:.2f}ms)")
71
+ return cached_data
72
+
73
+ try:
74
+ with open(file_path, 'r', encoding='utf-8') as f:
75
+ post = frontmatter.load(f)
76
+ result = (post.metadata, post.content)
77
+ _frontmatter_cache[cache_key] = (mtime, result)
78
+ elapsed = (time.time() - start_time) * 1000
79
+ logger.debug(f"[DEBUG] parse_frontmatter READ FILE {file_path.name} ({elapsed:.2f}ms)")
80
+ return result
81
+ except Exception as e:
82
+ print(f"Error parsing frontmatter from {file_path}: {e}")
83
+ return {}, open(file_path).read()
84
+
85
+ def get_post_title(file_path: str | Path, abbreviations=None) -> str:
86
+ """Get post title from frontmatter or filename"""
87
+ metadata, _ = parse_frontmatter(file_path)
88
+ file_path = Path(file_path)
89
+ return metadata.get('title', slug_to_title(file_path.stem, abbreviations=abbreviations))
90
+
91
+ @lru_cache(maxsize=128)
92
+ def _cached_vyasa_config(path_str: str, mtime: float):
93
+ path = Path(path_str)
94
+ try:
95
+ with path.open("rb") as f:
96
+ return tomllib.load(f)
97
+ except Exception:
98
+ return {}
99
+
100
+ def _normalize_vyasa_config(parsed):
101
+ config = {
102
+ "order": [],
103
+ "sort": "name_asc",
104
+ "folders_first": True,
105
+ "folders_always_first": False,
106
+ "layout_max_width": None,
107
+ "abbreviations": None,
108
+ }
109
+ if not isinstance(parsed, dict):
110
+ return config
111
+
112
+ order = parsed.get("order")
113
+ if order is not None:
114
+ if isinstance(order, (list, tuple)):
115
+ config["order"] = [str(item).strip() for item in order if str(item).strip()]
116
+ else:
117
+ config["order"] = []
118
+
119
+ sort = parsed.get("sort")
120
+ if isinstance(sort, str) and sort in ("name_asc", "name_desc", "mtime_asc", "mtime_desc"):
121
+ config["sort"] = sort
122
+
123
+ folders_first = parsed.get("folders_first")
124
+ if isinstance(folders_first, bool):
125
+ config["folders_first"] = folders_first
126
+ elif isinstance(folders_first, str):
127
+ lowered = folders_first.lower()
128
+ if lowered in ("true", "false"):
129
+ config["folders_first"] = lowered == "true"
130
+
131
+ folders_always_first = parsed.get("folders_always_first")
132
+ if isinstance(folders_always_first, bool):
133
+ config["folders_always_first"] = folders_always_first
134
+ elif isinstance(folders_always_first, str):
135
+ lowered = folders_always_first.lower()
136
+ if lowered in ("true", "false"):
137
+ config["folders_always_first"] = lowered == "true"
138
+
139
+ for key in ("layout_max_width",):
140
+ value = parsed.get(key)
141
+ if isinstance(value, (int, float)):
142
+ value = str(value)
143
+ if isinstance(value, str):
144
+ value = value.strip()
145
+ config[key] = value if value else None
146
+
147
+ abbreviations = parsed.get("abbreviations")
148
+ if isinstance(abbreviations, (list, tuple, set)):
149
+ config["abbreviations"] = [str(item).strip() for item in abbreviations if str(item).strip()]
150
+ elif isinstance(abbreviations, str):
151
+ parts = [part.strip() for part in abbreviations.split(",")]
152
+ config["abbreviations"] = [part for part in parts if part]
153
+
154
+ return config
155
+
156
+ def _effective_abbreviations(root: Path, folder: Path | None = None):
157
+ root_config = get_vyasa_config(root)
158
+ root_abbrevs = root_config.get("abbreviations") or []
159
+ if folder is None or folder == root:
160
+ return root_abbrevs
161
+ folder_config = get_vyasa_config(folder)
162
+ folder_abbrevs = folder_config.get("abbreviations")
163
+ return folder_abbrevs if folder_abbrevs is not None else root_abbrevs
164
+
165
+ def get_vyasa_config(folder: Path):
166
+ vyasa_path = folder / ".vyasa"
167
+ if not vyasa_path.exists():
168
+ return _normalize_vyasa_config({})
169
+ try:
170
+ mtime = vyasa_path.stat().st_mtime
171
+ except OSError:
172
+ return _normalize_vyasa_config({})
173
+ parsed = _cached_vyasa_config(str(vyasa_path), mtime)
174
+ config = _normalize_vyasa_config(parsed)
175
+ logger.debug(
176
+ "[DEBUG] .vyasa config for %s: order=%s sort=%s folders_first=%s",
177
+ folder,
178
+ config.get("order"),
179
+ config.get("sort"),
180
+ config.get("folders_first"),
181
+ )
182
+ return config
183
+
184
+ def order_vyasa_entries(entries, config):
185
+ if not entries:
186
+ return []
187
+
188
+ order_list = [name.strip().rstrip("/") for name in config.get("order", []) if str(name).strip()]
189
+ if not order_list:
190
+ sorted_entries = _sort_vyasa_entries(entries, config.get("sort"), config.get("folders_first", True))
191
+ if config.get("folders_always_first"):
192
+ sorted_entries = _group_folders_first(sorted_entries)
193
+ logger.debug(
194
+ "[DEBUG] .vyasa order empty; sorted entries: %s",
195
+ [item.name for item in sorted_entries],
196
+ )
197
+ return sorted_entries
198
+
199
+ exact_map = {}
200
+ stem_map = {}
201
+ for item in entries:
202
+ exact_map.setdefault(item.name, item)
203
+ if item.suffix == ".md":
204
+ stem_map.setdefault(item.stem, item)
205
+
206
+ ordered = []
207
+ used = set()
208
+ for name in order_list:
209
+ if name in exact_map:
210
+ item = exact_map[name]
211
+ elif name in stem_map:
212
+ item = stem_map[name]
213
+ else:
214
+ item = None
215
+ if item and item not in used:
216
+ ordered.append(item)
217
+ used.add(item)
218
+
219
+ remaining = [item for item in entries if item not in used]
220
+ remaining_sorted = _sort_vyasa_entries(
221
+ remaining,
222
+ config.get("sort"),
223
+ config.get("folders_first", True)
224
+ )
225
+ combined = ordered + remaining_sorted
226
+ if config.get("folders_always_first"):
227
+ combined = _group_folders_first(combined)
228
+ logger.debug(
229
+ "[DEBUG] .vyasa ordered=%s remaining=%s",
230
+ [item.name for item in ordered],
231
+ [item.name for item in remaining_sorted],
232
+ )
233
+ return combined
234
+
235
+ def _group_folders_first(entries):
236
+ folders = [item for item in entries if item.is_dir()]
237
+ files = [item for item in entries if not item.is_dir()]
238
+ return folders + files
239
+
240
+ def _sort_vyasa_entries(entries, sort_method, folders_first):
241
+ method = sort_method or "name_asc"
242
+ reverse = method.endswith("desc")
243
+ by_mtime = method.startswith("mtime")
244
+
245
+ def sort_key(item):
246
+ if by_mtime:
247
+ try:
248
+ return item.stat().st_mtime
249
+ except OSError:
250
+ return 0
251
+ return item.name.lower()
252
+
253
+ if folders_first:
254
+ folders = [item for item in entries if item.is_dir()]
255
+ files = [item for item in entries if not item.is_dir()]
256
+ folders_sorted = sorted(folders, key=sort_key, reverse=reverse)
257
+ files_sorted = sorted(files, key=sort_key, reverse=reverse)
258
+ return folders_sorted + files_sorted
259
+
260
+ return sorted(entries, key=sort_key, reverse=reverse)
261
+
262
+ def list_vyasa_posts(root: Path, include_hidden: bool = False) -> list[dict]:
263
+ """List all posts in the blog root (md + pdf)."""
264
+ root = root.resolve()
265
+ root_parts = len(root.parts)
266
+ posts: list[dict] = []
267
+ abbreviations = _effective_abbreviations(root)
268
+
269
+ for path in sorted(root.rglob("*")):
270
+ if not path.is_file():
271
+ continue
272
+ rel_parts = path.parts[root_parts:]
273
+ if not rel_parts:
274
+ continue
275
+ if not include_hidden and any(part.startswith(".") for part in rel_parts):
276
+ continue
277
+ if path.suffix.lower() not in {".md", ".pdf"}:
278
+ continue
279
+
280
+ rel = Path(*rel_parts)
281
+ slug = rel.with_suffix("").as_posix()
282
+ if path.suffix.lower() == ".md":
283
+ title = get_post_title(path, abbreviations=abbreviations)
284
+ kind = "md"
285
+ else:
286
+ title = slug_to_title(rel.stem, abbreviations=abbreviations)
287
+ kind = "pdf"
288
+
289
+ posts.append(
290
+ {
291
+ "path": slug,
292
+ "title": title,
293
+ "type": kind,
294
+ }
295
+ )
296
+
297
+ return posts
298
+
299
+ def list_vyasa_entries(root: Path, relative: str = ".", include_hidden: bool = False) -> dict:
300
+ """List immediate entries (folders + md/pdf files) under a relative path."""
301
+ root = root.resolve()
302
+ target = (root / relative).resolve()
303
+ if target != root and root not in target.parents:
304
+ return {"error": "Path escapes blog root"}
305
+ if not target.exists() or not target.is_dir():
306
+ return {"error": "Folder not found"}
307
+
308
+ abbreviations = _effective_abbreviations(root, target)
309
+ entries: list[dict] = []
310
+ for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
311
+ if not include_hidden and item.name.startswith("."):
312
+ continue
313
+ if item.is_dir():
314
+ entries.append({"type": "folder", "path": item.relative_to(root).as_posix()})
315
+ continue
316
+ if item.suffix.lower() not in {".md", ".pdf"}:
317
+ continue
318
+ rel = item.relative_to(root)
319
+ slug = rel.with_suffix("").as_posix()
320
+ if item.suffix.lower() == ".md":
321
+ title = get_post_title(item, abbreviations=abbreviations)
322
+ kind = "md"
323
+ else:
324
+ title = slug_to_title(rel.stem, abbreviations=abbreviations)
325
+ kind = "pdf"
326
+ entries.append({"type": kind, "path": slug, "title": title})
327
+
328
+ return {"path": target.relative_to(root).as_posix(), "entries": entries}
329
+
330
+ def find_folder_note_file(folder: Path) -> Path | None:
331
+ """Return the preferred folder note file (index.md, readme.md, or foldername.md)."""
332
+ try:
333
+ folder_name = folder.name.lower()
334
+ index_file = None
335
+ readme_file = None
336
+ named_file = None
337
+ for item in folder.iterdir():
338
+ if not item.is_file() or item.suffix.lower() != ".md":
339
+ continue
340
+ stem = item.stem.lower()
341
+ if stem == "index":
342
+ index_file = item
343
+ elif stem == "readme":
344
+ readme_file = item
345
+ elif stem == folder_name:
346
+ named_file = item
347
+ return index_file or readme_file or named_file
348
+ except OSError:
349
+ return None
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from .config import get_config
5
+
6
+ def _coerce_config_str(value):
7
+ if value is None:
8
+ return None
9
+ if isinstance(value, (int, float)):
10
+ return str(value)
11
+ if isinstance(value, str):
12
+ cleaned = value.strip()
13
+ return cleaned if cleaned else None
14
+ return str(value)
15
+
16
+ def _width_class_and_style(value, kind):
17
+ if not value:
18
+ return "", ""
19
+ val = value.strip()
20
+ lowered = val.lower()
21
+ if lowered in ("default", "auto", "none"):
22
+ return "", ""
23
+ if kind == "max":
24
+ if val.startswith("max-w-"):
25
+ return val, ""
26
+ if re.match(r'^\d+(\.\d+)?$', val):
27
+ val = f"{val}px"
28
+ return "", f"--layout-max-width: {val};"
29
+ return "", ""
30
+
31
+ def _style_attr(style_value):
32
+ if not style_value:
33
+ return {}
34
+ return {"style": style_value}
35
+
36
+ def _resolve_layout_config(current_path):
37
+ config = get_config()
38
+ return {
39
+ "layout_max_width": _coerce_config_str(config.get("layout_max_width", "VYASA_LAYOUT_MAX_WIDTH", "75vw")),
40
+ }
vyasa/main.py ADDED
@@ -0,0 +1,108 @@
1
+ from pathlib import Path
2
+ import sys
3
+ import os
4
+ from .config import get_config, reload_config
5
+
6
+ # Import app at module level, but config will be initialized before it's used
7
+ from .core import app
8
+
9
+ def build_command():
10
+ """CLI entry point for vyasa build command"""
11
+ import argparse
12
+ from .build import build_static_site
13
+
14
+ parser = argparse.ArgumentParser(description='Build static site from markdown files')
15
+ parser.add_argument('directory', nargs='?', help='Path to markdown files directory')
16
+ parser.add_argument('-o', '--output', help='Output directory (default: ./dist)', default='dist')
17
+
18
+ args = parser.parse_args(sys.argv[2:]) # Skip 'vyasa' and 'build'
19
+
20
+ try:
21
+ output_dir = build_static_site(input_dir=args.directory, output_dir=args.output)
22
+ return 0
23
+ except Exception as e:
24
+ print(f"Error building static site: {e}", file=sys.stderr)
25
+ import traceback
26
+ traceback.print_exc()
27
+ return 1
28
+
29
+ def cli():
30
+ """CLI entry point for vyasa command
31
+
32
+ Usage:
33
+ vyasa [directory] # Run locally on 127.0.0.1:5001
34
+ vyasa [directory] --host 0.0.0.0 # Run on all interfaces
35
+ vyasa build [directory] # Build static site
36
+ vyasa build [directory] -o output # Build to custom output directory
37
+
38
+ Environment variables:
39
+ VYASA_ROOT: Path to markdown files
40
+ VYASA_HOST: Server host (default: 127.0.0.1)
41
+ VYASA_PORT: Server port (default: 5001)
42
+
43
+ Configuration file:
44
+ Create a .vyasa file (TOML format) in your blog directory
45
+ """
46
+ import uvicorn
47
+ import argparse
48
+
49
+ # Check if first argument is 'build'
50
+ if len(sys.argv) > 1 and sys.argv[1] == 'build':
51
+ sys.exit(build_command())
52
+
53
+ parser = argparse.ArgumentParser(description='Run Vyasa server')
54
+ parser.add_argument('directory', nargs='?', help='Path to markdown files directory')
55
+ parser.add_argument('--host', help='Server host (default: 127.0.0.1, use 0.0.0.0 for all interfaces)')
56
+ parser.add_argument('--port', type=int, help='Server port (default: 5001)')
57
+ parser.add_argument('--no-reload', action='store_true', help='Disable auto-reload')
58
+ parser.add_argument('--user', help='Login username (overrides config/env)')
59
+ parser.add_argument('--password', help='Login password (overrides config/env)')
60
+
61
+ args = parser.parse_args()
62
+
63
+ # Set root folder from arguments or environment
64
+ if args.directory:
65
+ root = Path(args.directory).resolve()
66
+ if not root.exists():
67
+ print(f"Error: Directory {root} does not exist")
68
+ sys.exit(1)
69
+ os.environ['VYASA_ROOT'] = str(root)
70
+
71
+ # Initialize or reload config to pick up .vyasa file
72
+ # This ensures .vyasa file is loaded and config is refreshed
73
+ config = reload_config() if args.directory else get_config()
74
+
75
+ # Get host and port from arguments, config, or use defaults
76
+ host = args.host or config.get_host()
77
+ port = args.port or config.get_port()
78
+ reload = not args.no_reload
79
+
80
+ # Set login credentials from CLI if provided
81
+ if args.user:
82
+ os.environ['VYASA_USER'] = args.user
83
+ if args.password:
84
+ os.environ['VYASA_PASSWORD'] = args.password
85
+
86
+ print(f"Starting Vyasa server...")
87
+ print(f"Blog root: {config.get_root_folder()}")
88
+ print(f"Blog title: {config.get_blog_title()}")
89
+ print(f"Serving at: http://{host}:{port}")
90
+ if host == '0.0.0.0':
91
+ print(f"Server accessible from network at: http://<your-ip>:{port}")
92
+
93
+ # Configure reload to watch markdown and PDF files in the blog directory
94
+ reload_kwargs = {}
95
+ if reload:
96
+ blog_root = config.get_root_folder()
97
+ reload_kwargs = {
98
+ "reload": True,
99
+ "reload_dirs": [str(blog_root)],
100
+ "reload_includes": ["*.md", "*.pdf", "*.vyasa"]
101
+ }
102
+ else:
103
+ reload_kwargs = {"reload": False}
104
+
105
+ uvicorn.run("vyasa.main:app", host=host, port=port, **reload_kwargs)
106
+
107
+ if __name__ == "__main__":
108
+ cli()