Pytml

Pytml is a 15KB lightweight PyScript alternative — run Python in HTML with one script tag.

git clone https://github.com/notamitgamer/Pytml.git
· rebuilt hourlyOpen on GitHub
root / pytml.js

pytml.js (40150B)


      1 fetch('https://pytml.vercel.app/api/count', {
      2   cache: 'no-store',
      3   referrerPolicy: 'origin'
      4 })
      5   .then(r => r.json())
      6   .then(data => console.log('Pytml loaded:', data.message, 'times'))
      7   .catch(() => {});
      8 /*
      9  * Pytml 2.5.1
     10  * Python + HTML, powered by Pyodide.
     11  * 
     12  * Pytml 2.5 includes a lightweight inline UI preprocessor for <pyN> blocks.
     13  * The full document/language compiler remains planned for v3.0.
     14  */
     15 (function (global) {
     16   'use strict';
     17 
     18   const VERSION = '2.5.1';
     19   const PYODIDE_VERSION = '314.0.7';
     20   const PYODIDE_INDEX = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
     21   const PYODIDE_SCRIPT = `${PYODIDE_INDEX}pyodide.js`;
     22   // file:// is supported for inline Python. Pyodide itself stays on the HTTPS CDN.
     23   const IS_FILE_PROTOCOL = typeof location !== 'undefined' && location.protocol === 'file:';
     24   const PY_BLOCK = /^py(?:\d+)?$/i;
     25 
     26   // --- Network helpers. Pyodide is loaded only when a Python block is present. ---
     27   let _pyodideScriptPromise = null;
     28 
     29   function preconnect() {
     30     for (const [rel, href] of [
     31       ['preconnect', 'https://cdn.jsdelivr.net'],
     32       ['dns-prefetch', 'https://cdn.jsdelivr.net'],
     33     ]) {
     34       const link = document.createElement('link');
     35       link.rel = rel;
     36       link.href = href;
     37       if (rel === 'preconnect') link.crossOrigin = 'anonymous';
     38       document.head.appendChild(link);
     39     }
     40   }
     41 
     42   function preloadPyodideScript() {
     43     if (_pyodideScriptPromise) return _pyodideScriptPromise;
     44     _pyodideScriptPromise = new Promise((resolve, reject) => {
     45       if (typeof global.loadPyodide === 'function') return resolve();
     46       const existing = document.querySelector(`script[src="${PYODIDE_SCRIPT}"]`);
     47       if (existing) {
     48         existing.addEventListener('load', resolve, { once: true });
     49         existing.addEventListener('error', () => reject(new Error(`Failed to load ${PYODIDE_SCRIPT}`)), { once: true });
     50         return;
     51       }
     52       const script = document.createElement('script');
     53       script.src = PYODIDE_SCRIPT;
     54       script.async = true;
     55       script.crossOrigin = 'anonymous';
     56       script.onload = resolve;
     57       script.onerror = () => reject(new Error(`Failed to load ${PYODIDE_SCRIPT}`));
     58       document.head.appendChild(script);
     59     });
     60     return _pyodideScriptPromise;
     61   }
     62 
     63   // --- Boot lock: interactive elements are visible but inert until Python is ready ---
     64   function installBootState() {
     65     document.documentElement.classList.add('pytml-booting');
     66     const style = document.createElement('style');
     67     style.id = 'pytml-boot-style';
     68     style.textContent = `
     69       /* Page paints normally, but nothing is clickable until Python is up */
     70       html.pytml-booting button,
     71       html.pytml-booting input,
     72       html.pytml-booting select,
     73       html.pytml-booting textarea,
     74       html.pytml-booting a[href],
     75       html.pytml-booting [role="button"],
     76       html.pytml-booting [contenteditable="true"] {
     77         pointer-events: none !important;
     78         opacity: 0.5;
     79         cursor: not-allowed !important;
     80         user-select: none;
     81         transition: opacity 0.2s ease;
     82       }
     83 
     84       /* Small fixed pill at the top so users know why nothing is clickable */
     85       .pytml-boot-pill {
     86         position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
     87         display: flex; align-items: center; gap: 8px;
     88         background: rgba(10,14,39,0.95); color: #c8cdda;
     89         border: 1px solid rgba(102,126,234,0.4);
     90         border-radius: 999px; padding: 8px 16px;
     91         font: 13px system-ui, -apple-system, sans-serif;
     92         z-index: 2147483647;
     93         box-shadow: 0 4px 20px rgba(0,0,0,0.35);
     94         backdrop-filter: blur(6px);
     95       }
     96       .pytml-boot-pill.pytml-boot-error {
     97         border-color: rgba(255,80,120,0.5);
     98         color: #ff8aa8;
     99       }
    100       .pytml-boot-spinner {
    101         width: 14px; height: 14px; flex: 0 0 14px;
    102         border-radius: 50%;
    103         border: 2px solid rgba(102,126,234,0.25);
    104         border-top-color: #3b82f6;
    105         animation: pytml-spin 0.8s linear infinite;
    106       }
    107       @keyframes pytml-spin { to { transform: rotate(360deg); } }
    108       @media (prefers-reduced-motion: reduce) { .pytml-boot-spinner { animation: none; } }
    109     `;
    110     (document.head || document.documentElement).appendChild(style);
    111 
    112     const mount = () => {
    113       if (document.querySelector('.pytml-boot-pill')) return;
    114       const pill = document.createElement('div');
    115       pill.className = 'pytml-boot-pill';
    116       pill.innerHTML = `<span class="pytml-boot-spinner"></span><span>Loading Python…</span>`;
    117       document.body.appendChild(pill);
    118     };
    119     if (document.body) mount();
    120     else document.addEventListener('DOMContentLoaded', mount, { once: true });
    121   }
    122 
    123   function clearBootState() {
    124     document.documentElement.classList.remove('pytml-booting');
    125     document.documentElement.classList.add('pytml-ready');
    126     document.querySelectorAll('.pytml-boot-pill').forEach(el => el.remove());
    127   }
    128 
    129   function failBootState(message) {
    130     document.documentElement.classList.remove('pytml-booting');
    131     document.querySelectorAll('.pytml-boot-pill').forEach(el => {
    132       el.classList.add('pytml-boot-error');
    133       el.innerHTML = `<span>${message || 'Python failed to load.'}</span>`;
    134     });
    135   }
    136 
    137   function pageHasPythonBlocks() {
    138     if (document.querySelector('script[type="text/python"]')) return true;
    139     return Array.from(document.querySelectorAll('*')).some((el) => {
    140       const tag = el.tagName?.toLowerCase?.() || '';
    141       return PY_BLOCK.test(tag);
    142     });
    143   }
    144 
    145   const DEFAULT_STYLE = `
    146     .pytml-output{box-sizing:border-box;background:#0a0e27;color:#e7e9ee;border:1px solid rgba(102,126,234,.28);border-radius:14px;padding:16px;margin:16px 0;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;max-height:420px;overflow:auto}
    147     .pytml-line{margin:4px 0;white-space:pre-wrap;overflow-wrap:anywhere}
    148     .pytml-error{color:#ff8aa8;background:rgba(255,80,120,.10);padding:8px 10px;border-radius:8px}
    149     .pytml-input-container{background:#11162f;border:1px solid rgba(102,126,234,.35);border-radius:12px;padding:14px;margin:14px 0}
    150     .pytml-input-prompt{font:600 14px system-ui,sans-serif;color:#f2d15b;margin-bottom:8px}
    151     .pytml-input-field{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid rgba(255,255,255,.18);background:#090c1c;color:#fff;outline:none;margin-bottom:9px}
    152     .pytml-input-submit{border:0;border-radius:8px;padding:9px 14px;background:#3b82f6;color:#fff;font-weight:600;cursor:pointer}
    153     .pytml-joystick{position:relative;touch-action:none;user-select:none;-webkit-user-select:none;overflow:hidden}
    154     .pytml-joystick-knob{position:absolute;left:50%;top:50%;width:32%;aspect-ratio:1;border-radius:50%;transform:translate(-50%,-50%);background:currentColor;opacity:.78;pointer-events:none}
    155     [data-pytml-hidden]{display:none!important}
    156   `;
    157 
    158   // This is the Python-side DOM/runtime module injected into Pyodide at startup.
    159   const PYTHON_MODULE = String.raw`
    160 import js
    161 import asyncio
    162 from pyodide.ffi import create_proxy
    163 import html as _html_escape
    164 
    165 
    166 _callbacks = []
    167 
    168 class Event(dict):
    169     """Dictionary-like browser event with default-control helpers."""
    170     def __init__(self, data, raw):
    171         super().__init__(data)
    172         self._raw = raw
    173     def __getattr__(self, name):
    174         try:
    175             return self[name]
    176         except KeyError as exc:
    177             raise AttributeError(name) from exc
    178     def prevent_default(self):
    179         self._raw.preventDefault()
    180     def stop_propagation(self):
    181         self._raw.stopPropagation()
    182     def stop_immediate_propagation(self):
    183         self._raw.stopImmediatePropagation()
    184 
    185 class Element:
    186     def __init__(self, obj):
    187         self._obj = obj
    188 
    189     @property
    190     def element(self): return self._obj
    191     @property
    192     def value(self): return self._obj.value if hasattr(self._obj, 'value') else ''
    193     @value.setter
    194     def value(self, value): self._obj.value = '' if value is None else str(value)
    195     @property
    196     def text(self): return self._obj.textContent or ''
    197     @text.setter
    198     def text(self, value): self._obj.textContent = '' if value is None else str(value)
    199     @property
    200     def html(self): return self._obj.innerHTML or ''
    201     @html.setter
    202     def html(self, value): self._obj.innerHTML = '' if value is None else str(value)
    203     @property
    204     def checked(self): return bool(getattr(self._obj, 'checked', False))
    205     @checked.setter
    206     def checked(self, value): self._obj.checked = bool(value)
    207     @property
    208     def selected(self): return bool(getattr(self._obj, 'selected', False))
    209     @selected.setter
    210     def selected(self, value): self._obj.selected = bool(value)
    211     @property
    212     def disabled(self): return bool(getattr(self._obj, 'disabled', False))
    213     @disabled.setter
    214     def disabled(self, value): self._obj.disabled = bool(value)
    215     @property
    216     def visible(self): return bool(js.pytmlInstance.element(self._obj).visible)
    217     @visible.setter
    218     def visible(self, value): js.pytmlInstance.element(self._obj).visible = bool(value)
    219     @property
    220     def id(self): return str(self._obj.id)
    221     @property
    222     def tag(self): return str(self._obj.tagName).lower()
    223     @property
    224     def placeholder(self): return getattr(self._obj, 'placeholder', '')
    225     @placeholder.setter
    226     def placeholder(self, value): self._obj.placeholder = str(value)
    227     @property
    228     def class_name(self): return str(self._obj.className)
    229     @class_name.setter
    230     def class_name(self, value): self._obj.className = str(value)
    231     @property
    232     def x(self): return float(self._obj.getBoundingClientRect().x)
    233     @property
    234     def y(self): return float(self._obj.getBoundingClientRect().y)
    235     @property
    236     def width(self): return float(self._obj.getBoundingClientRect().width)
    237     @property
    238     def height(self): return float(self._obj.getBoundingClientRect().height)
    239     @property
    240     def scroll_top(self): return float(getattr(self._obj, 'scrollTop', 0))
    241     @scroll_top.setter
    242     def scroll_top(self, value): self._obj.scrollTop = float(value)
    243     @property
    244     def scroll_left(self): return float(getattr(self._obj, 'scrollLeft', 0))
    245     @scroll_left.setter
    246     def scroll_left(self, value): self._obj.scrollLeft = float(value)
    247     @property
    248     def files(self):
    249         files = getattr(self._obj, 'files', None)
    250         if not files:
    251             return []
    252         return [{'name': f.name, 'size': int(f.size), 'type': f.type, 'last_modified': int(f.lastModified)} for f in files]
    253 
    254     def attr(self, name): return self._obj.getAttribute(str(name))
    255     def set_attr(self, name, value): self._obj.setAttribute(str(name), str(value))
    256     def remove_attr(self, name): self._obj.removeAttribute(str(name))
    257     def add_class(self, name): self._obj.classList.add(str(name))
    258     def remove_class(self, name): self._obj.classList.remove(str(name))
    259     def toggle_class(self, name, force=None):
    260         if force is None:
    261             return bool(self._obj.classList.toggle(str(name)))
    262         return bool(self._obj.classList.toggle(str(name), bool(force)))
    263     def has_class(self, name): return bool(self._obj.classList.contains(str(name)))
    264     def focus(self): self._obj.focus()
    265     def blur(self): self._obj.blur()
    266     def click(self): self._obj.click()
    267     def remove(self): self._obj.remove()
    268     def clear(self): js.pytmlInstance.clear_element(self._obj)
    269     def scroll_to(self, x=0, y=0, behavior='auto'): js.pytmlInstance.scroll_element(self._obj, x, y, behavior)
    270     def into_view(self, **kwargs): self._obj.scrollIntoView(kwargs)
    271     def set_style(self, name, value): self._obj.style[str(name)] = str(value)
    272     def get_style(self, name): return str(js.pytmlInstance.get_style(self._obj, str(name)))
    273     def emit(self, name, detail=None): js.pytmlInstance.emit(self._obj, str(name), detail or {})
    274 
    275     def on(self, event, callback, options=None):
    276         def handler(raw):
    277             try:
    278                 normalized = js.pytmlInstance.api_event(raw).to_py()
    279                 result = callback(Event(normalized, raw))
    280                 if asyncio.iscoroutine(result):
    281                     asyncio.ensure_future(result)
    282                 return None
    283             except Exception:
    284                 import traceback
    285                 js.pytmlInstance.addError(traceback.format_exc())
    286                 return None
    287         proxy = create_proxy(handler)
    288         self._obj.addEventListener(str(event), proxy, options)
    289         _callbacks.append((self._obj, str(event), callback, proxy))
    290 
    291         def off():
    292             try:
    293                 self._obj.removeEventListener(str(event), proxy, options)
    294             finally:
    295                 try: proxy.destroy()
    296                 except Exception: pass
    297                 try: _callbacks.remove((self._obj, str(event), callback, proxy))
    298                 except ValueError: pass
    299         return off
    300 
    301     def off(self, event, callback):
    302         keep = []
    303         for obj, ev, cb, proxy in _callbacks:
    304             if obj is self._obj and ev == str(event) and cb is callback:
    305                 try: obj.removeEventListener(ev, proxy)
    306                 except Exception: pass
    307                 try: proxy.destroy()
    308                 except Exception: pass
    309             else:
    310                 keep.append((obj, ev, cb, proxy))
    311         _callbacks[:] = keep
    312 
    313     def on_click(self, callback, options=None): return self.on('click', callback, options)
    314     def on_input(self, callback, options=None): return self.on('input', callback, options)
    315     def on_change(self, callback, options=None): return self.on('change', callback, options)
    316     def on_keydown(self, callback, options=None): return self.on('keydown', callback, options)
    317     def on_keyup(self, callback, options=None): return self.on('keyup', callback, options)
    318     def on_scroll(self, callback, options=None): return self.on('scroll', callback, options)
    319     def play(self): return self._obj.play()
    320     def pause(self): return self._obj.pause()
    321     def request_fullscreen(self): return self._obj.requestFullscreen()
    322     def canvas_context(self, kind='2d'): return self._obj.getContext(str(kind))
    323 
    324 class Joystick(Element):
    325     @property
    326     def joystick_x(self): return float(js.pytmlInstance.joystick_state(self._obj)['x'])
    327     @property
    328     def joystick_y(self): return float(js.pytmlInstance.joystick_state(self._obj)['y'])
    329     @property
    330     def magnitude(self): return float(js.pytmlInstance.joystick_state(self._obj)['magnitude'])
    331     @property
    332     def angle(self): return float(js.pytmlInstance.joystick_state(self._obj)['angle'])
    333     @property
    334     def active(self): return bool(js.pytmlInstance.joystick_state(self._obj)['active'])
    335 
    336 
    337 def element(target): return Element(js.pytmlInstance.element(target).element)
    338 def get(target): return element(target)
    339 def query(selector): return element(selector)
    340 def query_all(selector): return [Element(x) for x in js.document.querySelectorAll(str(selector))]
    341 def joystick(target): return Joystick(js.pytmlInstance.makeJoystick(target).element)
    342 async def sleep(ms): await js.pytmlInstance.sleep(int(ms))
    343 def timeout(callback, ms): 
    344     from pyodide.ffi import create_proxy
    345     return js.pytmlInstance.setTimeout(create_proxy(callback), int(ms))
    346 def interval(callback, ms): 
    347     from pyodide.ffi import create_proxy
    348     return js.pytmlInstance.setInterval(create_proxy(callback), int(ms))
    349 def clear_timeout(timer_id): return js.pytmlInstance.clearTimeout(int(timer_id))
    350 def clear_interval(timer_id): return js.pytmlInstance.clearInterval(int(timer_id))
    351 async def clipboard_copy(text): await js.pytmlInstance.clipboard_write(str(text))
    352 async def clipboard_read(): return await js.pytmlInstance.clipboard_read()
    353 def alert(message): return js.pytmlInstance.alert(str(message))
    354 def confirm(message): return bool(js.pytmlInstance.confirm(str(message)))
    355 def download(data, filename, mime='text/plain;charset=utf-8'): return js.pytmlInstance.download(data, filename, mime)
    356 def gamepads(): return js.pytmlInstance.gamepads()
    357 def storage_get(key, fallback=None): return js.pytmlInstance.storage_get(key, fallback)
    358 def storage_set(key, value): return js.pytmlInstance.storage_set(key, value)
    359 def storage_remove(key): return js.pytmlInstance.storage_remove(key)
    360 def escape(text):
    361     """Safely escape HTML characters (<, >, &) from user input."""
    362     return _html_escape.escape(str(text))
    363 def install(requirements, **kwargs):
    364     import micropip
    365     return micropip.install(requirements, **kwargs)
    366 
    367 version = '2.5.1'
    368 pyodide_version = '314.0.7'
    369 
    370 def _bind_all_dom(namespace):
    371     elements = {}
    372     for obj in js.document.querySelectorAll('[id]'):
    373         name = str(obj.id)
    374         if name.isidentifier():
    375             elements[name] = Element(obj)
    376             if name not in namespace:
    377                 namespace[name] = elements[name]
    378     class _Elements:
    379         def __getattr__(self, name):
    380             try:
    381                 return elements[name]
    382             except KeyError as exc:
    383                 raise AttributeError(name) from exc
    384         def __getitem__(self, name):
    385             try:
    386                 return elements[str(name)]
    387             except KeyError as exc:
    388                 raise KeyError(name) from exc
    389     namespace['elements'] = _Elements()
    390     namespace['document'] = js.document
    391     namespace['window'] = js.window
    392     namespace['console'] = js.console
    393     namespace['pytml'] = __import__('pytml')
    394 `;
    395 
    396   function installEarlyPythonHide() {
    397     if (typeof document === 'undefined') return;
    398     if (document.getElementById('pytml-early-hide')) return;
    399     const style = document.createElement('style');
    400     style.id = 'pytml-early-hide';
    401     style.textContent = 'py,py0,py1,py2,py3,py4,py5,py6,py7,py8,py9,py10,py11,py12,py13,py14,py15,py16,py17,py18,py19,py20,py21,py22,py23,py24,py25,py26,py27,py28,py29,py30,py31,py32,py33,py34,py35,py36,py37,py38,py39,py40,py41,py42,py43,py44,py45,py46,py47,py48,py49,py50,py51,py52,py53,py54,py55,py56,py57,py58,py59,py60,py61,py62,py63,py64,py65,py66,py67,py68,py69,py70,py71,py72,py73,py74,py75,py76,py77,py78,py79,py80,py81,py82,py83,py84,py85,py86,py87,py88,py89,py90,py91,py92,py93,py94,py95,py96,py97,py98,py99,py100 { display:none !important; }';
    402     (document.head || document.documentElement).appendChild(style);
    403   }
    404 
    405   function normalizeEvent(event) {
    406     const target = event?.currentTarget || event?.target || null;
    407     const rect = target?.getBoundingClientRect ? target.getBoundingClientRect() : null;
    408     const detail = event?.detail || {};
    409     return {
    410       type: event?.type || '',
    411       x: Number(event?.clientX ?? detail.x ?? 0),
    412       y: Number(event?.clientY ?? detail.y ?? 0),
    413       dx: Number(event?.movementX ?? detail.dx ?? 0),
    414       dy: Number(event?.movementY ?? detail.dy ?? 0),
    415       deltaX: Number(event?.deltaX ?? 0),
    416       deltaY: Number(event?.deltaY ?? 0),
    417       key: event?.key || '',
    418       code: event?.code || '',
    419       button: Number(event?.button ?? 0),
    420       buttons: Number(event?.buttons ?? 0),
    421       pointerId: Number(event?.pointerId ?? 0),
    422       pointerType: event?.pointerType || '',
    423       value: event?.target?.value ?? '',
    424       checked: !!event?.target?.checked,
    425       scrollTop: Number(event?.target?.scrollTop ?? 0),
    426       scrollLeft: Number(event?.target?.scrollLeft ?? 0),
    427       ctrlKey: !!event?.ctrlKey,
    428       shiftKey: !!event?.shiftKey,
    429       altKey: !!event?.altKey,
    430       metaKey: !!event?.metaKey,
    431       width: Number(rect?.width ?? 0),
    432       height: Number(rect?.height ?? 0),
    433       joystick_x: detail.x,
    434       joystick_y: detail.y,
    435       magnitude: detail.magnitude,
    436       angle: detail.angle,
    437       active: detail.active
    438     };
    439   }
    440 
    441   // Inline UI syntax supported inside <py>, <py1>, <py2>, ... blocks.
    442   // These tags are converted to real browser elements before the Python is
    443   // sent to Pyodide. Non-UI text nodes are preserved, including indentation.
    444   const PY_INLINE_UI = /^(btn|input|txt)(\d+)$/i;
    445 
    446   function copyInlineAttributes(source, target, kind) {
    447     for (const attr of Array.from(source.attributes || [])) {
    448       const name = attr.name.toLowerCase();
    449       if (name === 'id') continue;
    450       if (kind === 'txt' && name === 'type') continue;
    451       target.setAttribute(attr.name, attr.value);
    452     }
    453   }
    454 
    455   class Pytml {
    456     constructor(options = {}) {
    457       this.version = VERSION;
    458       this.pyodideVersion = PYODIDE_VERSION;
    459       this.options = {
    460         pyodideVersion: PYODIDE_VERSION,
    461         pyodideIndexURL: PYODIDE_INDEX,
    462         autoOutput: options.autoOutput === true,
    463         loadPackagesFromImports: options.loadPackagesFromImports !== false,
    464         ...options
    465       };
    466       this.pyodide = null;
    467       this.outputContainer = null;
    468       this.statusElement = null;
    469       this.ready = false;
    470       this.readyPromise = null;
    471       this.programs = new Map();
    472       this._callbackRegistry = new Map();
    473       this._intervals = new Map();
    474       this._timeouts = new Map();
    475       this._joystickState = new WeakMap();
    476     }
    477 
    478     async start() {
    479       if (!this.readyPromise) this.readyPromise = this._start();
    480       return this.readyPromise;
    481     }
    482 
    483     async _start() {
    484       preconnect();
    485       installBootState();
    486       this.installStyles();
    487 
    488       // Always use the remote Pyodide CDN from file:// pages. This avoids
    489       // trying to resolve Pyodide assets relative to a local file origin.
    490       const indexURL = IS_FILE_PROTOCOL
    491         ? PYODIDE_INDEX
    492         : this.options.pyodideIndexURL;
    493 
    494       await this.loadPyodideScript();
    495       this.pyodide = await global.loadPyodide({ indexURL });
    496       global.pyodide = this.pyodide;
    497       global.pytmlInstance = this;
    498       global.pytml = this;
    499       await this.installPythonModule();
    500       await this.setupPythonEnvironment();
    501       this.ready = true;
    502       await this.runAllPythonScripts();
    503       clearBootState();
    504       document.dispatchEvent(new CustomEvent('pytml:ready', { detail: this }));
    505       return this;
    506     }
    507 
    508     installStyles() {
    509       if (document.getElementById('pytml-runtime-style')) return;
    510       const style = document.createElement('style');
    511       style.id = 'pytml-runtime-style';
    512       style.textContent = DEFAULT_STYLE;
    513       document.head.appendChild(style);
    514     }
    515 
    516     createOutputContainer() {
    517       let container = document.getElementById('pytml-output');
    518       if (!container) {
    519         container = document.createElement('div');
    520         container.id = 'pytml-output';
    521         const first = this.findPythonBlocks()[0]?.element;
    522         if (first?.parentNode) first.parentNode.insertBefore(container, first);
    523         else document.body.insertBefore(container, document.body.firstChild);
    524       }
    525       container.classList.add('pytml-output');
    526       this.outputContainer = container;
    527     }
    528 
    529     async loadPyodideScript() {
    530       await preloadPyodideScript();
    531     }
    532 
    533     async installPythonModule() {
    534       this.pyodide.globals.set('_pytml_module_source', PYTHON_MODULE);
    535       await this.pyodide.runPythonAsync(`
    536 import sys, types
    537 _pytml_module = types.ModuleType('pytml')
    538 exec(_pytml_module_source, _pytml_module.__dict__)
    539 sys.modules['pytml'] = _pytml_module
    540 `);
    541       this.pyodide.globals.delete('_pytml_module_source');
    542     }
    543 
    544     async setupPythonEnvironment() {
    545       const bootstrap = String.raw`
    546 import sys, ast, builtins, pytml
    547 import js
    548 
    549 class _OutputRedirect:
    550     def __init__(self, error=False): self.error = error
    551     def write(self, text):
    552         if text: js.pytmlInstance.addOutput(str(text), self.error)
    553     def flush(self): pass
    554 
    555 sys.stdout = _OutputRedirect(False)
    556 
    557 async def _pytml_input(prompt=""):
    558     return await js.pytmlInstance.getUserInput(str(prompt))
    559 
    560 builtins.input = _pytml_input
    561 
    562 class _InputTransformer(ast.NodeTransformer):
    563     def visit_Call(self, node):
    564         self.generic_visit(node)
    565         if isinstance(node.func, ast.Name) and node.func.id == "input":
    566             return ast.copy_location(ast.Await(value=node), node)
    567         return node
    568 
    569 def _pytml_transform_tree(source):
    570     tree = ast.parse(source)
    571     tree = _InputTransformer().visit(tree)
    572     ast.fix_missing_locations(tree)
    573     return tree
    574 
    575 async def _pytml_run_source(source):
    576     source = str(source)
    577     tree = _pytml_transform_tree(source)
    578     await js.pytmlInstance.prepare_python_globals()
    579     code = compile(tree, "<pytml>", "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT)
    580     result = eval(code, globals(), globals())
    581     if result is not None:
    582         import inspect
    583         if inspect.isawaitable(result):
    584             await result
    585 `;
    586       await this.pyodide.runPythonAsync(bootstrap);
    587     }
    588 
    589     preprocessPythonBlock(el) {
    590       if (el.__pytmlPreprocessed) return el.__pytmlPreprocessed;
    591 
    592       const declarations = [];
    593       let code = '';
    594 
    595       const visit = (node) => {
    596         for (const child of Array.from(node.childNodes || [])) {
    597           if (child.nodeType === Node.TEXT_NODE) {
    598             code += child.nodeValue || '';
    599             continue;
    600           }
    601           if (child.nodeType !== Node.ELEMENT_NODE) continue;
    602 
    603           const tag = child.tagName?.toLowerCase?.() || '';
    604           const match = tag.match(PY_INLINE_UI);
    605           if (match) {
    606             declarations.push({
    607               node: child,
    608               kind: match[1].toLowerCase(),
    609               id: `${match[1].toLowerCase()}${match[2]}`,
    610               content: child.textContent || ''
    611             });
    612             continue;
    613           }
    614 
    615           // Ordinary wrapper elements contribute their text to Python source.
    616           visit(child);
    617         }
    618       };
    619 
    620       visit(el);
    621 
    622       if (declarations.length) {
    623         const fragment = document.createDocumentFragment();
    624         for (const declaration of declarations) {
    625           const { node, kind, id, content } = declaration;
    626           const existing = document.getElementById(id);
    627           if (existing && existing !== el && !existing.hasAttribute('data-pytml-generated')) {
    628             throw new Error(`Pytml UI id "${id}" already exists outside its <pyN> block.`);
    629           }
    630 
    631           const generated = document.createElement(kind === 'btn' ? 'button' : 'input');
    632           if (kind === 'txt' || (kind === 'input' && !node.hasAttribute('type'))) generated.type = 'text';
    633           copyInlineAttributes(node, generated, kind);
    634           generated.id = id;
    635           generated.setAttribute('data-pytml-generated', '');
    636 
    637           if (kind === 'btn') {
    638             generated.type = node.getAttribute('type') || 'button';
    639             generated.textContent = content;
    640           } else if (content.trim()) {
    641             generated.placeholder = content.trim();
    642           }
    643 
    644           fragment.appendChild(generated);
    645           node.remove();
    646         }
    647         if (el.parentNode) el.parentNode.insertBefore(fragment, el);
    648       }
    649 
    650       const processed = {
    651         code,
    652         declarations: declarations.map(({ kind, id }) => ({ kind, id }))
    653       };
    654       el.__pytmlPreprocessed = processed;
    655       return processed;
    656     }
    657 
    658     findPythonBlocks() {
    659       const result = [];
    660       document.querySelectorAll('*').forEach((el) => {
    661         const tag = el.tagName?.toLowerCase?.() || '';
    662         if (PY_BLOCK.test(tag)) {
    663           el.setAttribute('data-pytml-hidden', '');
    664           el.hidden = true;
    665           const processed = this.preprocessPythonBlock(el);
    666           result.push({
    667             element: el,
    668             id: tag,
    669             src: el.getAttribute('src'),
    670             code: processed.code,
    671             ui: processed.declarations
    672           });
    673         }
    674       });
    675       document.querySelectorAll('script[type="text/python"]').forEach((el, index) => {
    676         result.push({
    677           element: el,
    678           id: (el.getAttribute('data-id') || el.id || `script-${index + 1}`).toLowerCase(),
    679           src: el.getAttribute('src'),
    680           code: el.textContent,
    681           ui: []
    682         });
    683       });
    684       return result;
    685     }
    686 
    687     async runAllPythonScripts() {
    688       for (const block of this.findPythonBlocks()) {
    689         if (block.src) {
    690           const resolved = new URL(block.src, document.baseURI);
    691           if (location.protocol === 'file:' && resolved.protocol === 'file:') {
    692             throw new Error(`Pytml cannot read a separate local Python file ("${block.src}") from file:// because browsers block local-file fetches. Use inline <py> / <pyN> code or an HTTPS src.`);
    693           }
    694           const response = await fetch(resolved.href, {
    695             credentials: IS_FILE_PROTOCOL ? 'omit' : 'same-origin',
    696             mode: 'cors'
    697           });
    698           if (!response.ok) throw new Error(`HTTP ${response.status} while loading ${resolved.href}`);
    699           await this.executePythonCode(await response.text(), block.id);
    700         } else if (block.code.trim()) {
    701           await this.executePythonCode(block.code, block.id);
    702         }
    703         block.element.setAttribute('data-pytml-hidden', '');
    704       }
    705     }
    706 
    707     async prepare_python_globals() {
    708       await this.pyodide.runPythonAsync('from pytml import _bind_all_dom; _bind_all_dom(globals())');
    709     }
    710 
    711     async executePythonCode(source, programId = 'py') {
    712       if (!this.ready) throw new Error('Pytml is not ready yet.');
    713       if (this.options.loadPackagesFromImports) {
    714         try { await this.pyodide.loadPackagesFromImports(source); } catch (_) {}
    715       }
    716       this.pyodide.globals.set('_pytml_source', source);
    717       try {
    718         await this.pyodide.runPythonAsync('await _pytml_run_source(_pytml_source)');
    719         this.programs.set(programId, (this.programs.get(programId) || 0) + 1);
    720       } catch (error) {
    721         const message = error?.message || String(error);
    722         this.addError(`Execution error in ${programId}: ${message}`);
    723         return false;
    724       } finally {
    725         this.pyodide.globals.delete('_pytml_source');
    726       }
    727     }
    728 
    729     element(target) {
    730       let el = target;
    731       if (typeof target === 'string') {
    732         const looksLikeSelector = target.startsWith('#') || target.startsWith('.') || target.includes('[') || target.includes(' ') || target.includes(':');
    733         el = looksLikeSelector ? document.querySelector(target) : document.getElementById(target);
    734       }
    735       if (!el) throw new Error(`Pytml element not found: ${target}`);
    736       return this.makeElementProxy(el);
    737     }
    738 
    739     makeElementProxy(el) {
    740       return {
    741         element: el,
    742         get value() { return el.value ?? ''; },
    743         set value(v) { el.value = v == null ? '' : String(v); },
    744         get text() { return el.textContent ?? ''; },
    745         set text(v) { el.textContent = v == null ? '' : String(v); },
    746         get html() { return el.innerHTML ?? ''; },
    747         set html(v) { el.innerHTML = v == null ? '' : String(v); },
    748         get checked() { return !!el.checked; },
    749         set checked(v) { el.checked = !!v; },
    750         get selected() { return !!el.selected; },
    751         set selected(v) { el.selected = !!v; },
    752         get disabled() { return !!el.disabled; },
    753         set disabled(v) { el.disabled = !!v; },
    754         get visible() { return getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden'; },
    755         set visible(v) { el.style.display = v ? '' : 'none'; },
    756         get_style: (name) => getComputedStyle(el)[String(name)],
    757         set_style: (name, value) => { el.style[String(name)] = String(value); },
    758         clear: () => { while (el.firstChild) el.removeChild(el.firstChild); if ('value' in el) el.value = ''; },
    759         scroll_to: (x, y, behavior='auto') => el.scrollTo({ left: Number(x)||0, top: Number(y)||0, behavior }),
    760         emit: (name, detail={}) => el.dispatchEvent(new CustomEvent(String(name), { detail, bubbles:true })),
    761         get files() { return [...(el.files || [])].map(f => ({name:f.name,size:f.size,type:f.type,last_modified:f.lastModified})); }
    762       };
    763     }
    764 
    765     api_event(event) { return normalizeEvent(event); }
    766     clear_element(el) { while (el.firstChild) el.removeChild(el.firstChild); if ('value' in el) el.value = ''; }
    767     scroll_element(el, x=0, y=0, behavior='auto') { el.scrollTo({ left:Number(x)||0, top:Number(y)||0, behavior }); }
    768     get_style(el, name) { return getComputedStyle(el)[name]; }
    769     emit(el, name, detail={}) { el.dispatchEvent(new CustomEvent(name, { detail, bubbles:true })); }
    770     sleep(ms) { return new Promise(resolve => setTimeout(resolve, Number(ms)||0)); }
    771 
    772     bindPythonCallback(el, event, callback, options=undefined) {
    773       if (!this._callbackRegistry.has(el)) this._callbackRegistry.set(el, new Map());
    774       const eventMap = this._callbackRegistry.get(el);
    775       if (!eventMap.has(event)) eventMap.set(event, []);
    776       const proxy = this.pyodide.ffi.create_proxy((raw) => {
    777         try { return callback(normalizeEvent(raw)); }
    778         catch (error) { this.addError(`Event handler error: ${error.message}`); }
    779       });
    780       el.addEventListener(event, proxy, options);
    781       eventMap.get(event).push({ callback, proxy });
    782       return () => {
    783         el.removeEventListener(event, proxy, options);
    784         try { proxy.destroy(); } catch (_) {}
    785       };
    786     }
    787 
    788     async getUserInput(prompt) {
    789       return new Promise((resolve) => {
    790         if (!this.outputContainer) this.createOutputContainer();
    791         const box = document.createElement('div');
    792         box.className = 'pytml-input-container';
    793         const label = document.createElement('div'); label.className = 'pytml-input-prompt'; label.textContent = prompt || 'Enter value:';
    794         const input = document.createElement('input'); input.className = 'pytml-input-field'; input.type = 'text'; input.autocomplete = 'off';
    795         const submit = document.createElement('button'); submit.className = 'pytml-input-submit'; submit.type = 'button'; submit.textContent = 'Submit';
    796         box.append(label, input, submit); this.outputContainer.appendChild(box);
    797         let done = false;
    798         const finish = () => { if (done) return; done = true; const value=input.value; box.remove(); resolve(value); };
    799         submit.addEventListener('click', finish); input.addEventListener('keydown', e => { if (e.key === 'Enter') finish(); });
    800         input.focus(); box.scrollIntoView({behavior:'smooth',block:'center'});
    801       });
    802     }
    803 
    804     addOutput(text, isError=false) {
    805       if (!this.outputContainer) this.createOutputContainer();
    806       const line = document.createElement('div');
    807       line.className = `pytml-line${isError ? ' pytml-error' : ''}`;
    808       line.textContent = String(text);
    809       this.outputContainer.appendChild(line);
    810       this.outputContainer.scrollTop = this.outputContainer.scrollHeight;
    811     }
    812 
    813     addError(text) { this.addOutput(`X ${text}`, true); }
    814 
    815     showStatus(message, isError=false) {
    816       if (!this.statusElement) { this.statusElement=document.createElement('div'); document.body.appendChild(this.statusElement); }
    817       this.statusElement.className = `pytml-status${isError ? ' error' : ''}`;
    818       this.statusElement.textContent = String(message);
    819       this.statusElement.style.display = 'block';
    820     }
    821     hideStatus() { if (this.statusElement) { this.statusElement.remove(); this.statusElement=null; } }
    822 
    823     setTimeout(fn, ms) {
    824       const id = window.setTimeout(() => { 
    825         try { fn(); } catch (e) { this.addError(`Timer error: ${e.message}`); } 
    826       }, Number(ms)||0);
    827       this._timeouts.set(id, fn); 
    828       return id;
    829     }
    830 
    831     setInterval(fn, ms) {
    832       const id = window.setInterval(() => { 
    833         try { fn(); } catch (e) { this.addError(`Timer error: ${e.message}`); } 
    834       }, Number(ms)||0);
    835       this._intervals.set(id, fn); 
    836       return id;
    837     }
    838     clearTimeout(id) { 
    839       window.clearTimeout(id); 
    840       const fn = this._timeouts.get(id);
    841       if (fn) { 
    842         try { fn.destroy(); } catch (_) {} 
    843         this._timeouts.delete(id); 
    844       }
    845     }
    846 
    847     clearInterval(id) { 
    848       window.clearInterval(id); 
    849       const fn = this._intervals.get(id);
    850       if (fn) { 
    851         try { fn.destroy(); } catch (_) {} 
    852         this._intervals.delete(id); 
    853       }
    854     }
    855     clipboard_write(text) {
    856       if (!navigator.clipboard?.writeText) return Promise.reject(new Error('Clipboard API unavailable'));
    857       return navigator.clipboard.writeText(String(text));
    858     }
    859     clipboard_read() {
    860       if (!navigator.clipboard?.readText) return Promise.reject(new Error('Clipboard API unavailable'));
    861       return navigator.clipboard.readText();
    862     }
    863     alert(message) { return window.alert(String(message)); }
    864     confirm(message) { return window.confirm(String(message)); }
    865     storage_get(key, fallback=null) { return localStorage.getItem(String(key)) ?? fallback; }
    866     storage_set(key, value) { localStorage.setItem(String(key), String(value)); }
    867     storage_remove(key) { localStorage.removeItem(String(key)); }
    868     download(data, filename, mime='text/plain;charset=utf-8') {
    869       const blob = data instanceof Blob ? data : new Blob([String(data)], { type: mime });
    870       const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href=url; a.download=String(filename); a.click();
    871       setTimeout(() => URL.revokeObjectURL(url), 1000);
    872     }
    873 
    874     makeJoystick(target) {
    875       const el = this.element(target).element;
    876       if (el.__pytmlJoystick) return this.makeJoystickProxy(el);
    877       el.classList.add('pytml-joystick'); el.style.touchAction='none';
    878       const state={x:0,y:0,magnitude:0,angle:0,active:false}; this._joystickState.set(el,state);
    879       const knob=document.createElement('div'); knob.className='pytml-joystick-knob'; el.appendChild(knob);
    880       const move=(ev)=>{
    881         const r=el.getBoundingClientRect(), cx=r.left+r.width/2, cy=r.top+r.height/2, max=Math.max(1,Math.min(r.width,r.height)/2);
    882         const dx=ev.clientX-cx, dy=ev.clientY-cy, angle=Math.atan2(dy,dx), magnitude=Math.min(1,Math.hypot(dx,dy)/max);
    883         state.x=Math.cos(angle)*magnitude; state.y=Math.sin(angle)*magnitude; state.magnitude=magnitude; state.angle=angle; state.active=true;
    884         knob.style.left=`${50+state.x*35}%`; knob.style.top=`${50+state.y*35}%`;
    885         el.dispatchEvent(new CustomEvent('joystickmove',{detail:{...state},bubbles:true}));
    886       };
    887       const reset=(ev)=>{ state.x=state.y=state.magnitude=state.angle=0; state.active=false; knob.style.left='50%'; knob.style.top='50%'; el.dispatchEvent(new CustomEvent('joystickmove',{detail:{...state},bubbles:true})); try{el.releasePointerCapture?.(ev.pointerId)}catch(_){} };
    888       el.addEventListener('pointerdown',ev=>{el.setPointerCapture?.(ev.pointerId);move(ev)});
    889       el.addEventListener('pointermove',ev=>{if(el.hasPointerCapture?.(ev.pointerId))move(ev)});
    890       el.addEventListener('pointerup',reset); el.addEventListener('pointercancel',reset); el.__pytmlJoystick=true;
    891       return this.makeJoystickProxy(el);
    892     }
    893     makeJoystickProxy(el) {
    894       const base=this.makeElementProxy(el);
    895       Object.defineProperties(base, {
    896         joystick_x:{enumerable:true,get:()=>this._joystickState.get(el)?.x||0},
    897         joystick_y:{enumerable:true,get:()=>this._joystickState.get(el)?.y||0},
    898         magnitude:{enumerable:true,get:()=>this._joystickState.get(el)?.magnitude||0},
    899         angle:{enumerable:true,get:()=>this._joystickState.get(el)?.angle||0},
    900         active:{enumerable:true,get:()=>this._joystickState.get(el)?.active||false}
    901       });
    902       return base;
    903     }
    904     joystick_state(el) { return this._joystickState.get(el) || {x:0,y:0,magnitude:0,angle:0,active:false}; }
    905     gamepads() { return [...(navigator.getGamepads?.()||[])].filter(Boolean).map(g=>({id:g.id,index:g.index,connected:g.connected,buttons:[...g.buttons].map(b=>({pressed:b.pressed,value:b.value})),axes:[...g.axes]})); }
    906   }
    907 
    908   let singleton=null;
    909   function start(options={}) { if(!singleton) singleton=new Pytml(options); return singleton.start(); }
    910   async function boot() {
    911     const run = async () => {
    912       if (!pageHasPythonBlocks()) return;
    913       try {
    914         await start();
    915       } catch (err) {
    916         console.error('[PYTML] boot failed:', err);
    917         failBootState(err?.message || 'Python failed to load. Refresh to retry.');
    918       }
    919     };
    920 
    921     if (document.readyState === 'loading') {
    922       document.addEventListener('DOMContentLoaded', run, { once: true });
    923     } else {
    924       run();
    925     }
    926   }
    927 
    928   installEarlyPythonHide();
    929 
    930   global.Pytml=Pytml;
    931   global.PytmlStart=start;
    932   global.pytml=global.pytml||{start};
    933   boot();
    934 })(typeof window!=='undefined'?window:globalThis);