@oml/markdown 0.15.0 → 0.16.1

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.
@@ -572,4 +572,806 @@ async function applyExecutionResults(): Promise<void> {
572
572
  }
573
573
 
574
574
  setupDownloadHandler();
575
- void applyExecutionResults();
575
+
576
+ function ensureInitialContentLoaded(): void {
577
+ const content = document.getElementById('oml-md-content');
578
+ if (!content) {
579
+ return;
580
+ }
581
+ if (content.childNodes.length > 0) {
582
+ return;
583
+ }
584
+ const template = document.getElementById('oml-md-initial-content');
585
+ if (!(template instanceof HTMLTemplateElement)) {
586
+ return;
587
+ }
588
+ content.innerHTML = template.innerHTML;
589
+ }
590
+
591
+ async function bootstrapStaticRender(): Promise<void> {
592
+ void pruneOldRuntimeCaches();
593
+ // Hard gate first paint to prevent transient unhydrated flashes on reload,
594
+ // even for pages generated before wrapper/boot-class support.
595
+ const body = document.body;
596
+ const previousBodyVisibility = body.style.visibility;
597
+ body.style.visibility = 'hidden';
598
+
599
+ // Always start from template content so code blocks are re-hydrated deterministically.
600
+ // Full-page HTML cache can preserve stale interactive table state across reloads.
601
+ ensureInitialContentLoaded();
602
+ try {
603
+ await applyExecutionResults();
604
+ await Promise.all([
605
+ applyJsBlocks(),
606
+ applyPyBlocks(),
607
+ applyRBlocks(),
608
+ ]);
609
+ } finally {
610
+ document.documentElement.classList.remove('oml-md-booting');
611
+ document.body.classList.remove('oml-md-booting');
612
+ body.style.visibility = previousBodyVisibility || 'visible';
613
+ }
614
+ }
615
+
616
+ // ── Shared types ─────────────────────────────────────────────────────────────
617
+
618
+ type QueryResult = {
619
+ success: boolean;
620
+ columns: string[];
621
+ rows: Record<string, string>[];
622
+ error?: string;
623
+ };
624
+
625
+ function getScriptSparqlCache(): Record<string, Record<string, QueryResult>> {
626
+ return parseJsonNode<Record<string, Record<string, QueryResult>>>('oml-md-script-sparql-cache', {});
627
+ }
628
+
629
+ const PYTHON_PANEL_CACHE_STORAGE_KEY = 'oml-md-python-panel-cache-v1';
630
+ const R_PANEL_CACHE_STORAGE_KEY = 'oml-md-r-panel-cache-v1';
631
+ const JS_PANEL_CACHE_STORAGE_KEY = 'oml-md-js-panel-cache-v1';
632
+ const MAX_PYTHON_PANEL_CACHE_ENTRIES = 64;
633
+ const MAX_R_PANEL_CACHE_ENTRIES = 64;
634
+ const MAX_JS_PANEL_CACHE_ENTRIES = 64;
635
+ let pythonPanelHtmlCache = readPythonPanelCache();
636
+ let rPanelHtmlCache = readRPanelCache();
637
+ let jsPanelHtmlCache = readJsPanelCache();
638
+
639
+ function readPythonPanelCache(): Record<string, string> {
640
+ try {
641
+ const raw = window.localStorage.getItem(PYTHON_PANEL_CACHE_STORAGE_KEY);
642
+ const parsed = raw ? JSON.parse(raw) as unknown : {};
643
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
644
+ return {};
645
+ }
646
+ const cache: Record<string, string> = {};
647
+ for (const [key, value] of Object.entries(parsed)) {
648
+ if (typeof value === 'string') {
649
+ cache[key] = value;
650
+ }
651
+ }
652
+ return cache;
653
+ } catch {
654
+ return {};
655
+ }
656
+ }
657
+
658
+ function rememberPythonPanelCache(key: string, html: string): void {
659
+ const nextEntries = Object.entries(pythonPanelHtmlCache).filter(([entryKey]) => entryKey !== key);
660
+ nextEntries.push([key, html]);
661
+ while (nextEntries.length > MAX_PYTHON_PANEL_CACHE_ENTRIES) {
662
+ nextEntries.shift();
663
+ }
664
+ pythonPanelHtmlCache = Object.fromEntries(nextEntries);
665
+ try {
666
+ window.localStorage.setItem(PYTHON_PANEL_CACHE_STORAGE_KEY, JSON.stringify(pythonPanelHtmlCache));
667
+ } catch {
668
+ // Ignore storage quota/availability errors; Python still renders normally.
669
+ }
670
+ }
671
+
672
+ function readRPanelCache(): Record<string, string> {
673
+ try {
674
+ const raw = window.localStorage.getItem(R_PANEL_CACHE_STORAGE_KEY);
675
+ const parsed = raw ? JSON.parse(raw) as unknown : {};
676
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
677
+ return {};
678
+ }
679
+ const cache: Record<string, string> = {};
680
+ for (const [key, value] of Object.entries(parsed)) {
681
+ if (typeof value === 'string') {
682
+ cache[key] = value;
683
+ }
684
+ }
685
+ return cache;
686
+ } catch {
687
+ return {};
688
+ }
689
+ }
690
+
691
+ function rememberRPanelCache(key: string, html: string): void {
692
+ const nextEntries = Object.entries(rPanelHtmlCache).filter(([entryKey]) => entryKey !== key);
693
+ nextEntries.push([key, html]);
694
+ while (nextEntries.length > MAX_R_PANEL_CACHE_ENTRIES) {
695
+ nextEntries.shift();
696
+ }
697
+ rPanelHtmlCache = Object.fromEntries(nextEntries);
698
+ try {
699
+ window.localStorage.setItem(R_PANEL_CACHE_STORAGE_KEY, JSON.stringify(rPanelHtmlCache));
700
+ } catch {
701
+ // Ignore storage quota/availability errors; R still renders normally.
702
+ }
703
+ }
704
+
705
+ function readJsPanelCache(): Record<string, string> {
706
+ try {
707
+ const raw = window.localStorage.getItem(JS_PANEL_CACHE_STORAGE_KEY);
708
+ const parsed = raw ? JSON.parse(raw) as unknown : {};
709
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
710
+ return {};
711
+ }
712
+ const cache: Record<string, string> = {};
713
+ for (const [key, value] of Object.entries(parsed)) {
714
+ if (typeof value === 'string') {
715
+ cache[key] = value;
716
+ }
717
+ }
718
+ return cache;
719
+ } catch {
720
+ return {};
721
+ }
722
+ }
723
+
724
+ function rememberJsPanelCache(key: string, html: string): void {
725
+ const nextEntries = Object.entries(jsPanelHtmlCache).filter(([entryKey]) => entryKey !== key);
726
+ nextEntries.push([key, html]);
727
+ while (nextEntries.length > MAX_JS_PANEL_CACHE_ENTRIES) {
728
+ nextEntries.shift();
729
+ }
730
+ jsPanelHtmlCache = Object.fromEntries(nextEntries);
731
+ try {
732
+ window.localStorage.setItem(JS_PANEL_CACHE_STORAGE_KEY, JSON.stringify(jsPanelHtmlCache));
733
+ } catch {
734
+ // Ignore storage quota/availability errors; JavaScript still renders normally.
735
+ }
736
+ }
737
+
738
+ function buildPythonPanelCacheKey(blockId: string, source: string, blockCache: Record<string, QueryResult>): string {
739
+ const page = `${window.location.origin}${window.location.pathname}${window.location.search}`;
740
+ return `python|${page}|${blockId}|${hash32(source)}|${hash32(JSON.stringify(blockCache))}`;
741
+ }
742
+
743
+ function buildRPanelCacheKey(blockId: string, source: string, blockCache: Record<string, QueryResult>): string {
744
+ const page = `${window.location.origin}${window.location.pathname}${window.location.search}`;
745
+ return `r|${page}|${blockId}|${hash32(source)}|${hash32(JSON.stringify(blockCache))}`;
746
+ }
747
+
748
+ function buildJsPanelCacheKey(blockId: string, source: string, blockCache: Record<string, QueryResult>): string {
749
+ const page = `${window.location.origin}${window.location.pathname}${window.location.search}`;
750
+ return `js|${page}|${blockId}|${hash32(source)}|${hash32(JSON.stringify(blockCache))}`;
751
+ }
752
+
753
+ function hash32(input: string): string {
754
+ let hash = 2166136261;
755
+ for (let i = 0; i < input.length; i++) {
756
+ hash ^= input.charCodeAt(i);
757
+ hash = Math.imul(hash, 16777619);
758
+ }
759
+ return (hash >>> 0).toString(16);
760
+ }
761
+
762
+ function sparqlResultToHtmlTable(result: QueryResult): string {
763
+ if (!result.success || result.rows.length === 0) {
764
+ const msg = result.error ?? 'No results';
765
+ return result.error
766
+ ? `<p class="oml-md-js-error">${msg}</p>`
767
+ : `<p class="oml-md-js-empty">No results</p>`;
768
+ }
769
+ const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
770
+ const header = result.columns.map((c) => `<th>${esc(c)}</th>`).join('');
771
+ const body = result.rows.map((row) =>
772
+ `<tr>${result.columns.map((c) => `<td>${esc(row[c] ?? '')}</td>`).join('')}</tr>`
773
+ ).join('');
774
+ return `<div class="oml-md-table-wrapper"><table class="oml-md-table"><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></div>`;
775
+ }
776
+
777
+ function makeScriptPanel(): HTMLDivElement {
778
+ const panel = document.createElement('div');
779
+ panel.className = 'oml-md-js-result';
780
+ return panel;
781
+ }
782
+
783
+ function appendResult(
784
+ panel: HTMLElement,
785
+ result: { error?: string }
786
+ ): void {
787
+ if (result.error) {
788
+ const el = document.createElement('div');
789
+ el.className = 'oml-md-js-error';
790
+ el.textContent = result.error;
791
+ panel.appendChild(el);
792
+ }
793
+ if (panel.childElementCount === 0) {
794
+ const el = document.createElement('div');
795
+ el.className = 'oml-md-js-empty';
796
+ el.textContent = '(no output)';
797
+ panel.appendChild(el);
798
+ }
799
+ }
800
+
801
+ // ── JavaScript blocks ─────────────────────────────────────────────────────────
802
+
803
+ const loadedScripts = new Set<string>();
804
+
805
+ async function loadScript(url: string): Promise<void> {
806
+ if (loadedScripts.has(url)) {
807
+ return;
808
+ }
809
+ await new Promise<void>((resolve, reject) => {
810
+ const script = document.createElement('script');
811
+ script.src = url;
812
+ script.onload = () => { loadedScripts.add(url); resolve(); };
813
+ script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
814
+ document.head.appendChild(script);
815
+ });
816
+ }
817
+
818
+ function isPyodideNoise(text: string): boolean {
819
+ return /^(Loading|Loaded|Unloading) /.test(text)
820
+ || text.includes('already loaded from')
821
+ || text.includes('channel');
822
+ }
823
+
824
+ async function executeJsCode(
825
+ code: string,
826
+ blockCache: Record<string, QueryResult>,
827
+ container: HTMLElement
828
+ ): Promise<{ error?: string }> {
829
+ let statusEl: HTMLElement | undefined = document.createElement('div');
830
+ statusEl.className = 'oml-md-js-text-output';
831
+ statusEl.textContent = 'Running JavaScript block…';
832
+ container.appendChild(statusEl);
833
+ const clearStatus = (): void => {
834
+ if (statusEl?.parentElement === container) {
835
+ container.removeChild(statusEl);
836
+ }
837
+ statusEl = undefined;
838
+ };
839
+ const appendHtml = (html: string): void => {
840
+ clearStatus();
841
+ const el = document.createElement('div');
842
+ el.className = 'oml-md-js-html-output';
843
+ el.style.color = window.getComputedStyle(document.body).color;
844
+ el.innerHTML = html;
845
+ container.appendChild(el);
846
+ };
847
+ const appendText = (text: string): void => {
848
+ clearStatus();
849
+ const pre = document.createElement('pre');
850
+ pre.className = 'oml-md-js-text-output';
851
+ pre.textContent = text;
852
+ container.appendChild(pre);
853
+ };
854
+ const display = (content: unknown): void => {
855
+ if (typeof content === 'string') { appendHtml(content); } else { appendText(String(content)); }
856
+ };
857
+ const query = async (sparql: string): Promise<QueryResult> => {
858
+ return blockCache[sparql] ?? { success: false, columns: [], rows: [], error: 'SPARQL not pre-fetched for this query' };
859
+ };
860
+ const table = (result: QueryResult): string => sparqlResultToHtmlTable(result);
861
+ const load = async (url: string): Promise<void> => loadScript(url);
862
+
863
+ const savedLog = console.log;
864
+ const savedWarn = console.warn;
865
+ const savedError = console.error;
866
+ console.log = (...args: unknown[]): void => {
867
+ const text = args.map((v) => (typeof v === 'string' ? v : JSON.stringify(v, null, 2))).join(' ');
868
+ if (!isPyodideNoise(text)) { appendText(text); }
869
+ };
870
+ console.warn = console.log;
871
+ console.error = (...args: unknown[]): void => {
872
+ const text = '[error] ' + args.map((v) => (typeof v === 'string' ? v : JSON.stringify(v, null, 2))).join(' ');
873
+ if (!isPyodideNoise(text)) { appendText(text); }
874
+ };
875
+ try {
876
+ const AsyncFunction = Object.getPrototypeOf(async function () { /* */ }).constructor as new (...a: string[]) => (...a: unknown[]) => Promise<void>;
877
+ const fn = new AsyncFunction('display', 'query', 'table', 'load', code);
878
+ await fn(display, query, table, load);
879
+ clearStatus();
880
+ return {};
881
+ } catch (error) {
882
+ clearStatus();
883
+ return { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) };
884
+ } finally {
885
+ console.log = savedLog;
886
+ console.warn = savedWarn;
887
+ console.error = savedError;
888
+ }
889
+ }
890
+
891
+ async function applyJsBlocks(): Promise<void> {
892
+ const cache = getScriptSparqlCache();
893
+ const codeNodes = Array.from(document.querySelectorAll<HTMLElement>('pre > code.language-javascript, pre > code.language-js'));
894
+ const sourceBlocks = codeNodes
895
+ .map((code) => code.parentElement)
896
+ .filter((pre): pre is HTMLElement => pre instanceof HTMLElement);
897
+ for (const pre of sourceBlocks) {
898
+ pre.style.visibility = 'hidden';
899
+ }
900
+ try {
901
+ for (const code of codeNodes) {
902
+ const pre = code.parentElement as HTMLElement;
903
+ const blockId = pre.dataset.omlBlockId ?? '';
904
+ const blockCache = cache[blockId] ?? {};
905
+ const source = code.textContent ?? '';
906
+ const cacheKey = buildJsPanelCacheKey(blockId, source, blockCache);
907
+ const cachedHtml = jsPanelHtmlCache[cacheKey];
908
+ if (typeof cachedHtml === 'string') {
909
+ const panel = makeScriptPanel();
910
+ panel.innerHTML = cachedHtml;
911
+ pre.replaceWith(panel);
912
+ continue;
913
+ }
914
+ const panel = makeScriptPanel();
915
+ pre.replaceWith(panel);
916
+ const result = await executeJsCode(source, blockCache, panel);
917
+ appendResult(panel, result);
918
+ if (!result.error) {
919
+ rememberJsPanelCache(cacheKey, panel.innerHTML);
920
+ }
921
+ }
922
+ } finally {
923
+ for (const pre of sourceBlocks) {
924
+ if (pre.isConnected) {
925
+ pre.style.visibility = '';
926
+ }
927
+ }
928
+ }
929
+ }
930
+
931
+ // ── Runtime WASM cache ────────────────────────────────────────────────────────
932
+ //
933
+ // Pyodide runs entirely on the main thread, so patching globalThis.fetch lets us
934
+ // intercept and cache every resource it fetches (WASM, Python stdlib, packages).
935
+ //
936
+ // WebR spawns a Web Worker for R's WASM execution; those fetches happen inside
937
+ // the worker and are NOT visible to a main-thread fetch patch. Caching WebR's
938
+ // heavy assets would require a Service Worker. The interceptor below still covers
939
+ // any resources WebR fetches from the main thread (e.g. the .mjs entry module
940
+ // when future WebR versions do so), and the infrastructure is ready if a SW is
941
+ // added later.
942
+ //
943
+ // Version bump: update PYODIDE_VERSION / WEBR_VERSION constants. The old versioned
944
+ // cache is automatically deleted by pruneOldRuntimeCaches() on the next page load.
945
+
946
+ const PYODIDE_VERSION = 'v0.27.0';
947
+ const WEBR_VERSION = 'v0.4.2';
948
+ const PYODIDE_CDN_BASE = `https://cdn.jsdelivr.net/pyodide/${PYODIDE_VERSION}/full`;
949
+ const WEBR_CDN_BASE = `https://webr.r-wasm.org/${WEBR_VERSION}`;
950
+ const PYODIDE_CACHE_NAME = `oml-pyodide-${PYODIDE_VERSION}`;
951
+ const WEBR_CACHE_NAME = `oml-webr-${WEBR_VERSION}`;
952
+ const RUNTIME_CACHE_PREFIXES = ['oml-pyodide-', 'oml-webr-'];
953
+
954
+ // Registry mapping CDN URL prefix → Cache API cache name. The patched fetch
955
+ // below consults this map so multiple runtimes can be intercepted concurrently
956
+ // without re-patching / un-patching (which would race when both load in parallel).
957
+ const runtimeCacheInterceptors = new Map<string, string>();
958
+ const _originalFetch: typeof fetch = globalThis.fetch.bind(globalThis);
959
+
960
+ if ('caches' in globalThis) {
961
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
962
+ const url = typeof input === 'string' ? input
963
+ : input instanceof URL ? input.href
964
+ : (input as Request).url;
965
+ for (const [prefix, cacheName] of runtimeCacheInterceptors) {
966
+ if (url.startsWith(prefix)) {
967
+ try {
968
+ const cache = await caches.open(cacheName);
969
+ const hit = await cache.match(url);
970
+ if (hit) { return hit; }
971
+ const response = await _originalFetch(input as RequestInfo, init);
972
+ if (response.ok) { await cache.put(url, response.clone()); }
973
+ return response;
974
+ } catch {
975
+ break;
976
+ }
977
+ }
978
+ }
979
+ return _originalFetch(input as RequestInfo, init);
980
+ };
981
+ }
982
+
983
+ async function pruneOldRuntimeCaches(): Promise<void> {
984
+ if (!('caches' in globalThis)) { return; }
985
+ const current = new Set([PYODIDE_CACHE_NAME, WEBR_CACHE_NAME]);
986
+ try {
987
+ const all = await caches.keys();
988
+ await Promise.all(
989
+ all
990
+ .filter((n) => RUNTIME_CACHE_PREFIXES.some((p) => n.startsWith(p)) && !current.has(n))
991
+ .map((n) => caches.delete(n))
992
+ );
993
+ } catch { /* best-effort */ }
994
+ }
995
+
996
+ // ── Python (Pyodide) blocks ───────────────────────────────────────────────────
997
+
998
+ interface PyodideInterface {
999
+ runPythonAsync(code: string): Promise<unknown>;
1000
+ globals: { set(name: string, value: unknown): void };
1001
+ setStdout(opts: { batched: (msg: string) => void }): void;
1002
+ setStderr(opts: { batched: (msg: string) => void }): void;
1003
+ loadPackage(pkg: string | string[]): Promise<void>;
1004
+ }
1005
+
1006
+ let pyodideInstance: PyodideInterface | null = null;
1007
+ let pyodideLoading: Promise<PyodideInterface> | null = null;
1008
+
1009
+ async function getPyodide(): Promise<PyodideInterface> {
1010
+ if (pyodideInstance) { return pyodideInstance; }
1011
+ if (pyodideLoading) { return pyodideLoading; }
1012
+ pyodideLoading = (async () => {
1013
+ // Register the CDN interceptor before any fetches so that loadPyodide()'s
1014
+ // internal WASM + stdlib requests are served from / stored in the Cache API.
1015
+ runtimeCacheInterceptors.set(PYODIDE_CDN_BASE, PYODIDE_CACHE_NAME);
1016
+ // Use ES module dynamic import via new Function so that Pyodide's internal
1017
+ // import('./pyodide.asm.js') resolves relative to the CDN module URL, not
1018
+ // the document base URL (which <base href> would redirect to localhost).
1019
+ // This matches the pattern used by WebR.
1020
+ const dynamicImport = new Function('url', 'return import(url)') as
1021
+ (url: string) => Promise<{ loadPyodide: (opts: { indexURL: string }) => Promise<PyodideInterface> }>;
1022
+ const { loadPyodide } = await dynamicImport(`${PYODIDE_CDN_BASE}/pyodide.mjs`);
1023
+ const py = await loadPyodide({ indexURL: `${PYODIDE_CDN_BASE}/` });
1024
+ await py.loadPackage('micropip');
1025
+ pyodideInstance = py;
1026
+ return py;
1027
+ })();
1028
+ return pyodideLoading;
1029
+ }
1030
+
1031
+ const PY_BOOTSTRAP = `
1032
+ def display(html):
1033
+ _js_display(str(html))
1034
+
1035
+ async def query(sparql):
1036
+ result = _js_query(sparql)
1037
+ return result.to_py() if hasattr(result, 'to_py') else result
1038
+
1039
+ async def load(package):
1040
+ import micropip as _micropip
1041
+ await _micropip.install(package)
1042
+
1043
+ def table(data):
1044
+ rows, columns = [], []
1045
+ if isinstance(data, dict) and 'rows' in data:
1046
+ rows = data['rows']
1047
+ columns = data.get('columns', list(rows[0].keys()) if rows else [])
1048
+ elif isinstance(data, list) and data and isinstance(data[0], dict):
1049
+ rows, columns = data, list(data[0].keys())
1050
+ else:
1051
+ try:
1052
+ import pandas as _pd
1053
+ if isinstance(data, _pd.DataFrame):
1054
+ columns, rows = list(data.columns), data.to_dict('records')
1055
+ except ImportError:
1056
+ pass
1057
+ if not rows or not columns:
1058
+ display(str(data))
1059
+ return
1060
+ def _e(s):
1061
+ return str(s).replace('&','&amp;').replace('<','&lt;').replace('>','&gt;')
1062
+ hdr = ''.join(f'<th>{_e(c)}</th>' for c in columns)
1063
+ bdy = ''.join(
1064
+ f'<tr>{"".join(f"<td>{_e(r.get(c,"") if isinstance(r,dict) else "")}</td>" for c in columns)}</tr>'
1065
+ for r in rows
1066
+ )
1067
+ display(f'<div class="oml-md-table-wrapper"><table class="oml-md-table"><thead><tr>{hdr}</tr></thead><tbody>{bdy}</tbody></table></div>')
1068
+ `;
1069
+
1070
+ let pyodideExecQueue: Promise<unknown> = Promise.resolve();
1071
+
1072
+ async function executePyCode(
1073
+ code: string,
1074
+ blockCache: Record<string, QueryResult>,
1075
+ container: HTMLElement
1076
+ ): Promise<{ error?: string }> {
1077
+ let resolve!: (r: { error?: string }) => void;
1078
+ const queued = new Promise<{ error?: string }>((res) => { resolve = res; });
1079
+ pyodideExecQueue = pyodideExecQueue.then(() => _executePyCode(code, blockCache, container).then(resolve, (e) => resolve({ error: String(e) })));
1080
+ return queued;
1081
+ }
1082
+
1083
+ async function _executePyCode(
1084
+ code: string,
1085
+ blockCache: Record<string, QueryResult>,
1086
+ container: HTMLElement
1087
+ ): Promise<{ error?: string }> {
1088
+ if (!container.isConnected) { return {}; }
1089
+ let statusEl: HTMLElement | undefined = document.createElement('div');
1090
+ statusEl.className = 'oml-md-js-text-output';
1091
+ statusEl.textContent = 'Running Python block…';
1092
+ container.appendChild(statusEl);
1093
+ const clearStatus = (): void => {
1094
+ if (statusEl?.parentElement === container) {
1095
+ container.removeChild(statusEl);
1096
+ }
1097
+ statusEl = undefined;
1098
+ };
1099
+ const appendHtml = (html: string): void => {
1100
+ clearStatus();
1101
+ const el = document.createElement('div');
1102
+ el.className = 'oml-md-js-html-output';
1103
+ el.style.color = window.getComputedStyle(document.body).color;
1104
+ el.innerHTML = html;
1105
+ container.appendChild(el);
1106
+ };
1107
+ const appendText = (text: string): void => {
1108
+ clearStatus();
1109
+ const pre = document.createElement('pre');
1110
+ pre.className = 'oml-md-js-text-output';
1111
+ pre.textContent = text;
1112
+ container.appendChild(pre);
1113
+ };
1114
+ let pyodide: PyodideInterface;
1115
+ try {
1116
+ pyodide = await getPyodide();
1117
+ // Keep a single stable status message until output appears.
1118
+ } catch (e) {
1119
+ clearStatus();
1120
+ return { error: `Failed to load Pyodide: ${e instanceof Error ? e.message : String(e)}` };
1121
+ }
1122
+ try {
1123
+ await pyodide.runPythonAsync(PY_BOOTSTRAP);
1124
+ } catch (e) {
1125
+ clearStatus();
1126
+ return { error: `Python bootstrap failed: ${e instanceof Error ? e.message : String(e)}` };
1127
+ }
1128
+ pyodide.globals.set('_js_display', appendHtml);
1129
+ pyodide.globals.set('_js_query', (sparql: string) =>
1130
+ blockCache[sparql] ?? { success: false, columns: [], rows: [], error: 'SPARQL not pre-fetched' });
1131
+ pyodide.setStdout({ batched: appendText });
1132
+ pyodide.setStderr({ batched: () => { /* suppress library noise */ } });
1133
+ try {
1134
+ await pyodide.runPythonAsync(code);
1135
+ clearStatus();
1136
+ return {};
1137
+ } catch (error) {
1138
+ clearStatus();
1139
+ return { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) };
1140
+ }
1141
+ }
1142
+
1143
+ async function applyPyBlocks(): Promise<void> {
1144
+ const cache = getScriptSparqlCache();
1145
+ for (const code of Array.from(document.querySelectorAll<HTMLElement>('pre > code.language-python'))) {
1146
+ const pre = code.parentElement as HTMLElement;
1147
+ const blockId = pre.dataset.omlBlockId ?? '';
1148
+ const blockCache = cache[blockId] ?? {};
1149
+ const source = code.textContent ?? '';
1150
+ const cacheKey = buildPythonPanelCacheKey(blockId, source, blockCache);
1151
+ const cachedHtml = pythonPanelHtmlCache[cacheKey];
1152
+ if (typeof cachedHtml === 'string') {
1153
+ const panel = makeScriptPanel();
1154
+ panel.innerHTML = cachedHtml;
1155
+ pre.replaceWith(panel);
1156
+ continue;
1157
+ }
1158
+ const panel = makeScriptPanel();
1159
+ pre.replaceWith(panel);
1160
+ const result = await executePyCode(source, blockCache, panel);
1161
+ appendResult(panel, result);
1162
+ if (!result.error) {
1163
+ rememberPythonPanelCache(cacheKey, panel.innerHTML);
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ // ── R (WebR) blocks ───────────────────────────────────────────────────────────
1169
+
1170
+ interface WebRInterface {
1171
+ init(): Promise<void>;
1172
+ evalRVoid(code: string): Promise<void>;
1173
+ evalRString(code: string): Promise<string>;
1174
+ }
1175
+
1176
+ let webRInstance: WebRInterface | null = null;
1177
+ let webRLoading: Promise<WebRInterface> | null = null;
1178
+
1179
+ async function getWebR(): Promise<WebRInterface> {
1180
+ if (webRInstance) { return webRInstance; }
1181
+ if (webRLoading) { return webRLoading; }
1182
+ webRLoading = (async () => {
1183
+ // Register the interceptor for any resources WebR fetches from the main
1184
+ // thread. Note: R's WASM is loaded inside a Web Worker, so those fetches
1185
+ // are not visible here and are not cached. Full offline support for WebR
1186
+ // would require a Service Worker to intercept worker-origin requests.
1187
+ runtimeCacheInterceptors.set(WEBR_CDN_BASE, WEBR_CACHE_NAME);
1188
+ const dynamicImport = new Function('url', 'return import(url)') as
1189
+ (url: string) => Promise<{ WebR: new () => WebRInterface }>;
1190
+ const { WebR: WebRClass } = await dynamicImport(`${WEBR_CDN_BASE}/webr.mjs`);
1191
+ const webR = new WebRClass();
1192
+ await webR.init();
1193
+ webRInstance = webR;
1194
+ return webR;
1195
+ })();
1196
+ return webRLoading;
1197
+ }
1198
+
1199
+ const R_DISPLAY_SENTINEL = '<<OML_DISPLAY>>';
1200
+ const R_DISPLAY_END = '<</OML_DISPLAY>>';
1201
+
1202
+ const R_BOOTSTRAP = `
1203
+ display <- function(html) {
1204
+ .oml_buf_ <<- c(.oml_buf_, paste0('<<OML_DISPLAY>>', html, '<</OML_DISPLAY>>'))
1205
+ }
1206
+ table <- function(data) {
1207
+ if (is.data.frame(data) || is.matrix(data)) {
1208
+ df <- as.data.frame(data)
1209
+ cols <- colnames(df)
1210
+ hdr <- paste0('<th>', cols, '</th>', collapse='')
1211
+ rows <- apply(df, 1, function(r) {
1212
+ cells <- paste0('<td>', r, '</td>', collapse='')
1213
+ paste0('<tr>', cells, '</tr>')
1214
+ })
1215
+ bdy <- paste(rows, collapse='')
1216
+ display(paste0('<div class="oml-md-table-wrapper"><table class="oml-md-table"><thead><tr>',
1217
+ hdr, '</tr></thead><tbody>', bdy, '</tbody></table></div>'))
1218
+ } else { print(data) }
1219
+ }
1220
+ load <- function(pkg) {
1221
+ if (!requireNamespace(pkg, quietly=TRUE)) { webr::install(pkg) }
1222
+ library(pkg, character.only=TRUE)
1223
+ }
1224
+ `;
1225
+
1226
+ function buildRDataFrameCode(result: QueryResult, varName: string): string {
1227
+ const rStr = (s: string): string =>
1228
+ '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '') + '"';
1229
+ if (!result.success) {
1230
+ return `${varName} <- (function() stop(${rStr(result.error ?? 'SPARQL query failed')}))()`;
1231
+ }
1232
+ if (result.rows.length === 0) {
1233
+ const colDefs = result.columns.map((c) => ` ${rStr(c)} = character(0)`).join(',\n');
1234
+ return `${varName} <- data.frame(${colDefs ? '\n' + colDefs + ',\n' : ''} stringsAsFactors = FALSE, check.names = FALSE)`;
1235
+ }
1236
+ const colDefs = result.columns.map((col) => {
1237
+ const vals = result.rows.map((row) => rStr(String(row[col] ?? ''))).join(', ');
1238
+ return ` ${rStr(col)} = c(${vals})`;
1239
+ }).join(',\n');
1240
+ return `${varName} <- data.frame(\n${colDefs},\n stringsAsFactors = FALSE, check.names = FALSE\n)`;
1241
+ }
1242
+
1243
+ function extractRQueryStrings(code: string): string[] {
1244
+ const re = /\bquery\s*\(\s*(?:"((?:[^"\\]|\\[\s\S])*)"|'((?:[^'\\]|\\[\s\S])*)')\s*\)/g;
1245
+ const seen = new Set<string>();
1246
+ let m: RegExpExecArray | null;
1247
+ while ((m = re.exec(code)) !== null) {
1248
+ const raw = m[1] ?? m[2];
1249
+ const unesc = raw.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\'/g, "'").replace(/\\\\/g, '\\');
1250
+ seen.add(unesc);
1251
+ }
1252
+ return [...seen];
1253
+ }
1254
+
1255
+ function rewriteRQueryCalls(code: string, queryMap: Map<string, string>): string {
1256
+ const re = /\bquery\s*\(\s*(?:"((?:[^"\\]|\\[\s\S])*)"|'((?:[^'\\]|\\[\s\S])*)')\s*\)/g;
1257
+ return code.replace(re, (_, dq: string, sq: string) => {
1258
+ const raw = dq ?? sq;
1259
+ const sparql = raw.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\'/g, "'").replace(/\\\\/g, '\\');
1260
+ return queryMap.get(sparql) ?? 'data.frame()';
1261
+ });
1262
+ }
1263
+
1264
+ function processROutput(
1265
+ raw: string,
1266
+ appendHtml: (html: string) => void,
1267
+ appendText: (text: string) => void
1268
+ ): void {
1269
+ let remaining = raw;
1270
+ while (remaining.length > 0) {
1271
+ const start = remaining.indexOf(R_DISPLAY_SENTINEL);
1272
+ if (start === -1) { if (remaining.trim()) { appendText(remaining); } break; }
1273
+ if (start > 0) { appendText(remaining.slice(0, start)); }
1274
+ const end = remaining.indexOf(R_DISPLAY_END, start + R_DISPLAY_SENTINEL.length);
1275
+ if (end === -1) { appendText(remaining.slice(start)); break; }
1276
+ appendHtml(remaining.slice(start + R_DISPLAY_SENTINEL.length, end));
1277
+ remaining = remaining.slice(end + R_DISPLAY_END.length);
1278
+ }
1279
+ }
1280
+
1281
+ async function executeRCode(
1282
+ code: string,
1283
+ blockCache: Record<string, QueryResult>,
1284
+ container: HTMLElement
1285
+ ): Promise<{ error?: string }> {
1286
+ let statusEl: HTMLElement | undefined = document.createElement('div');
1287
+ statusEl.className = 'oml-md-js-text-output';
1288
+ statusEl.textContent = 'Running R block…';
1289
+ container.appendChild(statusEl);
1290
+ const clearStatus = (): void => {
1291
+ if (statusEl?.parentElement === container) {
1292
+ container.removeChild(statusEl);
1293
+ }
1294
+ statusEl = undefined;
1295
+ };
1296
+ const appendHtml = (html: string): void => {
1297
+ clearStatus();
1298
+ const el = document.createElement('div');
1299
+ el.className = 'oml-md-js-html-output';
1300
+ el.style.color = window.getComputedStyle(document.body).color;
1301
+ el.innerHTML = html;
1302
+ container.appendChild(el);
1303
+ };
1304
+ const appendText = (text: string): void => {
1305
+ clearStatus();
1306
+ const pre = document.createElement('pre');
1307
+ pre.className = 'oml-md-js-text-output';
1308
+ pre.textContent = text;
1309
+ container.appendChild(pre);
1310
+ };
1311
+ const queryStrings = extractRQueryStrings(code);
1312
+ const prefetchResults = queryStrings.map((sparql, i) => {
1313
+ const varName = `.oml_q${i}_`;
1314
+ const result = blockCache[sparql] ?? { success: false, columns: [], rows: [], error: 'SPARQL not pre-fetched' };
1315
+ return { sparql, varName, preamble: buildRDataFrameCode(result, varName) };
1316
+ });
1317
+ let webR: WebRInterface;
1318
+ try {
1319
+ webR = await getWebR();
1320
+ } catch (e) {
1321
+ clearStatus();
1322
+ return { error: `Failed to load WebR: ${e instanceof Error ? e.message : String(e)}` };
1323
+ }
1324
+ try {
1325
+ await webR.evalRVoid(R_BOOTSTRAP);
1326
+ } catch (e) {
1327
+ clearStatus();
1328
+ return { error: `R bootstrap failed: ${e instanceof Error ? e.message : String(e)}` };
1329
+ }
1330
+ let finalCode = code;
1331
+ if (prefetchResults.length > 0) {
1332
+ const queryMap = new Map(prefetchResults.map((r) => [r.sparql, r.varName] as [string, string]));
1333
+ finalCode = prefetchResults.map((r) => r.preamble).join('\n') + '\n' + rewriteRQueryCalls(code, queryMap);
1334
+ }
1335
+ try {
1336
+ await webR.evalRVoid(
1337
+ '.oml_buf_ <- character(0)\n' +
1338
+ 'tryCatch({\n' +
1339
+ finalCode + '\n' +
1340
+ '}, error = function(e) { .oml_buf_ <<- c(.oml_buf_, paste0("Error: ", conditionMessage(e))) })\n'
1341
+ );
1342
+ const rawOutput = await webR.evalRString('paste(.oml_buf_, collapse="")');
1343
+ processROutput(rawOutput, appendHtml, appendText);
1344
+ clearStatus();
1345
+ return {};
1346
+ } catch (error) {
1347
+ clearStatus();
1348
+ return { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) };
1349
+ }
1350
+ }
1351
+
1352
+ async function applyRBlocks(): Promise<void> {
1353
+ const cache = getScriptSparqlCache();
1354
+ for (const code of Array.from(document.querySelectorAll<HTMLElement>('pre > code.language-r'))) {
1355
+ const pre = code.parentElement as HTMLElement;
1356
+ const blockId = pre.dataset.omlBlockId ?? '';
1357
+ const blockCache = cache[blockId] ?? {};
1358
+ const source = code.textContent ?? '';
1359
+ const cacheKey = buildRPanelCacheKey(blockId, source, blockCache);
1360
+ const cachedHtml = rPanelHtmlCache[cacheKey];
1361
+ if (typeof cachedHtml === 'string') {
1362
+ const panel = makeScriptPanel();
1363
+ panel.innerHTML = cachedHtml;
1364
+ pre.replaceWith(panel);
1365
+ continue;
1366
+ }
1367
+ const panel = makeScriptPanel();
1368
+ pre.replaceWith(panel);
1369
+ const result = await executeRCode(source, blockCache, panel);
1370
+ appendResult(panel, result);
1371
+ if (!result.error) {
1372
+ rememberRPanelCache(cacheKey, panel.innerHTML);
1373
+ }
1374
+ }
1375
+ }
1376
+
1377
+ void bootstrapStaticRender();