diff --git a/static/messages.js b/static/messages.js
index eb0ba502e..c3758371c 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -4261,6 +4261,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const parserFor = (data)=>{
return _smdParserKey(data, el);
};
+ const writeFadeText=(writeParent, writeData, writeText)=>{
+ if(!writeParent||_streamFadeSkipNode(writeParent)){
+ _smdAppendPlainText(writeParent, writeData, writeText, baseAddText);
+ return;
+ }
+ _streamFadeAppendText(writeParent, writeText);
+ };
renderer.add_text=(data,text)=>{
const parent=data&&data.nodes&&data.nodes[data.index];
if(!parent||_streamFadeSkipNode(parent)){baseAddText(data,text);return;}
@@ -4273,7 +4280,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const value=String(text||'');
const hasMediaPrefixTail=!!_smdMediaPrefixTail(value);
if(/MEDIA:/.test(value)||hasMediaTail||hasMediaPrefixTail){
- _smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser);
+ _smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText);
return;
}
const frag=document.createDocumentFragment();
@@ -4338,8 +4345,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// Without this, streamed prose shows MEDIA:C:\... as literal text until the
// turn settles and the full re-render swaps it for the real
.
// SAFETY & CROSS-CHUNK SPLITS (Greptile #1 + #2):
- // 1. Prose slices go back to baseAddText (which calls createTextNode),
- // NOT through DOMParser — mixed prose with HTML entities /
+ // 1. Prose slices go back to the owning text writer (text nodes or
+ // fade spans), NOT through DOMParser — mixed prose with HTML entities /
// malicious
stays as literal text.
// 2. Each MEDIA token's HTML (from _inlineMediaHtmlForRef) is handed
// to DOMParser one at a time — only trusted markup is parsed.
@@ -4357,57 +4364,88 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
return '';
}
- function _smdMediaTailSet(tailMap, parser, chunk, parent, baseAddText, data){
- if(chunk) tailMap.set(parser, {chunk, parent, baseAddText, data});
+ function _smdAppendPlainText(parent, data, text, baseAddText){
+ const value=String(text||'');
+ if(parent&&parent.appendChild&&typeof document!=='undefined'&&document.createTextNode){
+ parent.appendChild(document.createTextNode(value));
+ return;
+ }
+ if(baseAddText) baseAddText(data,value);
+ }
+ function _smdMediaWriteText(parent, data, baseAddText, writeText, text){
+ if(writeText){
+ writeText(parent, data, String(text||''));
+ return;
+ }
+ if(baseAddText) baseAddText(data,String(text||''));
+ }
+ function _smdMediaTailSet(tailMap, parser, chunk, parent, baseAddText, data, writeText){
+ if(!tailMap||!parser) return;
+ if(chunk) tailMap.set(parser, {chunk, parent, baseAddText, data, writeText});
else tailMap.delete(parser);
}
function _smdMediaTailEntryChunk(entry){
return entry && typeof entry==='object' && Object.prototype.hasOwnProperty.call(entry,'chunk') ? entry.chunk : entry;
}
+ function _smdMediaTailSameOwner(entry, parent, baseAddText, writeText){
+ return !!entry && entry.parent===parent && entry.baseAddText===baseAddText && entry.writeText===writeText;
+ }
function _smdMediaRefHasReliableBoundary(rawRef){
const raw=String(rawRef||'');
if(/[?#]$/.test(raw)) return false;
const ref=raw.split(/[?#]/,1)[0];
return /\.(?:png|jpe?g|gif|webp|bmp|ico|svg|avif|mp4|webm|mov|m4v|mkv|avi|ogv|mp3|wav|ogg|m4a|aac|wma|opus|flac|oga|pdf|html?|csv|diff|patch|excalidraw)$/i.test(ref);
}
+ function _smdMediaTailFlushEntry(entry){
+ const chunk=_smdMediaTailEntryChunk(entry);
+ if(!chunk) return;
+ const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
+ const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
+ if(!emitted && entry) _smdMediaWriteText(entry.parent, entry.data, entry.baseAddText, entry.writeText, chunk);
+ }
function _smdMediaTailFlush(parser){
if(!_SMD_MEDIA_TAIL||!parser||!_SMD_MEDIA_TAIL.get) return;
const entry=_SMD_MEDIA_TAIL.get(parser);
- const chunk=_smdMediaTailEntryChunk(entry);
- if(!chunk) return;
+ if(!entry) return;
_SMD_MEDIA_TAIL.delete(parser);
- const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
- const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
- if(!emitted && entry && entry.baseAddText) entry.baseAddText(entry.data, chunk);
+ _smdMediaTailFlushEntry(entry);
}
- function _smdMediaAwareAddText(baseAddText, parent, data, text, tailMap, parser){
+ function _smdMediaAwareAddText(baseAddText, parent, data, text, tailMap, parser, writeText){
const value=String(text||'');
const tails=tailMap||(typeof _SMD_MEDIA_TAIL!=='undefined'&&_SMD_MEDIA_TAIL)||null;
+ const writeCurrent=(chunk)=>_smdMediaWriteText(parent, data, baseAddText, writeText, chunk);
if(!value){
- if(baseAddText) baseAddText(data,'');
+ writeCurrent('');
return;
}
// Pull any pending tail from a previous (split) chunk, then clear it;
// this call will either complete it, re-buffer it, or flush it as text.
- const leadEntry = tails && parser && tails.get ? tails.get(parser) : null;
- const lead = _smdMediaTailEntryChunk(leadEntry);
- if(lead && tails && parser && tails.delete) tails.delete(parser);
+ let leadEntry = tails && parser && tails.get ? tails.get(parser) : null;
+ let lead = _smdMediaTailEntryChunk(leadEntry);
+ if(lead && !_smdMediaTailSameOwner(leadEntry, parent, baseAddText, writeText)){
+ if(tails && parser && tails.delete) tails.delete(parser);
+ _smdMediaTailFlushEntry(leadEntry);
+ leadEntry=null;
+ lead='';
+ }else if(lead && tails && parser && tails.delete){
+ tails.delete(parser);
+ }
const combined = lead ? lead + value : value;
// Fast path: no MEDIA tokens in the (possibly combined) string.
if(!/MEDIA:/.test(combined)){
const prefixTail=_smdMediaPrefixTail(combined);
if(prefixTail && tails && parser && prefixTail.length < _MEDIA_TAIL_MAX){
const stable=combined.slice(0, combined.length-prefixTail.length);
- if(stable && baseAddText) baseAddText(data, stable);
- _smdMediaTailSet(tails, parser, prefixTail, parent, baseAddText, data);
+ if(stable) writeCurrent(stable);
+ _smdMediaTailSet(tails, parser, prefixTail, parent, baseAddText, data, writeText);
return;
}
- if(baseAddText) baseAddText(data, combined);
+ writeCurrent(combined);
return;
}
// Walk the combined string, slicing into prose + MEDIA token runs.
- // Prose runs go through baseAddText (text nodes). MEDIA tokens go
- // through the single-token DOMParser helper only after a delimiter or
+ // Prose runs go through the owning text writer. MEDIA tokens go through
+ // the single-token DOMParser helper only after a delimiter or
// reliable filename suffix proves the ref is complete.
const re=/MEDIA:([^\s\)\]]+)/g;
let last=0, m;
@@ -4416,19 +4454,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const matchEnd = m.index + m[0].length;
if(m.index>last){
const slice = combined.slice(last, m.index);
- if(baseAddText) baseAddText(data, slice);
+ writeCurrent(slice);
}
if(matchEnd===combined.length && !_smdMediaRefHasReliableBoundary(m[1])){
const candidate = combined.slice(m.index);
if(candidate.length < _MEDIA_TAIL_MAX){
unmatchedTail = candidate;
- } else if(baseAddText){
- baseAddText(data, candidate);
+ } else {
+ writeCurrent(candidate);
}
last = combined.length;
break;
}
- if(!_smdAppendMediaNode(parent, m[1]) && baseAddText) baseAddText(data, m[0]);
+ if(!_smdAppendMediaNode(parent, m[1])) writeCurrent(m[0]);
last = matchEnd;
}
// Tail buffer — hold trailing bytes that look like an unterminated
@@ -4440,14 +4478,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const tailValue = tailMatch ? tailMatch[0] : prefixTail;
if(tailValue && rest.length < _MEDIA_TAIL_MAX){
const tailStart = tailMatch ? tailMatch.index : rest.length-prefixTail.length;
- if(tailStart>0 && baseAddText) baseAddText(data, rest.slice(0, tailStart));
+ if(tailStart>0) writeCurrent(rest.slice(0, tailStart));
unmatchedTail = tailValue;
- } else if(baseAddText){
- baseAddText(data, rest);
+ } else {
+ writeCurrent(rest);
}
}
if(tails && parser){
- _smdMediaTailSet(tails, parser, unmatchedTail, parent, baseAddText, data);
+ _smdMediaTailSet(tails, parser, unmatchedTail, parent, baseAddText, data, writeText);
}
}
// Single-token DOM splice. Only ever fed the output of
@@ -4516,12 +4554,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const renderer=window.smd.default_renderer(el);
const baseSetAttr=renderer.set_attr;
const baseAddText=renderer.add_text;
+ const writePlainText=(writeParent, writeData, writeText)=>{
+ _smdAppendPlainText(writeParent, writeData, writeText, baseAddText);
+ };
const parserFor = (data)=>{
return _smdParserKey(data, el);
};
renderer.add_text=(data,text)=>{
const parent=data&&data.nodes&&data.nodes[data.index];
- _smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parserFor(data));
+ _smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parserFor(data), writePlainText);
};
renderer.set_attr=(data,attr,value)=>{
const isHref=window.smd&&attr===window.smd.HREF;
diff --git a/tests/test_smd_media_in_stream.py b/tests/test_smd_media_in_stream.py
index 43fd06e81..a58f8b675 100644
--- a/tests/test_smd_media_in_stream.py
+++ b/tests/test_smd_media_in_stream.py
@@ -32,6 +32,7 @@ from __future__ import annotations
import json
import pathlib
+import re
import shutil
import subprocess
import unittest
@@ -43,8 +44,11 @@ NODE = shutil.which("node")
def _extract_js_function(src: str, name: str) -> str:
- start = src.index(f"function {name}")
- brace = src.index("{", start)
+ match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
+ if not match:
+ raise ValueError(f"Could not find JS function {name}")
+ start = match.start()
+ brace = src.index("{", match.end())
depth = 1
pos = brace + 1
while pos < len(src) and depth:
@@ -60,9 +64,13 @@ def _run_real_smd_media_cases() -> dict:
helpers = "\n".join(
[
_extract_js_function(MESSAGES_JS, "_smdMediaPrefixTail"),
+ _extract_js_function(MESSAGES_JS, "_smdAppendPlainText"),
+ _extract_js_function(MESSAGES_JS, "_smdMediaWriteText"),
_extract_js_function(MESSAGES_JS, "_smdMediaTailSet"),
_extract_js_function(MESSAGES_JS, "_smdMediaTailEntryChunk"),
+ _extract_js_function(MESSAGES_JS, "_smdMediaTailSameOwner"),
_extract_js_function(MESSAGES_JS, "_smdMediaRefHasReliableBoundary"),
+ _extract_js_function(MESSAGES_JS, "_smdMediaTailFlushEntry"),
_extract_js_function(MESSAGES_JS, "_smdMediaTailFlush"),
_extract_js_function(MESSAGES_JS, "_smdMediaAwareAddText"),
_extract_js_function(MESSAGES_JS, "_smdAppendMediaNode"),
@@ -73,6 +81,7 @@ def _run_real_smd_media_cases() -> dict:
_extract_js_function(MESSAGES_JS, "_streamFadeSkipNode"),
_extract_js_function(MESSAGES_JS, "_streamFadeReduceMotionEnabled"),
_extract_js_function(MESSAGES_JS, "_streamFadeBindCleanup"),
+ _extract_js_function(MESSAGES_JS, "_streamFadeAppendText"),
_extract_js_function(MESSAGES_JS, "_streamFadeRenderer"),
_extract_js_function(MESSAGES_JS, "_safeSmdRenderer"),
_extract_js_function(MESSAGES_JS, "_smdRendererWithoutUnderscoreEmphasis"),
@@ -113,6 +122,7 @@ def _run_real_smd_media_cases() -> dict:
" }\n"
" get childNodes(){ return this.children; }\n"
" get firstChild(){ return this.children[0] || null; }\n"
+ " get parentElement(){ return this.parentNode; }\n"
" get className(){ return this.attributes.class || ''; }\n"
" set className(value){ this.attributes.class=String(value); }\n"
" appendChild(child){\n"
@@ -131,6 +141,12 @@ def _run_real_smd_media_cases() -> dict:
" visit(this); return out;\n"
" }\n"
" get textContent(){ return this.nodeType===3 ? this.data : this.children.map(c=>c.textContent).join(''); }\n"
+ " set textContent(value){\n"
+ " if(this.nodeType===3){ this.data=String(value); return; }\n"
+ " this.children=[];\n"
+ " const text=String(value);\n"
+ " if(text) this.appendChild(document.createTextNode(text));\n"
+ " }\n"
" get outerHTML(){\n"
" if(this.nodeType===3) return esc(this.data);\n"
" if(this.nodeType===11) return this.children.map(c=>c.outerHTML).join('');\n"
@@ -154,6 +170,12 @@ def _run_real_smd_media_cases() -> dict:
" return { body: { firstChild: host } };\n"
"} };\n"
f"{helpers}\n"
+ "function collectTagTexts(root, tag){\n"
+ " const out=[]; const wanted=String(tag).toLowerCase();\n"
+ " const visit=node=>{ for(const child of node.children){ if(String(child.tagName||'').toLowerCase()===wanted) out.push(child.textContent); visit(child); } };\n"
+ " visit(root); return out;\n"
+ "}\n"
+ "function collectClassTexts(root, cls){ return root.querySelectorAll('.'+cls).map(node=>node.textContent); }\n"
"function renderChunks(chunks, mode){\n"
" postProcessCalls = 0; playbackCalls = 0;\n"
" const root=document.createElement('div');\n"
@@ -165,7 +187,7 @@ def _run_real_smd_media_cases() -> dict:
" smd.parser_end(parser);\n"
" _smdMediaTailFlush(parser);\n"
" _smdMediaTailClear(parser);\n"
- " return { html: root.outerHTML, text: root.textContent, postProcessCalls, playbackCalls };\n"
+ " return { html: root.outerHTML, text: root.textContent, liTexts: collectTagTexts(root, 'li'), fadeWords: collectClassTexts(root, 'stream-fade-word'), postProcessCalls, playbackCalls };\n"
"}\n"
"function renderModes(chunks){ return { safe: renderChunks(chunks, 'safe'), fade: renderChunks(chunks, 'fade') }; }\n"
"const marker='MEDIA:';\n"
@@ -174,7 +196,9 @@ def _run_real_smd_media_cases() -> dict:
"const refSplit=renderModes(['MEDIA:C:/tmp/li', 've.png ']);\n"
"const finalExtensionless=renderModes(['MEDIA:https://fal.media/generated']);\n"
"const pdf=renderModes(['MEDIA:C:/tmp/report.pdf ']);\n"
- "console.log(JSON.stringify({prefixSplits, refSplit, finalExtensionless, pdf}));\n"
+ "const falsePrefix=renderModes(['M', 'aybe plain prose ']);\n"
+ "const crossParent=renderModes(['- ME', '\\n- ow']);\n"
+ "console.log(JSON.stringify({prefixSplits, refSplit, finalExtensionless, pdf, falsePrefix, crossParent}));\n"
)
completed = subprocess.run(
[NODE, "--input-type=module", "-e", script],
@@ -272,16 +296,14 @@ class TestSmdMediaInStream(unittest.TestCase):
def test_media_interceptor_falls_back_to_base_when_no_token(self):
# Fast path: when the chunk + buffered tail carries no MEDIA token,
- # the wrapper should delegate to the underlying add_text unchanged
- # so the fade renderer's word-by-word animation is preserved for
- # plain prose.
+ # the wrapper should delegate to the injected text writer so the
+ # owning renderer's semantics (plain text for safe mode, word fade for
+ # fade mode) survive for plain prose.
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
block = MESSAGES_JS[idx:idx + 6500]
- self.assertTrue(
- "baseAddText(data,value)" in block or "baseAddText(data," in block,
- "Interceptor must delegate to baseAddText for plain text so the "
- "underlying renderer's semantics survive",
- )
+ self.assertIn("const writeCurrent=", block)
+ self.assertIn("writeCurrent(combined)", block)
+ self.assertIn("function _smdMediaWriteText", MESSAGES_JS)
def test_no_recursive_infinite_loop_via_baseAddText(self):
# Regression guard: the fade renderer's add_text is itself a wrapper.
@@ -418,6 +440,29 @@ class TestSmdMediaInStream(unittest.TestCase):
"concurrent streams don't cross-pollinate",
)
+ def test_tail_entries_preserve_original_text_owner(self):
+ # A buffered MEDIA-looking suffix belongs to the parent/writer that
+ # produced it. If the next smd add_text callback is for a different
+ # parent, the scanner must flush through the original owner instead
+ # of concatenating across DOM nodes.
+ idx = MESSAGES_JS.index("function _smdMediaTailSet")
+ block = MESSAGES_JS[idx:idx + 3500]
+ self.assertIn("writeText", block)
+ self.assertIn("function _smdMediaTailSameOwner", MESSAGES_JS)
+ self.assertIn("entry.parent===parent", MESSAGES_JS)
+ self.assertIn("entry.writeText===writeText", MESSAGES_JS)
+ self.assertIn("_smdMediaTailFlushEntry(leadEntry)", MESSAGES_JS)
+
+ def test_stream_fade_media_scanner_preserves_plain_prose_fade(self):
+ # False MEDIA-prefix tails (for example "M" + "aybe") still enter
+ # the scanner. Those plain prose writes must use the non-recursive
+ # fade appender, not the raw default_renderer text writer.
+ idx = MESSAGES_JS.index("function _streamFadeRenderer")
+ block = MESSAGES_JS[idx:idx + 7000]
+ self.assertIn("const writeFadeText=", block)
+ self.assertIn("_streamFadeAppendText(writeParent, writeText)", block)
+ self.assertIn("_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText)", block)
+
def test_smd_parser_identity_is_bound_to_real_parser(self):
# smd's renderer.data does not expose a parser by default. Bind the
# created parser onto both renderer.data and the owning element so every
@@ -514,6 +559,20 @@ class TestSmdMediaRealParserBehaviour(unittest.TestCase):
self.assertGreaterEqual(result["postProcessCalls"], 1)
self.assertGreaterEqual(result["playbackCalls"], 1)
+ def test_real_smd_parser_false_prefix_plain_prose_keeps_fade(self):
+ result = self.cases["falsePrefix"]["fade"]
+ self.assertEqual(result["text"], "Maybe plain prose ")
+ self.assertEqual(result["fadeWords"], ["Maybe", "plain", "prose"])
+ self.assertIn('class="stream-fade-word is-new"', result["html"])
+
+ def test_real_smd_parser_refuses_cross_parent_tail_concat(self):
+ for mode, result in self.cases["crossParent"].items():
+ with self.subTest(mode=mode):
+ self.assertEqual(result["liTexts"], ["ME", "ow"])
+ if mode == "fade":
+ self.assertTrue(result["fadeWords"])
+ self.assertEqual("".join(result["fadeWords"]), "MEow")
+
if __name__ == "__main__":
import unittest