vyasa 0.3.6__py3-none-any.whl → 0.3.10__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/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "0.3.5"
1
+ __version__ = "0.3.7"
2
2
 
3
3
  from .core import app, rt, get_root_folder, get_blog_title
4
4
 
vyasa/core.py CHANGED
@@ -67,6 +67,10 @@ def span_token(name, pat, attr, prec=5):
67
67
  self.caption = match.group(2) if match.group(2) else None
68
68
  elif name == 'MermaidEmbed':
69
69
  self.option = match.group(2) if match.group(2) else None
70
+ elif name == 'IframeEmbed':
71
+ self.options = match.group(2) if match.group(2) else None
72
+ elif name == 'DownloadEmbed':
73
+ self.label = match.group(2) if match.group(2) else None
70
74
  T.__name__ = name
71
75
  return T
72
76
 
@@ -77,6 +81,18 @@ YoutubeEmbed = span_token(
77
81
  'video_id',
78
82
  6
79
83
  )
84
+ IframeEmbed = span_token(
85
+ 'IframeEmbed',
86
+ r'\[iframe:([^\|\]]+)(?:\|(.+))?\]',
87
+ 'src',
88
+ 6
89
+ )
90
+ DownloadEmbed = span_token(
91
+ 'DownloadEmbed',
92
+ r'\[download:([^\|\]]+)(?:\|(.+))?\]',
93
+ 'path',
94
+ 6
95
+ )
80
96
 
81
97
  # Superscript and Subscript tokens with higher precedence
82
98
  class Superscript(mst.span_token.SpanToken):
@@ -221,6 +237,7 @@ class ContentRenderer(FrankenRenderer):
221
237
  self.current_path = current_path # Current post path for resolving relative links and images
222
238
  self.heading_counts = {}
223
239
  self.mermaid_counter = 0
240
+ self.iframe_counter = 0
224
241
 
225
242
  def render_list_item(self, token):
226
243
  """Render list items with task list checkbox support"""
@@ -273,6 +290,124 @@ class ContentRenderer(FrankenRenderer):
273
290
  if caption:
274
291
  return iframe + f'<p class="text-sm text-slate-500 dark:text-slate-400 text-center mt-2">{caption}</p>'
275
292
  return iframe
293
+
294
+ def render_iframe_embed(self, token):
295
+ src = token.src.strip()
296
+ options_raw = getattr(token, 'options', None)
297
+
298
+ # Defaults
299
+ width = '65vw'
300
+ height = '400px'
301
+ title = 'Embedded content'
302
+ allow = 'clipboard-read; clipboard-write; fullscreen'
303
+ allowfullscreen = True
304
+ caption = None
305
+ popup = False
306
+ border = 'default'
307
+
308
+ # Parse options: key=value;key=value
309
+ if options_raw:
310
+ for part in options_raw.split(';'):
311
+ if not part.strip() or '=' not in part:
312
+ continue
313
+ key, value = part.split('=', 1)
314
+ key = key.strip().lower()
315
+ value = value.strip()
316
+ if key == 'width':
317
+ width = value
318
+ elif key == 'height':
319
+ height = value
320
+ elif key == 'title':
321
+ title = value
322
+ elif key == 'allow':
323
+ allow = value
324
+ elif key == 'fullscreen':
325
+ allowfullscreen = value.lower() in ('1', 'true', 'yes', 'on')
326
+ elif key == 'caption':
327
+ caption = value
328
+ elif key == 'popup':
329
+ popup = value.lower() in ('1', 'true', 'yes', 'on')
330
+ elif key == 'border':
331
+ border = value.lower()
332
+
333
+ # Break out of normal content flow for viewport widths
334
+ break_out = 'vw' in str(width).lower()
335
+ if break_out:
336
+ container_style = f"width: {width}; position: relative; left: 50%; transform: translateX(-50%);"
337
+ else:
338
+ container_style = f"width: {width};"
339
+
340
+ self.iframe_counter += 1
341
+ iframe_id = f"iframe-{abs(hash(src)) & 0xFFFFFF}-{self.iframe_counter}"
342
+
343
+ fullscreen_button = ''
344
+ if popup:
345
+ fullscreen_button = (
346
+ '<div class="iframe-controls absolute top-2 right-2 z-10 flex gap-1 '
347
+ 'bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm rounded">'
348
+ f'<button data-iframe-fullscreen-toggle="true" '
349
+ f'data-iframe-src="{src}" '
350
+ f'data-iframe-title="{title}" '
351
+ f'data-iframe-allow="{allow}" '
352
+ f'data-iframe-allowfullscreen="{str(allowfullscreen).lower()}" '
353
+ 'class="px-2 py-1 text-xs border rounded hover:bg-slate-100 '
354
+ 'dark:hover:bg-slate-700" title="Fullscreen">⛶</button>'
355
+ '</div>'
356
+ )
357
+
358
+ if border in ('black', 'dark'):
359
+ border_classes = 'border border-black'
360
+ elif border in ('none', 'false', '0', 'off'):
361
+ border_classes = 'border border-transparent'
362
+ else:
363
+ border_classes = 'border border-slate-200 dark:border-slate-800'
364
+
365
+ iframe = f'''
366
+ <div class="relative my-6 rounded-lg overflow-hidden {border_classes}" style="{container_style}">
367
+ {fullscreen_button}
368
+ <iframe
369
+ id="{iframe_id}"
370
+ src="{src}"
371
+ title="{title}"
372
+ frameborder="0"
373
+ allow="{allow}"
374
+ {'allowfullscreen' if allowfullscreen else ''}
375
+ style="width: 100%; height: {height};">
376
+ </iframe>
377
+ </div>
378
+ '''
379
+
380
+ if caption:
381
+ return iframe + f'<p class="text-sm text-slate-500 dark:text-slate-400 text-center mt-2">{caption}</p>'
382
+ return iframe
383
+
384
+ def render_download_embed(self, token):
385
+ from pathlib import Path
386
+ raw_path = token.path.strip()
387
+ label = getattr(token, 'label', None)
388
+
389
+ # Resolve relative paths against current_path like normal links
390
+ if self.current_path:
391
+ root = get_root_folder().resolve()
392
+ current_file_full = root / self.current_path
393
+ current_dir = current_file_full.parent
394
+ resolved = (current_dir / raw_path).resolve()
395
+ try:
396
+ rel_path = resolved.relative_to(root).as_posix()
397
+ download_path = f"/download/{rel_path}"
398
+ except ValueError:
399
+ download_path = raw_path
400
+ else:
401
+ download_path = f"/download/{raw_path}"
402
+
403
+ if not label:
404
+ label = Path(raw_path).name
405
+
406
+ link_class = (
407
+ "text-amber-600 dark:text-amber-400 underline underline-offset-2 "
408
+ "hover:text-amber-800 dark:hover:text-amber-200 font-medium transition-colors"
409
+ )
410
+ return f'<a href="{download_path}" class="{link_class}" download>{label}</a>'
276
411
 
277
412
  def render_footnote_ref(self, token):
278
413
  self.fn_counter += 1
@@ -473,6 +608,12 @@ class ContentRenderer(FrankenRenderer):
473
608
  is_external = href.startswith(('http://', 'https://', 'mailto:', 'tel:', '//'))
474
609
  is_absolute_internal = href.startswith('/') and not href.startswith('//')
475
610
  is_relative = not is_external and not is_absolute_internal
611
+ download_flag = False
612
+ if token.title and 'download=true' in token.title.lower():
613
+ download_flag = True
614
+ if '?download=true' in href.lower():
615
+ href = re.sub(r'\?download=true', '', href, flags=re.IGNORECASE)
616
+ download_flag = True
476
617
  if is_hash:
477
618
  link_class = (
478
619
  "text-amber-600 dark:text-amber-400 underline underline-offset-2 "
@@ -504,12 +645,23 @@ class ContentRenderer(FrankenRenderer):
504
645
  is_internal = is_absolute_internal and '.' not in href.split('/')[-1]
505
646
  hx = f' hx-get="{href}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML show:window:top"' if is_internal else ''
506
647
  ext = '' if (is_internal or is_absolute_internal or is_hash) else ' target="_blank" rel="noopener noreferrer"'
648
+ download_attr = ''
649
+ if download_flag:
650
+ download_attr = ' download'
651
+ if href.startswith('/posts/'):
652
+ download_target = href[len('/posts/'):]
653
+ elif href.startswith('/'):
654
+ download_target = href.lstrip('/')
655
+ else:
656
+ download_target = href
657
+ href = f'/download/{download_target}'
658
+ hx = ''
507
659
  # Amber/gold link styling, stands out and is accessible
508
660
  link_class = (
509
661
  "text-amber-600 dark:text-amber-400 underline underline-offset-2 "
510
662
  "hover:text-amber-800 dark:hover:text-amber-200 font-medium transition-colors"
511
663
  )
512
- return f'<a href="{href}"{hx}{ext} class="{link_class}"{title}>{inner}</a>'
664
+ return f'<a href="{href}"{hx}{ext}{download_attr} class="{link_class}"{title}>{inner}</a>'
513
665
 
514
666
 
515
667
  def postprocess_tabs(html, tab_data_store, img_dir, current_path, footnotes):
@@ -532,7 +684,7 @@ def postprocess_tabs(html, tab_data_store, img_dir, current_path, footnotes):
532
684
  for i, (_, tab_content) in enumerate(tabs):
533
685
  active = 'active' if i == 0 else ''
534
686
  # Render each tab's content as fresh markdown
535
- with ContentRenderer(YoutubeEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
687
+ with ContentRenderer(YoutubeEmbed, IframeEmbed, DownloadEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
536
688
  doc = mst.Document(tab_content)
537
689
  rendered = renderer.render(doc)
538
690
  html_parts.append(f'<div class="tab-panel {active}" data-tab-index="{i}">{rendered}</div>')
@@ -613,7 +765,7 @@ def from_md(content, img_dir=None, current_path=None):
613
765
  'table': 'uk-table uk-table-striped uk-table-hover uk-table-divider uk-table-middle my-6'}
614
766
 
615
767
  # Register custom tokens with renderer context manager
616
- with ContentRenderer(YoutubeEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
768
+ with ContentRenderer(YoutubeEmbed, IframeEmbed, DownloadEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
617
769
  doc = mst.Document(content)
618
770
  html = renderer.render(doc)
619
771
 
@@ -818,6 +970,41 @@ hdrs = (
818
970
  height: calc(100vh - 6rem) !important;
819
971
  }
820
972
 
973
+ /* Iframe fullscreen overlay */
974
+ body.iframe-fullscreen-open {
975
+ overflow: hidden;
976
+ }
977
+ .iframe-fullscreen-overlay {
978
+ position: fixed;
979
+ inset: 0;
980
+ z-index: 1000;
981
+ background: rgba(2, 6, 23, 0.85);
982
+ display: flex;
983
+ flex-direction: column;
984
+ }
985
+ .iframe-fullscreen-header {
986
+ display: flex;
987
+ align-items: center;
988
+ justify-content: space-between;
989
+ gap: 1rem;
990
+ padding: 0.75rem 1rem;
991
+ color: #e2e8f0;
992
+ font-size: 0.9rem;
993
+ background: rgba(15, 23, 42, 0.9);
994
+ border-bottom: 1px solid rgba(148, 163, 184, 0.3);
995
+ }
996
+ .iframe-fullscreen-body {
997
+ flex: 1;
998
+ padding: 0.75rem;
999
+ }
1000
+ .iframe-fullscreen-frame {
1001
+ width: 100%;
1002
+ height: 100%;
1003
+ border: 0;
1004
+ border-radius: 0.5rem;
1005
+ background: #0f172a;
1006
+ }
1007
+
821
1008
  .layout-fluid {
822
1009
  --layout-breakpoint: 1280px;
823
1010
  --layout-blend: 240px;
@@ -1800,6 +1987,33 @@ def serve_post_static(path: str, ext: str):
1800
1987
  return FileResponse(file_path)
1801
1988
  return Response(status_code=404)
1802
1989
 
1990
+ # Serve JSON attachments from blog posts (not included in fasthtml static exts)
1991
+ @rt("/posts/{path:path}.json")
1992
+ def serve_post_json(path: str):
1993
+ from starlette.responses import FileResponse
1994
+ file_path = get_root_folder() / f'{path}.json'
1995
+ if file_path.exists():
1996
+ return FileResponse(
1997
+ file_path,
1998
+ headers={"Content-Disposition": f'attachment; filename="{file_path.name}"'}
1999
+ )
2000
+ return Response(status_code=404)
2001
+
2002
+ # Generic download route for any file under the blog root
2003
+ @rt("/download/{path:path}")
2004
+ def download_file(path: str):
2005
+ from starlette.responses import FileResponse
2006
+ root = get_root_folder().resolve()
2007
+ file_path = (root / path).resolve()
2008
+ if not str(file_path).startswith(str(root) + os.sep):
2009
+ return Response(status_code=403)
2010
+ if file_path.exists() and file_path.is_file():
2011
+ return FileResponse(
2012
+ file_path,
2013
+ headers={"Content-Disposition": f'attachment; filename="{file_path.name}"'}
2014
+ )
2015
+ return Response(status_code=404)
2016
+
1803
2017
  def theme_toggle():
1804
2018
  theme_script = """on load set franken to (localStorage's __FRANKEN__ or '{}') as Object
1805
2019
  if franken's mode is 'dark' then add .dark to <html/> end
vyasa/static/scripts.js CHANGED
@@ -1174,6 +1174,86 @@ function initPdfFocusToggle() {
1174
1174
  });
1175
1175
  }
1176
1176
 
1177
+ function openIframeFullscreen(button) {
1178
+ const src = button.getAttribute('data-iframe-src');
1179
+ const title = button.getAttribute('data-iframe-title') || 'Embedded content';
1180
+ const allow = button.getAttribute('data-iframe-allow') || '';
1181
+ const allowfullscreen = button.getAttribute('data-iframe-allowfullscreen') === 'true';
1182
+
1183
+ let overlay = document.querySelector('.iframe-fullscreen-overlay');
1184
+ if (!overlay) {
1185
+ overlay = document.createElement('div');
1186
+ overlay.className = 'iframe-fullscreen-overlay';
1187
+ overlay.innerHTML = `
1188
+ <div class="iframe-fullscreen-header">
1189
+ <div class="iframe-fullscreen-title"></div>
1190
+ <button type="button" class="iframe-fullscreen-close px-2 py-1 text-xs border rounded hover:bg-slate-700">
1191
+ Close
1192
+ </button>
1193
+ </div>
1194
+ <div class="iframe-fullscreen-body">
1195
+ <iframe class="iframe-fullscreen-frame" frameborder="0"></iframe>
1196
+ </div>
1197
+ `;
1198
+ document.body.appendChild(overlay);
1199
+
1200
+ overlay.addEventListener('click', (event) => {
1201
+ if (event.target.classList.contains('iframe-fullscreen-overlay')) {
1202
+ closeIframeFullscreen();
1203
+ }
1204
+ });
1205
+
1206
+ overlay.querySelector('.iframe-fullscreen-close').addEventListener('click', () => {
1207
+ closeIframeFullscreen();
1208
+ });
1209
+ }
1210
+
1211
+ overlay.querySelector('.iframe-fullscreen-title').textContent = title;
1212
+ const frame = overlay.querySelector('.iframe-fullscreen-frame');
1213
+ frame.setAttribute('src', src);
1214
+ frame.setAttribute('title', title);
1215
+ frame.setAttribute('allow', allow);
1216
+ if (allowfullscreen) {
1217
+ frame.setAttribute('allowfullscreen', '');
1218
+ } else {
1219
+ frame.removeAttribute('allowfullscreen');
1220
+ }
1221
+
1222
+ document.body.classList.add('iframe-fullscreen-open');
1223
+ overlay.style.display = 'flex';
1224
+ }
1225
+
1226
+ function closeIframeFullscreen() {
1227
+ const overlay = document.querySelector('.iframe-fullscreen-overlay');
1228
+ if (!overlay) {
1229
+ return;
1230
+ }
1231
+ const frame = overlay.querySelector('.iframe-fullscreen-frame');
1232
+ if (frame) {
1233
+ frame.setAttribute('src', 'about:blank');
1234
+ }
1235
+ overlay.style.display = 'none';
1236
+ document.body.classList.remove('iframe-fullscreen-open');
1237
+ }
1238
+
1239
+ function initIframeFullscreenToggle() {
1240
+ document.addEventListener('click', (event) => {
1241
+ const button = event.target.closest('[data-iframe-fullscreen-toggle]');
1242
+ if (!button) {
1243
+ return;
1244
+ }
1245
+ event.preventDefault();
1246
+ openIframeFullscreen(button);
1247
+ });
1248
+
1249
+ document.addEventListener('keydown', (event) => {
1250
+ if (event.key !== 'Escape') {
1251
+ return;
1252
+ }
1253
+ closeIframeFullscreen();
1254
+ });
1255
+ }
1256
+
1177
1257
  // Initialize on page load
1178
1258
  document.addEventListener('DOMContentLoaded', () => {
1179
1259
  updateActivePostLink();
@@ -1183,6 +1263,7 @@ document.addEventListener('DOMContentLoaded', () => {
1183
1263
  initFolderChevronState();
1184
1264
  initKeyboardShortcuts();
1185
1265
  initPdfFocusToggle();
1266
+ initIframeFullscreenToggle();
1186
1267
  initSearchPlaceholderCycle(document);
1187
1268
  initPostsSearchPersistence(document);
1188
1269
  initCodeBlockCopyButtons(document);
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vyasa
3
- Version: 0.3.6
3
+ Version: 0.3.10
4
4
  Summary: A lightweight, elegant blogging platform built with FastHTML
5
5
  Home-page: https://github.com/yeshwanth/vyasa
6
6
  Author: Yeshwanth
@@ -0,0 +1,16 @@
1
+ vyasa/__init__.py,sha256=T0ZRRHOvcleFaTybYJyhb7pRgPUBQjeQHftxbLgmLj4,158
2
+ vyasa/agent.py,sha256=XD4V6rM-JrVtqTVa3pZqPy_BUmcQgKdidQaY4il632Q,3196
3
+ vyasa/build.py,sha256=USqeZsXGPB8COzdWRPLgSgkVg6DIzQwL0qGGJaMM7Ec,26548
4
+ vyasa/config.py,sha256=7mjBTnv7MlBF4CUHA-2IZDt9l9_EMjgOLmQ00I2LQZA,7768
5
+ vyasa/core.py,sha256=79HAv7f-cIF3jMbU6A6wTh7wyUDSSosMnsVrZaI4p4s,134134
6
+ vyasa/helpers.py,sha256=VgOzAIoL3X8RYLvKQRd-S4jM-sjmlolyxFlyOiP7bmM,12489
7
+ vyasa/layout_helpers.py,sha256=uodzhyrGoqC6egbGuzuAPJP2yiWYM8krxb64dE4nwpc,1082
8
+ vyasa/main.py,sha256=xcneOe3Vw3vxfxSii2Y7C5DJTFLz71JDCY_CDZKVwxc,4073
9
+ vyasa/static/scripts.js,sha256=ofLQZWt4QHkuR6RyNvkDxQaEoMBop5vqYLaUBn1TDlE,47954
10
+ vyasa/static/sidenote.css,sha256=9YEl111Qs6kGnrgcVQtC1eiccMGY3-lu05l_NnwXVME,1045
11
+ vyasa-0.3.10.dist-info/licenses/LICENSE,sha256=xV8xoN4VOL0uw9X8RSs2IMuD_Ss_a9yAbtGNeBWZwnw,11337
12
+ vyasa-0.3.10.dist-info/METADATA,sha256=5ZqmrcpkHAsXxfKnIC7COk_rDcEQeX_BBfGMLUH4qYs,9792
13
+ vyasa-0.3.10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
+ vyasa-0.3.10.dist-info/entry_points.txt,sha256=ypyjtxRlygocldmIxOIijoME2BIfxzU0uuNfdi3_1us,41
15
+ vyasa-0.3.10.dist-info/top_level.txt,sha256=Pwy2VqkeYm2zJ5zQ-6XsJHNPsAU4xwscZn-8siQz9Pg,6
16
+ vyasa-0.3.10.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- vyasa/__init__.py,sha256=l6KpjZx1i1_wMJSbuQIqteDjUJ_h9MM1W8xpm-5ISPA,158
2
- vyasa/agent.py,sha256=XD4V6rM-JrVtqTVa3pZqPy_BUmcQgKdidQaY4il632Q,3196
3
- vyasa/build.py,sha256=USqeZsXGPB8COzdWRPLgSgkVg6DIzQwL0qGGJaMM7Ec,26548
4
- vyasa/config.py,sha256=7mjBTnv7MlBF4CUHA-2IZDt9l9_EMjgOLmQ00I2LQZA,7768
5
- vyasa/core.py,sha256=3p_HMM5wfpHLCbtWEDW-gdluZCVj3tFV3N6DBXO4PGQ,126211
6
- vyasa/helpers.py,sha256=VgOzAIoL3X8RYLvKQRd-S4jM-sjmlolyxFlyOiP7bmM,12489
7
- vyasa/layout_helpers.py,sha256=uodzhyrGoqC6egbGuzuAPJP2yiWYM8krxb64dE4nwpc,1082
8
- vyasa/main.py,sha256=xcneOe3Vw3vxfxSii2Y7C5DJTFLz71JDCY_CDZKVwxc,4073
9
- vyasa/static/scripts.js,sha256=SBFM8klicaI77-GsozUVxdMJwTuaJWoVrV6FsY4Kuws,45124
10
- vyasa/static/sidenote.css,sha256=9YEl111Qs6kGnrgcVQtC1eiccMGY3-lu05l_NnwXVME,1045
11
- vyasa-0.3.6.dist-info/licenses/LICENSE,sha256=xV8xoN4VOL0uw9X8RSs2IMuD_Ss_a9yAbtGNeBWZwnw,11337
12
- vyasa-0.3.6.dist-info/METADATA,sha256=sMt4Yt11hsTD6w1i47BKCo4bhGQsYPoEjI65aWEUMFw,9791
13
- vyasa-0.3.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
- vyasa-0.3.6.dist-info/entry_points.txt,sha256=ypyjtxRlygocldmIxOIijoME2BIfxzU0uuNfdi3_1us,41
15
- vyasa-0.3.6.dist-info/top_level.txt,sha256=Pwy2VqkeYm2zJ5zQ-6XsJHNPsAU4xwscZn-8siQz9Pg,6
16
- vyasa-0.3.6.dist-info/RECORD,,
File without changes