pi-studio 0.9.10 → 0.9.11

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.11] — 2026-05-19
8
+
9
+ ### Added
10
+ - Render TeX math inside sandboxed interactive HTML previews through Studio's existing Pandoc/MathML pipeline without enabling network scripts.
11
+ - Added a **Copy source** button to rendered Mermaid diagrams.
12
+
13
+ ### Fixed
14
+ - Made same-document section links scroll correctly inside sandboxed interactive HTML previews.
15
+
7
16
  ## [0.9.10] — 2026-05-19
8
17
 
9
18
  ### Added
@@ -245,6 +245,7 @@
245
245
  const REPL_JOURNAL_MAX_ENTRIES = 80;
246
246
  const PDF_EXPORT_FETCH_TIMEOUT_MS = 180_000;
247
247
  const HTML_EXPORT_FETCH_TIMEOUT_MS = 180_000;
248
+ const HTML_ARTIFACT_MATH_RENDER_FETCH_TIMEOUT_MS = 30_000;
248
249
  const EDITOR_TAB_TEXT = " ";
249
250
  const QUIZ_DEFAULT_COUNT = 5;
250
251
  const QUIZ_SCOPES = ["editor", "selection", "file", "folder", "repo"];
@@ -3968,6 +3969,11 @@
3968
3969
  + " root ? root.offsetHeight : 0\n"
3969
3970
  + " ));\n"
3970
3971
  + " }\n"
3972
+ + " function getScrollTop() {\n"
3973
+ + " const body = document.body;\n"
3974
+ + " const root = document.documentElement;\n"
3975
+ + " return window.scrollY || (root ? root.scrollTop : 0) || (body ? body.scrollTop : 0) || 0;\n"
3976
+ + " }\n"
3971
3977
  + " function sendHeight() {\n"
3972
3978
  + " scheduled = false;\n"
3973
3979
  + " const height = measureHeight();\n"
@@ -3980,13 +3986,243 @@
3980
3986
  + " scheduled = true;\n"
3981
3987
  + " requestAnimationFrame(sendHeight);\n"
3982
3988
  + " }\n"
3989
+ + " function decodeFragment(value) {\n"
3990
+ + " const text = String(value || '').replace(/^#/, '');\n"
3991
+ + " try { return decodeURIComponent(text); } catch { return text; }\n"
3992
+ + " }\n"
3993
+ + " function findNamedFragmentTarget(fragment) {\n"
3994
+ + " const decoded = decodeFragment(fragment);\n"
3995
+ + " if (!decoded) return document.documentElement || document.body;\n"
3996
+ + " return document.getElementById(decoded) || document.getElementsByName(decoded)[0] || null;\n"
3997
+ + " }\n"
3998
+ + " function postFragmentScroll(target) {\n"
3999
+ + " if (!target || typeof target.getBoundingClientRect !== 'function') return;\n"
4000
+ + " const rect = target.getBoundingClientRect();\n"
4001
+ + " const scrollTop = getScrollTop();\n"
4002
+ + " try {\n"
4003
+ + " parent.postMessage({ type: 'pi-studio-html-artifact-fragment', id: PREVIEW_ID, targetTop: Math.max(0, rect.top + scrollTop), scrollTop, viewportHeight: window.innerHeight || 0, documentHeight: measureHeight() }, '*');\n"
4004
+ + " } catch {}\n"
4005
+ + " }\n"
4006
+ + " function scrollFragmentIntoView(fragment, options) {\n"
4007
+ + " const target = findNamedFragmentTarget(fragment);\n"
4008
+ + " if (!target) return false;\n"
4009
+ + " const behavior = options && options.smooth === false ? 'auto' : 'smooth';\n"
4010
+ + " try { target.scrollIntoView({ block: 'start', inline: 'nearest', behavior }); } catch { try { target.scrollIntoView(true); } catch {} }\n"
4011
+ + " postFragmentScroll(target);\n"
4012
+ + " setTimeout(() => postFragmentScroll(target), 80);\n"
4013
+ + " setTimeout(() => postFragmentScroll(target), 300);\n"
4014
+ + " return true;\n"
4015
+ + " }\n"
4016
+ + " function getAnchorFromClickTarget(target) {\n"
4017
+ + " let node = target;\n"
4018
+ + " if (node && node.nodeType === 3) node = node.parentElement;\n"
4019
+ + " return node && typeof node.closest === 'function' ? node.closest('a[href]') : null;\n"
4020
+ + " }\n"
4021
+ + " function getSameDocumentFragment(anchor) {\n"
4022
+ + " if (!anchor || typeof anchor.getAttribute !== 'function') return null;\n"
4023
+ + " if (anchor.hasAttribute('download')) return null;\n"
4024
+ + " const target = String(anchor.getAttribute('target') || '').trim().toLowerCase();\n"
4025
+ + " if (target && target !== '_self') return null;\n"
4026
+ + " const rawHref = String(anchor.getAttribute('href') || '').trim();\n"
4027
+ + " if (!rawHref) return null;\n"
4028
+ + " if (rawHref.charAt(0) === '#') return rawHref.slice(1);\n"
4029
+ + " const hashIndex = rawHref.indexOf('#');\n"
4030
+ + " if (hashIndex < 0) return null;\n"
4031
+ + " const beforeHash = rawHref.slice(0, hashIndex);\n"
4032
+ + " const currentWithoutHash = String(window.location && window.location.href || '').split('#')[0];\n"
4033
+ + " if (!beforeHash || beforeHash === currentWithoutHash || beforeHash === 'about:srcdoc') return rawHref.slice(hashIndex + 1);\n"
4034
+ + " return null;\n"
4035
+ + " }\n"
4036
+ + " function writeFragmentHistory(fragment) {\n"
4037
+ + " try {\n"
4038
+ + " if (history && typeof history.pushState === 'function') {\n"
4039
+ + " history.pushState(null, '', fragment ? '#' + encodeURIComponent(decodeFragment(fragment)) : '#');\n"
4040
+ + " }\n"
4041
+ + " } catch {}\n"
4042
+ + " }\n"
4043
+ + " function handleFragmentAnchorClick(event) {\n"
4044
+ + " if (!event || event.defaultPrevented) return;\n"
4045
+ + " if (typeof event.button === 'number' && event.button !== 0) return;\n"
4046
+ + " if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;\n"
4047
+ + " const anchor = getAnchorFromClickTarget(event.target);\n"
4048
+ + " const fragment = getSameDocumentFragment(anchor);\n"
4049
+ + " if (fragment == null) return;\n"
4050
+ + " if (!scrollFragmentIntoView(fragment)) return;\n"
4051
+ + " event.preventDefault();\n"
4052
+ + " writeFragmentHistory(fragment);\n"
4053
+ + " }\n"
4054
+ + " const htmlMathPlaceholders = new Map();\n"
4055
+ + " let htmlMathSerial = 0;\n"
4056
+ + " let htmlMathScanScheduled = false;\n"
4057
+ + " function delimiterListIncludes(list, start, end) {\n"
4058
+ + " if (!Array.isArray(list)) return false;\n"
4059
+ + " return list.some((entry) => Array.isArray(entry) && entry[0] === start && entry[1] === end);\n"
4060
+ + " }\n"
4061
+ + " function getHtmlMathDelimiterConfig() {\n"
4062
+ + " const mathJax = window.MathJax && typeof window.MathJax === 'object' ? window.MathJax : null;\n"
4063
+ + " const tex = mathJax && mathJax.tex && typeof mathJax.tex === 'object' ? mathJax.tex : null;\n"
4064
+ + " return {\n"
4065
+ + " inlineDollar: Boolean(tex && delimiterListIncludes(tex.inlineMath, '$', '$')),\n"
4066
+ + " displayDollar: Boolean(tex && delimiterListIncludes(tex.displayMath, '$$', '$$')),\n"
4067
+ + " };\n"
4068
+ + " }\n"
4069
+ + " function isEscapedAt(text, index) {\n"
4070
+ + " let count = 0;\n"
4071
+ + " let pos = index - 1;\n"
4072
+ + " while (pos >= 0 && text.charAt(pos) === '\\\\') { count += 1; pos -= 1; }\n"
4073
+ + " return count % 2 === 1;\n"
4074
+ + " }\n"
4075
+ + " function findUnescapedDelimiter(text, delimiter, fromIndex) {\n"
4076
+ + " let index = Math.max(0, Number(fromIndex) || 0);\n"
4077
+ + " while (index < text.length) {\n"
4078
+ + " index = text.indexOf(delimiter, index);\n"
4079
+ + " if (index < 0) return -1;\n"
4080
+ + " if (!isEscapedAt(text, index)) return index;\n"
4081
+ + " index += Math.max(1, delimiter.length);\n"
4082
+ + " }\n"
4083
+ + " return -1;\n"
4084
+ + " }\n"
4085
+ + " function textMightContainMath(text, config) {\n"
4086
+ + " if (!text) return false;\n"
4087
+ + " if (text.indexOf('\\\\(') !== -1 || text.indexOf('\\\\[') !== -1) return true;\n"
4088
+ + " return Boolean((config.inlineDollar || config.displayDollar) && text.indexOf('$') !== -1);\n"
4089
+ + " }\n"
4090
+ + " function findNextMathSegment(text, startIndex, config) {\n"
4091
+ + " for (let index = startIndex; index < text.length; index += 1) {\n"
4092
+ + " if (text.startsWith('\\\\(', index)) {\n"
4093
+ + " const end = findUnescapedDelimiter(text, '\\\\)', index + 2);\n"
4094
+ + " if (end > index + 2) return { start: index, end: end + 2, tex: text.slice(index + 2, end).trim(), display: false };\n"
4095
+ + " }\n"
4096
+ + " if (text.startsWith('\\\\[', index)) {\n"
4097
+ + " const end = findUnescapedDelimiter(text, '\\\\]', index + 2);\n"
4098
+ + " if (end > index + 2) return { start: index, end: end + 2, tex: text.slice(index + 2, end).trim(), display: true };\n"
4099
+ + " }\n"
4100
+ + " if (config.displayDollar && text.startsWith('$$', index) && !isEscapedAt(text, index)) {\n"
4101
+ + " const end = findUnescapedDelimiter(text, '$$', index + 2);\n"
4102
+ + " if (end > index + 2) return { start: index, end: end + 2, tex: text.slice(index + 2, end).trim(), display: true };\n"
4103
+ + " }\n"
4104
+ + " if (config.inlineDollar && text.charAt(index) === '$' && text.charAt(index + 1) !== '$' && !isEscapedAt(text, index)) {\n"
4105
+ + " const end = findUnescapedDelimiter(text, '$', index + 1);\n"
4106
+ + " if (end > index + 1) return { start: index, end: end + 1, tex: text.slice(index + 1, end).trim(), display: false };\n"
4107
+ + " }\n"
4108
+ + " }\n"
4109
+ + " return null;\n"
4110
+ + " }\n"
4111
+ + " function parseHtmlMathSegments(text, config, maxCount) {\n"
4112
+ + " const segments = [];\n"
4113
+ + " let index = 0;\n"
4114
+ + " const maxSegments = Math.max(1, Number(maxCount) || 1);\n"
4115
+ + " while (index < text.length && segments.length < maxSegments) {\n"
4116
+ + " const segment = findNextMathSegment(text, index, config);\n"
4117
+ + " if (!segment) break;\n"
4118
+ + " if (segment.tex) segments.push(segment);\n"
4119
+ + " index = Math.max(segment.end, segment.start + 1);\n"
4120
+ + " }\n"
4121
+ + " return segments;\n"
4122
+ + " }\n"
4123
+ + " function shouldSkipHtmlMathTextNode(node) {\n"
4124
+ + " let el = node && node.parentElement;\n"
4125
+ + " while (el) {\n"
4126
+ + " const tag = el.tagName ? el.tagName.toLowerCase() : '';\n"
4127
+ + " if (['script', 'style', 'textarea', 'pre', 'code', 'math', 'svg', 'mjx-container'].indexOf(tag) !== -1) return true;\n"
4128
+ + " if (el.classList && (el.classList.contains('pi-studio-html-math') || el.classList.contains('MathJax'))) return true;\n"
4129
+ + " el = el.parentElement;\n"
4130
+ + " }\n"
4131
+ + " return false;\n"
4132
+ + " }\n"
4133
+ + " function replaceTextNodeWithHtmlMathPlaceholders(node, segments) {\n"
4134
+ + " if (!node || !node.parentNode || !segments || segments.length === 0) return [];\n"
4135
+ + " const text = String(node.nodeValue || '');\n"
4136
+ + " const fragment = document.createDocumentFragment();\n"
4137
+ + " const items = [];\n"
4138
+ + " let index = 0;\n"
4139
+ + " segments.forEach((segment) => {\n"
4140
+ + " if (segment.start > index) fragment.appendChild(document.createTextNode(text.slice(index, segment.start)));\n"
4141
+ + " const mathId = PREVIEW_ID + '_math_' + (++htmlMathSerial).toString(36);\n"
4142
+ + " const span = document.createElement('span');\n"
4143
+ + " span.className = 'pi-studio-html-math pi-studio-html-math-' + (segment.display ? 'display' : 'inline');\n"
4144
+ + " span.setAttribute('data-pi-studio-html-math-id', mathId);\n"
4145
+ + " span.setAttribute('aria-busy', 'true');\n"
4146
+ + " span.textContent = text.slice(segment.start, segment.end);\n"
4147
+ + " htmlMathPlaceholders.set(mathId, span);\n"
4148
+ + " items.push({ mathId, tex: segment.tex, display: Boolean(segment.display) });\n"
4149
+ + " fragment.appendChild(span);\n"
4150
+ + " index = segment.end;\n"
4151
+ + " });\n"
4152
+ + " if (index < text.length) fragment.appendChild(document.createTextNode(text.slice(index)));\n"
4153
+ + " node.parentNode.replaceChild(fragment, node);\n"
4154
+ + " return items;\n"
4155
+ + " }\n"
4156
+ + " function applyRenderedHtmlMath(results) {\n"
4157
+ + " if (!Array.isArray(results)) return;\n"
4158
+ + " results.forEach((result) => {\n"
4159
+ + " if (!result || typeof result !== 'object') return;\n"
4160
+ + " const mathId = typeof result.mathId === 'string' ? result.mathId : '';\n"
4161
+ + " const placeholder = mathId ? htmlMathPlaceholders.get(mathId) : null;\n"
4162
+ + " if (!placeholder || !placeholder.isConnected) return;\n"
4163
+ + " placeholder.removeAttribute('aria-busy');\n"
4164
+ + " if (result.ok === true && typeof result.html === 'string' && result.html.trim()) {\n"
4165
+ + " placeholder.innerHTML = result.html;\n"
4166
+ + " placeholder.classList.add('pi-studio-html-math-rendered');\n"
4167
+ + " } else {\n"
4168
+ + " placeholder.classList.add('pi-studio-html-math-failed');\n"
4169
+ + " if (typeof result.error === 'string' && result.error) placeholder.title = result.error;\n"
4170
+ + " }\n"
4171
+ + " htmlMathPlaceholders.delete(mathId);\n"
4172
+ + " });\n"
4173
+ + " scheduleHeight();\n"
4174
+ + " }\n"
4175
+ + " function runHtmlMathRenderScan() {\n"
4176
+ + " htmlMathScanScheduled = false;\n"
4177
+ + " if (!document.body || typeof document.createTreeWalker !== 'function') return;\n"
4178
+ + " const nodeFilterApi = typeof NodeFilter !== 'undefined' ? NodeFilter : { SHOW_TEXT: 4, FILTER_ACCEPT: 1, FILTER_REJECT: 2 };\n"
4179
+ + " const config = getHtmlMathDelimiterConfig();\n"
4180
+ + " const nodes = [];\n"
4181
+ + " const walker = document.createTreeWalker(document.body, nodeFilterApi.SHOW_TEXT, {\n"
4182
+ + " acceptNode(node) {\n"
4183
+ + " const text = String(node && node.nodeValue || '');\n"
4184
+ + " if (!textMightContainMath(text, config)) return nodeFilterApi.FILTER_REJECT;\n"
4185
+ + " if (shouldSkipHtmlMathTextNode(node)) return nodeFilterApi.FILTER_REJECT;\n"
4186
+ + " return nodeFilterApi.FILTER_ACCEPT;\n"
4187
+ + " }\n"
4188
+ + " });\n"
4189
+ + " while (walker.nextNode()) nodes.push(walker.currentNode);\n"
4190
+ + " const items = [];\n"
4191
+ + " for (const node of nodes) {\n"
4192
+ + " const remaining = 250 - items.length;\n"
4193
+ + " if (remaining <= 0) break;\n"
4194
+ + " const text = String(node && node.nodeValue || '');\n"
4195
+ + " const segments = parseHtmlMathSegments(text, config, remaining);\n"
4196
+ + " if (segments.length === 0) continue;\n"
4197
+ + " items.push(...replaceTextNodeWithHtmlMathPlaceholders(node, segments));\n"
4198
+ + " }\n"
4199
+ + " if (items.length > 0) {\n"
4200
+ + " try { parent.postMessage({ type: 'pi-studio-html-artifact-render-math', id: PREVIEW_ID, items }, '*'); } catch {}\n"
4201
+ + " }\n"
4202
+ + " }\n"
4203
+ + " function scheduleHtmlMathRenderScan() {\n"
4204
+ + " if (htmlMathScanScheduled) return;\n"
4205
+ + " htmlMathScanScheduled = true;\n"
4206
+ + " requestAnimationFrame(runHtmlMathRenderScan);\n"
4207
+ + " }\n"
3983
4208
  + " window.addEventListener('message', (event) => {\n"
3984
4209
  + " const data = event && event.data;\n"
3985
- + " if (!data || typeof data !== 'object') return;\n"
3986
- + " if (data.type !== 'pi-studio-html-artifact-zoom' || data.id !== PREVIEW_ID) return;\n"
3987
- + " applyZoom(data.zoom);\n"
4210
+ + " if (!data || typeof data !== 'object' || data.id !== PREVIEW_ID) return;\n"
4211
+ + " if (data.type === 'pi-studio-html-artifact-zoom') {\n"
4212
+ + " applyZoom(data.zoom);\n"
4213
+ + " return;\n"
4214
+ + " }\n"
4215
+ + " if (data.type === 'pi-studio-html-artifact-math-rendered') {\n"
4216
+ + " applyRenderedHtmlMath(data.results);\n"
4217
+ + " }\n"
3988
4218
  + " });\n"
3989
- + " window.addEventListener('load', scheduleHeight);\n"
4219
+ + " document.addEventListener('click', handleFragmentAnchorClick);\n"
4220
+ + " document.addEventListener('DOMContentLoaded', scheduleHtmlMathRenderScan);\n"
4221
+ + " window.addEventListener('hashchange', () => {\n"
4222
+ + " const hash = String(window.location && window.location.hash || '');\n"
4223
+ + " if (hash) scrollFragmentIntoView(hash.slice(1), { smooth: false });\n"
4224
+ + " });\n"
4225
+ + " window.addEventListener('load', () => { scheduleHeight(); scheduleHtmlMathRenderScan(); });\n"
3990
4226
  + " window.addEventListener('resize', scheduleHeight);\n"
3991
4227
  + " if (typeof ResizeObserver === 'function') {\n"
3992
4228
  + " const observer = new ResizeObserver(scheduleHeight);\n"
@@ -3994,20 +4230,32 @@
3994
4230
  + " if (document.body) observer.observe(document.body);\n"
3995
4231
  + " }\n"
3996
4232
  + " if (typeof MutationObserver === 'function') {\n"
3997
- + " const observer = new MutationObserver(scheduleHeight);\n"
4233
+ + " const observer = new MutationObserver(() => { scheduleHeight(); scheduleHtmlMathRenderScan(); });\n"
3998
4234
  + " observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });\n"
3999
4235
  + " }\n"
4000
4236
  + " scheduleHeight();\n"
4001
4237
  + " setTimeout(scheduleHeight, 80);\n"
4002
4238
  + " setTimeout(scheduleHeight, 350);\n"
4239
+ + " setTimeout(scheduleHtmlMathRenderScan, 0);\n"
4240
+ + " setTimeout(scheduleHtmlMathRenderScan, 120);\n"
4241
+ + " setTimeout(scheduleHtmlMathRenderScan, 500);\n"
4003
4242
  + "})();\n"
4004
4243
  + "<\/script>";
4005
4244
  }
4006
4245
 
4246
+ function buildHtmlArtifactPreviewMathStyle() {
4247
+ return "<style data-pi-studio-html-preview-math>\n"
4248
+ + ".pi-studio-html-math-display{display:block;margin:0.75em 0;overflow-x:auto;text-align:center;}\n"
4249
+ + ".pi-studio-html-math-display>math{display:block;margin:0 auto;}\n"
4250
+ + ".pi-studio-html-math-inline>math{vertical-align:-0.15em;}\n"
4251
+ + "</style>\n";
4252
+ }
4253
+
4007
4254
  function buildHtmlArtifactPreviewHeadMarkup(previewId) {
4008
4255
  return "<meta charset=\"utf-8\">\n"
4009
4256
  + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
4010
4257
  + "<meta http-equiv=\"Content-Security-Policy\" content=\"" + escapeHtml(HTML_ARTIFACT_PREVIEW_CSP) + "\">\n"
4258
+ + buildHtmlArtifactPreviewMathStyle()
4011
4259
  + buildHtmlArtifactPreviewResizeScript(previewId);
4012
4260
  }
4013
4261
 
@@ -4067,7 +4315,161 @@
4067
4315
  }
4068
4316
  }
4069
4317
 
4318
+ function handleHtmlArtifactFrameFragmentMessage(event) {
4319
+ const data = event && event.data;
4320
+ if (!data || typeof data !== "object" || data.type !== "pi-studio-html-artifact-fragment") return;
4321
+ const id = typeof data.id === "string" ? data.id : "";
4322
+ const record = id ? htmlArtifactFramesById.get(id) : null;
4323
+ if (!record || !record.iframe || !record.iframe.isConnected) {
4324
+ if (id) htmlArtifactFramesById.delete(id);
4325
+ return;
4326
+ }
4327
+ if (event.source && record.iframe.contentWindow && event.source !== record.iframe.contentWindow) return;
4328
+ if (record.shell && record.shell.classList && record.shell.classList.contains("is-focused")) return;
4329
+
4330
+ const scrollContainer = record.shell && typeof record.shell.closest === "function"
4331
+ ? record.shell.closest(".panel-scroll")
4332
+ : null;
4333
+ const isCapped = Boolean(record.iframe.classList && record.iframe.classList.contains("is-height-capped"));
4334
+ const documentHeight = Number(data.documentHeight);
4335
+ const viewportHeight = Number(data.viewportHeight);
4336
+ const isInternallyScrollable = isCapped
4337
+ || (Number.isFinite(documentHeight) && Number.isFinite(viewportHeight) && documentHeight > viewportHeight + 2);
4338
+ if (!scrollContainer || isInternallyScrollable) {
4339
+ if (typeof record.iframe.scrollIntoView === "function") {
4340
+ try {
4341
+ record.iframe.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
4342
+ } catch {
4343
+ record.iframe.scrollIntoView(false);
4344
+ }
4345
+ }
4346
+ return;
4347
+ }
4348
+
4349
+ const rawTargetTop = Number(data.targetTop);
4350
+ const offsetInFrame = Number.isFinite(rawTargetTop) && rawTargetTop > 0 ? rawTargetTop : 0;
4351
+ const iframeRect = record.iframe.getBoundingClientRect();
4352
+ const containerRect = scrollContainer.getBoundingClientRect();
4353
+ const topPadding = 12;
4354
+ const nextTop = Math.max(
4355
+ 0,
4356
+ scrollContainer.scrollTop + iframeRect.top - containerRect.top + offsetInFrame - topPadding,
4357
+ );
4358
+ try {
4359
+ scrollContainer.scrollTo({ top: nextTop, behavior: "smooth" });
4360
+ } catch {
4361
+ scrollContainer.scrollTop = nextTop;
4362
+ }
4363
+ }
4364
+
4365
+ function normalizeHtmlArtifactMathRenderItems(rawItems) {
4366
+ if (!Array.isArray(rawItems)) return [];
4367
+ return rawItems.slice(0, 250).map((item) => {
4368
+ const raw = item && typeof item === "object" ? item : null;
4369
+ const mathId = raw && typeof raw.mathId === "string" ? raw.mathId : "";
4370
+ const tex = raw && typeof raw.tex === "string" ? raw.tex : "";
4371
+ if (!mathId || !tex.trim()) return null;
4372
+ return {
4373
+ mathId,
4374
+ tex,
4375
+ display: Boolean(raw.display),
4376
+ };
4377
+ }).filter(Boolean);
4378
+ }
4379
+
4380
+ async function fetchRenderedHtmlArtifactMath(items) {
4381
+ const token = getToken();
4382
+ if (!token) {
4383
+ throw new Error("Missing Studio token in URL.");
4384
+ }
4385
+ const response = await fetchWithTimeout("/render-math?token=" + encodeURIComponent(token), {
4386
+ method: "POST",
4387
+ headers: {
4388
+ "Content-Type": "application/json",
4389
+ },
4390
+ body: JSON.stringify({ items }),
4391
+ }, HTML_ARTIFACT_MATH_RENDER_FETCH_TIMEOUT_MS, "HTML preview math render");
4392
+
4393
+ const rawBody = await response.text();
4394
+ let payload = null;
4395
+ try {
4396
+ payload = rawBody ? JSON.parse(rawBody) : null;
4397
+ } catch {
4398
+ payload = null;
4399
+ }
4400
+ if (!response.ok) {
4401
+ const message = payload && typeof payload.error === "string"
4402
+ ? payload.error
4403
+ : "HTML preview math render failed with HTTP " + response.status + ".";
4404
+ throw new Error(message);
4405
+ }
4406
+ if (!payload || payload.ok !== true || !Array.isArray(payload.results)) {
4407
+ throw new Error("HTML preview math renderer returned an invalid payload.");
4408
+ }
4409
+ return payload.results;
4410
+ }
4411
+
4412
+ function postHtmlArtifactMathResults(record, results) {
4413
+ if (!record || !record.iframe || !record.iframe.isConnected || !record.iframe.contentWindow) return;
4414
+ try {
4415
+ record.iframe.contentWindow.postMessage({
4416
+ type: "pi-studio-html-artifact-math-rendered",
4417
+ id: record.id || "",
4418
+ results: Array.isArray(results) ? results : [],
4419
+ }, "*");
4420
+ } catch {
4421
+ // Ignore iframe postMessage failures.
4422
+ }
4423
+ }
4424
+
4425
+ async function renderHtmlArtifactMathItems(record, items) {
4426
+ if (!record || !Array.isArray(items) || items.length === 0) return;
4427
+ if (record.detail) record.detail.textContent = "HTML preview · rendering math";
4428
+ try {
4429
+ const results = await fetchRenderedHtmlArtifactMath(items);
4430
+ postHtmlArtifactMathResults(record, results);
4431
+ } catch (error) {
4432
+ console.error("HTML preview math render failed:", error);
4433
+ postHtmlArtifactMathResults(record, items.map((item) => ({
4434
+ mathId: item.mathId,
4435
+ ok: false,
4436
+ error: error && error.message ? error.message : String(error || "HTML preview math render failed."),
4437
+ })));
4438
+ } finally {
4439
+ if (record.detail) record.detail.textContent = "HTML preview";
4440
+ }
4441
+ }
4442
+
4443
+ function handleHtmlArtifactFrameMathRenderMessage(event) {
4444
+ const data = event && event.data;
4445
+ if (!data || typeof data !== "object" || data.type !== "pi-studio-html-artifact-render-math") return;
4446
+ const id = typeof data.id === "string" ? data.id : "";
4447
+ const record = id ? htmlArtifactFramesById.get(id) : null;
4448
+ if (!record || !record.iframe || !record.iframe.isConnected) {
4449
+ if (id) htmlArtifactFramesById.delete(id);
4450
+ return;
4451
+ }
4452
+ if (event.source && record.iframe.contentWindow && event.source !== record.iframe.contentWindow) return;
4453
+ const items = normalizeHtmlArtifactMathRenderItems(data.items);
4454
+ if (items.length === 0) return;
4455
+
4456
+ record.mathRenderBatchCount = Math.max(0, Number(record.mathRenderBatchCount) || 0) + 1;
4457
+ record.mathRenderItemCount = Math.max(0, Number(record.mathRenderItemCount) || 0) + items.length;
4458
+ if (record.mathRenderBatchCount > 24 || record.mathRenderItemCount > 1000) {
4459
+ postHtmlArtifactMathResults(record, items.map((item) => ({
4460
+ mathId: item.mathId,
4461
+ ok: false,
4462
+ error: "HTML preview math render limit reached.",
4463
+ })));
4464
+ return;
4465
+ }
4466
+
4467
+ void renderHtmlArtifactMathItems(record, items);
4468
+ }
4469
+
4070
4470
  window.addEventListener("message", handleHtmlArtifactFrameSizeMessage);
4471
+ window.addEventListener("message", handleHtmlArtifactFrameFragmentMessage);
4472
+ window.addEventListener("message", handleHtmlArtifactFrameMathRenderMessage);
4071
4473
 
4072
4474
  function isStudioHtmlFocusOpen() {
4073
4475
  return Boolean(studioHtmlFocusOverlayEl && studioHtmlFocusOverlayEl.hidden === false && studioHtmlFocusShellEl);
@@ -4375,7 +4777,7 @@
4375
4777
  iframe.addEventListener("load", () => { postArtifactZoom(); });
4376
4778
  iframe.srcdoc = buildHtmlArtifactSrcdoc(html, previewId);
4377
4779
  shell.appendChild(iframe);
4378
- htmlArtifactFramesById.set(previewId, { iframe, shell, detail, zoomControls });
4780
+ htmlArtifactFramesById.set(previewId, { id: previewId, iframe, shell, detail, zoomControls, mathRenderBatchCount: 0, mathRenderItemCount: 0 });
4379
4781
 
4380
4782
  targetEl.appendChild(shell);
4381
4783
 
@@ -5719,12 +6121,34 @@
5719
6121
  const source = codeEl ? codeEl.textContent : preEl.textContent;
5720
6122
 
5721
6123
  const wrapper = document.createElement("div");
5722
- wrapper.className = "mermaid-container";
6124
+ wrapper.className = "mermaid-container studio-copyable-block";
6125
+ if (wrapper.dataset) {
6126
+ wrapper.dataset.mermaidSource = source || "";
6127
+ wrapper.dataset.studioCopyDecorated = "1";
6128
+ }
5723
6129
 
5724
6130
  const diagramEl = document.createElement("div");
5725
6131
  diagramEl.className = "mermaid";
5726
6132
  diagramEl.textContent = source || "";
5727
6133
 
6134
+ const copyBtn = document.createElement("button");
6135
+ copyBtn.type = "button";
6136
+ copyBtn.className = "studio-copy-block-btn studio-copy-mermaid-source-btn";
6137
+ copyBtn.textContent = "Copy source";
6138
+ copyBtn.title = "Copy this Mermaid source to the clipboard.";
6139
+ copyBtn.setAttribute("aria-label", "Copy Mermaid source to the clipboard");
6140
+ copyBtn.addEventListener("pointerdown", (event) => {
6141
+ event.stopPropagation();
6142
+ });
6143
+ copyBtn.addEventListener("mousedown", (event) => {
6144
+ event.stopPropagation();
6145
+ });
6146
+
6147
+ const toolbarEl = document.createElement("div");
6148
+ toolbarEl.className = "mermaid-source-toolbar";
6149
+ toolbarEl.appendChild(copyBtn);
6150
+
6151
+ wrapper.appendChild(toolbarEl);
5728
6152
  wrapper.appendChild(diagramEl);
5729
6153
  preEl.replaceWith(wrapper);
5730
6154
  });
@@ -6300,6 +6724,9 @@
6300
6724
 
6301
6725
  function getCopyablePreviewBlockText(blockEl) {
6302
6726
  if (!blockEl || typeof blockEl.querySelectorAll !== "function") return "";
6727
+ if (blockEl.classList && blockEl.classList.contains("mermaid-container") && blockEl.dataset && typeof blockEl.dataset.mermaidSource === "string") {
6728
+ return normalizeCopyableBlockText(blockEl.dataset.mermaidSource);
6729
+ }
6303
6730
  if (blockEl.classList && blockEl.classList.contains("preview-code-lines")) {
6304
6731
  return normalizeCopyableBlockText(
6305
6732
  Array.from(blockEl.querySelectorAll(".preview-code-line-content"))
package/client/studio.css CHANGED
@@ -2456,6 +2456,39 @@
2456
2456
  overflow-x: auto;
2457
2457
  }
2458
2458
 
2459
+ .rendered-markdown .mermaid-source-toolbar {
2460
+ display: flex;
2461
+ justify-content: flex-end;
2462
+ margin: 0 0 4px;
2463
+ pointer-events: none;
2464
+ }
2465
+
2466
+ .rendered-markdown .mermaid-source-toolbar .studio-copy-mermaid-source-btn {
2467
+ position: static;
2468
+ top: auto;
2469
+ right: auto;
2470
+ z-index: auto;
2471
+ padding: 2px 7px;
2472
+ border-color: transparent;
2473
+ background: transparent;
2474
+ box-shadow: none;
2475
+ font-size: 11px;
2476
+ opacity: 0.38;
2477
+ pointer-events: auto;
2478
+ }
2479
+
2480
+ .rendered-markdown .mermaid-container:hover > .studio-copy-block-btn {
2481
+ opacity: 0.38;
2482
+ }
2483
+
2484
+ .rendered-markdown .mermaid-source-toolbar .studio-copy-mermaid-source-btn:hover,
2485
+ .rendered-markdown .mermaid-source-toolbar .studio-copy-mermaid-source-btn:focus-visible {
2486
+ opacity: 1;
2487
+ color: var(--text);
2488
+ border-color: var(--control-border);
2489
+ background: var(--panel);
2490
+ }
2491
+
2459
2492
  .rendered-markdown .mermaid-container svg {
2460
2493
  max-width: 100%;
2461
2494
  height: auto;
package/index.ts CHANGED
@@ -64,6 +64,19 @@ interface StudioPromptDescriptor {
64
64
  promptTriggerText: string | null;
65
65
  }
66
66
 
67
+ interface StudioHtmlPreviewMathRenderItem {
68
+ mathId: string;
69
+ tex: string;
70
+ display: boolean;
71
+ }
72
+
73
+ interface StudioHtmlPreviewMathRenderResult {
74
+ mathId: string;
75
+ ok: boolean;
76
+ html?: string;
77
+ error?: string;
78
+ }
79
+
67
80
  interface ActiveStudioRequest extends StudioPromptDescriptor {
68
81
  id: string;
69
82
  kind: StudioRequestKind;
@@ -460,6 +473,9 @@ const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
460
473
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
461
474
  const PDF_EXPORT_MAX_CHARS = 400_000;
462
475
  const HTML_EXPORT_MAX_CHARS = 400_000;
476
+ const HTML_PREVIEW_MATH_RENDER_MAX_ITEMS = 250;
477
+ const HTML_PREVIEW_MATH_RENDER_ITEM_MAX_CHARS = 8_000;
478
+ const HTML_PREVIEW_MATH_RENDER_TOTAL_MAX_CHARS = 120_000;
463
479
  const STUDIO_QUIZ_SOURCE_MAX_CHARS = 80_000;
464
480
  const STUDIO_QUIZ_CONTEXT_FILE_MAX_CHARS = 14_000;
465
481
  const STUDIO_QUIZ_CONTEXT_MAX_FILES = 18;
@@ -4900,6 +4916,72 @@ function stripMathMlAnnotationTags(html: string): string {
4900
4916
  });
4901
4917
  }
4902
4918
 
4919
+ function normalizeStudioHtmlPreviewMathForPandoc(tex: string): string {
4920
+ return String(tex ?? "")
4921
+ .replace(/\r\n/g, "\n")
4922
+ .replace(/\\rm\s*\{([^{}]+)\}/g, "\\mathrm{$1}")
4923
+ .replace(/\\rm\s+([A-Za-z]+)(?=[^A-Za-z]|$)/g, "\\mathrm{$1}");
4924
+ }
4925
+
4926
+ function getStudioHtmlPreviewMathWrapperId(index: number): string {
4927
+ return `studio-html-preview-math-${Math.max(0, Math.floor(index))}`;
4928
+ }
4929
+
4930
+ function buildStudioHtmlPreviewMathPandocSource(items: StudioHtmlPreviewMathRenderItem[]): string {
4931
+ return items.map((item, index) => {
4932
+ const wrapperId = getStudioHtmlPreviewMathWrapperId(index);
4933
+ const tex = normalizeStudioHtmlPreviewMathForPandoc(item.tex);
4934
+ const mathSource = item.display ? `\\[\n${tex}\n\\]` : `\\(${tex}\\)`;
4935
+ return `:::: {#${wrapperId} .studio-html-preview-math-render-item}\n${mathSource}\n::::`;
4936
+ }).join("\n\n");
4937
+ }
4938
+
4939
+ function extractStudioHtmlPreviewMathHtml(renderedHtml: string, wrapperId: string): string {
4940
+ const idPattern = escapeStudioRegExpLiteral(wrapperId);
4941
+ const wrapperPattern = new RegExp(`<div\\b(?=[^>]*\\bid="${idPattern}")[^>]*>([\\s\\S]*?)<\\/div>`, "i");
4942
+ const wrapperMatch = String(renderedHtml ?? "").match(wrapperPattern);
4943
+ const wrapperHtml = wrapperMatch ? String(wrapperMatch[1] ?? "") : "";
4944
+ const mathMatch = wrapperHtml.match(/<math\b[\s\S]*?<\/math>/i);
4945
+ return mathMatch ? stripMathMlAnnotationTags(mathMatch[0]) : "";
4946
+ }
4947
+
4948
+ async function renderStudioHtmlPreviewMathWithPandoc(items: StudioHtmlPreviewMathRenderItem[]): Promise<StudioHtmlPreviewMathRenderResult[]> {
4949
+ if (items.length === 0) return [];
4950
+ const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
4951
+ const inputFormat = "markdown+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash";
4952
+ const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none"];
4953
+ const source = buildStudioHtmlPreviewMathPandocSource(items);
4954
+ const pandocResult = await runStudioSubprocess(pandocCommand, args, {
4955
+ input: source,
4956
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
4957
+ stdoutMaxBytes: Math.min(STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES, 10_000_000),
4958
+ label: "pandoc HTML preview math render",
4959
+ notFoundMessage: "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary.",
4960
+ });
4961
+ if (pandocResult.code !== 0) {
4962
+ throw new Error(`pandoc math render failed with exit code ${pandocResult.code}${pandocResult.stderr ? `: ${pandocResult.stderr}` : ""}`);
4963
+ }
4964
+ if (pandocResult.stdoutTruncated) {
4965
+ throw new Error("pandoc math render output exceeded Studio's size limit.");
4966
+ }
4967
+
4968
+ return items.map((item, index) => {
4969
+ const html = extractStudioHtmlPreviewMathHtml(pandocResult.stdout, getStudioHtmlPreviewMathWrapperId(index));
4970
+ if (!html) {
4971
+ return {
4972
+ mathId: item.mathId,
4973
+ ok: false,
4974
+ error: "Pandoc did not render this expression as MathML.",
4975
+ };
4976
+ }
4977
+ return {
4978
+ mathId: item.mathId,
4979
+ ok: true,
4980
+ html,
4981
+ };
4982
+ });
4983
+ }
4984
+
4903
4985
  function normalizeObsidianImages(markdown: string): string {
4904
4986
  // Use angle-bracket destinations so paths with spaces/special chars are safe for Pandoc
4905
4987
  return markdown
@@ -11479,6 +11561,84 @@ export default function (pi: ExtensionAPI) {
11479
11561
  }
11480
11562
  };
11481
11563
 
11564
+ const handleRenderMathRequest = async (req: IncomingMessage, res: ServerResponse) => {
11565
+ let rawBody = "";
11566
+ try {
11567
+ rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
11568
+ } catch (error) {
11569
+ const message = error instanceof Error ? error.message : String(error);
11570
+ const status = message.includes("exceeds") ? 413 : 400;
11571
+ respondJson(res, status, { ok: false, error: message });
11572
+ return;
11573
+ }
11574
+
11575
+ let parsedBody: unknown;
11576
+ try {
11577
+ parsedBody = rawBody ? JSON.parse(rawBody) : {};
11578
+ } catch {
11579
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
11580
+ return;
11581
+ }
11582
+
11583
+ const rawItems =
11584
+ parsedBody && typeof parsedBody === "object" && Array.isArray((parsedBody as { items?: unknown }).items)
11585
+ ? (parsedBody as { items: unknown[] }).items
11586
+ : null;
11587
+ if (!rawItems) {
11588
+ respondJson(res, 400, { ok: false, error: "Missing math items array in request body." });
11589
+ return;
11590
+ }
11591
+ if (rawItems.length > HTML_PREVIEW_MATH_RENDER_MAX_ITEMS) {
11592
+ respondJson(res, 413, {
11593
+ ok: false,
11594
+ error: `HTML preview math render accepts at most ${HTML_PREVIEW_MATH_RENDER_MAX_ITEMS} items per request.`,
11595
+ });
11596
+ return;
11597
+ }
11598
+
11599
+ const items: StudioHtmlPreviewMathRenderItem[] = [];
11600
+ let totalChars = 0;
11601
+ for (const rawItem of rawItems) {
11602
+ const item = rawItem && typeof rawItem === "object" ? rawItem as { mathId?: unknown; tex?: unknown; display?: unknown } : null;
11603
+ const mathId = typeof item?.mathId === "string" ? item.mathId.trim() : "";
11604
+ const tex = typeof item?.tex === "string" ? item.tex : "";
11605
+ if (!mathId || !tex.trim()) continue;
11606
+ if (mathId.length > 160) {
11607
+ respondJson(res, 400, { ok: false, error: "Math item id is too long." });
11608
+ return;
11609
+ }
11610
+ if (tex.length > HTML_PREVIEW_MATH_RENDER_ITEM_MAX_CHARS) {
11611
+ respondJson(res, 413, {
11612
+ ok: false,
11613
+ error: `A math expression exceeds ${HTML_PREVIEW_MATH_RENDER_ITEM_MAX_CHARS} characters.`,
11614
+ });
11615
+ return;
11616
+ }
11617
+ totalChars += tex.length;
11618
+ if (totalChars > HTML_PREVIEW_MATH_RENDER_TOTAL_MAX_CHARS) {
11619
+ respondJson(res, 413, {
11620
+ ok: false,
11621
+ error: `Math render text exceeds ${HTML_PREVIEW_MATH_RENDER_TOTAL_MAX_CHARS} characters.`,
11622
+ });
11623
+ return;
11624
+ }
11625
+ items.push({ mathId, tex, display: Boolean(item?.display) });
11626
+ }
11627
+
11628
+ if (items.length === 0) {
11629
+ respondJson(res, 400, { ok: false, error: "No valid math items to render." });
11630
+ return;
11631
+ }
11632
+
11633
+ try {
11634
+ const results = await renderStudioHtmlPreviewMathWithPandoc(items);
11635
+ respondJson(res, 200, { ok: true, renderer: "pandoc", results });
11636
+ } catch (error) {
11637
+ const message = error instanceof Error ? error.message : String(error);
11638
+ respondJson(res, 500, { ok: false, error: `Math render failed: ${message}` });
11639
+ }
11640
+ };
11641
+
11482
11642
  const handleExportPdfRequest = async (req: IncomingMessage, res: ServerResponse) => {
11483
11643
  let rawBody = "";
11484
11644
  try {
@@ -11844,6 +12004,29 @@ export default function (pi: ExtensionAPI) {
11844
12004
  return;
11845
12005
  }
11846
12006
 
12007
+ if (requestUrl.pathname === "/render-math") {
12008
+ const token = requestUrl.searchParams.get("token") ?? "";
12009
+ if (token !== serverState.token) {
12010
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12011
+ return;
12012
+ }
12013
+
12014
+ const method = (req.method ?? "GET").toUpperCase();
12015
+ if (method !== "POST") {
12016
+ res.setHeader("Allow", "POST");
12017
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
12018
+ return;
12019
+ }
12020
+
12021
+ void handleRenderMathRequest(req, res).catch((error) => {
12022
+ respondJson(res, 500, {
12023
+ ok: false,
12024
+ error: `Math render failed: ${error instanceof Error ? error.message : String(error)}`,
12025
+ });
12026
+ });
12027
+ return;
12028
+ }
12029
+
11847
12030
  if (requestUrl.pathname === "/export-pdf") {
11848
12031
  const token = requestUrl.searchParams.get("token") ?? "";
11849
12032
  if (token !== serverState.token) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.10",
3
+ "version": "0.9.11",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",