'+hi(r.item.name,q)+'
';
@@ -1182,6 +1182,7 @@ def _get_js() -> str:
});
}
+ // BEGIN_ESCAPERS
function esc(str) {
if (!str) return '';
var d = document.createElement('div');
@@ -1202,13 +1203,16 @@ def _get_js() -> str:
}
function escJsAttr(str) {
- // A JS string literal nested inside an HTML attribute — two contexts, so
- // two escapes in that order. innerHTML decodes the entities before the
- // handler compiles, so the JS escapes must survive that decode: \' stays
- // \', while " arrives as a plain quote which cannot close a
- // single-quoted JS string.
- return escAttr(String(str).replace(/\\\\/g, '\\\\\\\\').replace(/'/g, "\\\\'"));
+ // JSON.stringify is the JavaScript string-literal serializer. It handles
+ // quotes, backslashes, and ordinary line terminators; escape the two
+ // legacy Unicode line separators that it leaves literal. Then HTML-escape
+ // the complete literal for the double-quoted attribute.
+ var literal = !str ? '""' : JSON.stringify(String(str));
+ return escAttr(
+ literal.replace(/\\u2028/g, '\\\\u2028').replace(/\\u2029/g, '\\\\u2029')
+ );
}
+ // END_ESCAPERS
init();
})();
diff --git a/tests/codex/test_docs_output_safety.py b/tests/codex/test_docs_output_safety.py
index 701d36a6..9ebbd612 100644
--- a/tests/codex/test_docs_output_safety.py
+++ b/tests/codex/test_docs_output_safety.py
@@ -475,16 +475,18 @@ def test_entity_encoded_schemes_are_neutralized_in_both_renderers(self, tmp_path
assert "javascript:alert" not in content
def test_emitted_page_script_survives_backslash_escaping(self, tmp_path):
- """The JS template is a non-raw Python string — backslash halving
- once shipped an unparseable script and a blank page. Pin the
- emitted (post-halving) escJsAttr line, and parse every script
- block with node when it is available."""
+ """Pin the emitted escJsAttr escapes and parse every script block
+ with node when it is available."""
import shutil
import subprocess
from skillsaw.docs.html_renderer import _get_js
- assert ".replace(/\\\\/g, '\\\\\\\\').replace(/'/g, \"\\\\'\")" in _get_js()
+ js = _get_js()
+ assert "function escJsAttr(str)" in js
+ assert "JSON.stringify(String(str))" in js
+ assert ".replace(/\\u2028/g, '\\\\u2028')" in js
+ assert ".replace(/\\u2029/g, '\\\\u2029')" in js
node = shutil.which("node")
if node is None:
@@ -506,6 +508,39 @@ def test_emitted_page_script_survives_backslash_escaping(self, tmp_path):
proc = subprocess.run([node, "--check", str(js)], capture_output=True, text=True)
assert proc.returncode == 0, proc.stderr
+ def test_escjsattr_serializes_javascript_line_terminators(self, tmp_path):
+ import shutil
+ import subprocess
+
+ from skillsaw.docs.html_renderer import _get_js
+
+ node = shutil.which("node")
+ if node is None:
+ pytest.skip("node not available")
+
+ js = _get_js()
+ start_marker = " // BEGIN_ESCAPERS"
+ end_marker = " // END_ESCAPERS"
+ assert start_marker in js, "missing JavaScript escaper start marker"
+ assert end_marker in js, "missing JavaScript escaper end marker"
+ start = js.index(start_marker)
+ end = js.index(end_marker, start) + len(end_marker)
+ helpers = js[start:end]
+ value = "first\r\nsecond\u2028third\u2029fourth\\'quote"
+ expected_js = json.dumps(value, ensure_ascii=False)
+ expected_js = expected_js.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
+ expected_attr = (
+ expected_js.replace("&", "&")
+ .replace('"', """)
+ .replace("<", "<")
+ .replace(">", ">")
+ )
+ script = f"{helpers}\nprocess.stdout.write(JSON.stringify(escJsAttr({json.dumps(value)})));"
+ proc = subprocess.run([node, "-e", script], capture_output=True, text=True)
+
+ assert proc.returncode == 0, proc.stderr
+ assert json.loads(proc.stdout) == expected_attr
+
class TestSafeUrlEntityDecoding:
def test_safe_url_decodes_entities_before_scheme_validation(self):