strip_external_scripts
Make generated API docs obey the site's script-src 'self' CSP (MIP-0044 §5.6).
scaladoc emits four third-party <script src="https://..."> tags on every page — dagre-d3,
graphlib-dot, d3 and scastie — and one inline var pathToRoot = "...". A site that advertises no
third-party requests cannot serve them. Losing dagre/d3 loses inheritance diagrams and losing
scastie loses "run this snippet"; neither is used by this codebase's docs.
pathToRoot is kept, moved to a data-path-to-root attribute on
scripts/strip_external_scripts.py out/scala out/python
scripts/strip_external_scripts.py --self-test
1#!/usr/bin/env python3 2"""Make generated API docs obey the site's `script-src 'self'` CSP (MIP-0044 §5.6). 3 4scaladoc emits four third-party `<script src="https://...">` tags on every page — dagre-d3, 5graphlib-dot, d3 and scastie — and one inline `var pathToRoot = "..."`. A site that advertises no 6third-party requests cannot serve them. Losing dagre/d3 loses inheritance diagrams and losing 7scastie loses "run this snippet"; neither is used by this codebase's docs. 8 9`pathToRoot` is kept, moved to a `data-path-to-root` attribute on <body> that scaladoc's own 10scripts read via a tiny same-origin shim, so relative links still resolve without an inline script. 11 12 scripts/strip_external_scripts.py out/scala out/python 13 scripts/strip_external_scripts.py --self-test 14""" 15 16from __future__ import annotations 17 18import argparse 19import re 20import sys 21from pathlib import Path 22 23EXTERNAL_SCRIPT = re.compile( 24 r"""<script[^>]*\ssrc=["']https?://[^"']*["'][^>]*>\s*</script>""", re.I 25) 26PATH_TO_ROOT = re.compile( 27 r"""<script[^>]*>\s*var\s+pathToRoot\s*=\s*["']([^"']*)["']\s*;?\s*</script>""", re.I 28) 29SHIM = '<script src="{root}scripts/path-to-root.js"></script>' 30 31 32def strip(html: str) -> tuple[str, int]: 33 """Remove third-party scripts and inline pathToRoot; return the new html and how many went.""" 34 removed = 0 35 root = "" 36 m = PATH_TO_ROOT.search(html) 37 if m: 38 root = m.group(1) 39 html = PATH_TO_ROOT.sub("", html, count=1) 40 html = re.sub(r"<body([^>]*)>", rf'<body\1 data-path-to-root="{root}">', html, count=1) 41 html = html.replace("</head>", SHIM.format(root=root) + "</head>", 1) 42 43 def drop(_match: re.Match[str]) -> str: 44 nonlocal removed 45 removed += 1 46 return "" 47 48 return EXTERNAL_SCRIPT.sub(drop, html), removed 49 50 51SHIM_JS = """// Restores the `pathToRoot` global scaladoc's own scripts expect, without an inline 52// <script> — read from <body data-path-to-root>, so the page keeps script-src 'self'. MIP-0044. 53var pathToRoot = document.body.getAttribute("data-path-to-root") || ""; 54""" 55 56 57def check(dirs: list[Path]) -> int: 58 """Report any real external <script src> left in the tree, using EXTERNAL_SCRIPT itself. 59 60 The workflow used to grep for the looser `src="http`, which matched this file's own 61 documentation once pdoc rendered it — escaped text about script tags, not a script tag. 62 """ 63 offenders = [ 64 f 65 for d in dirs 66 if d.is_dir() 67 for f in d.rglob("*.html") 68 if EXTERNAL_SCRIPT.search(f.read_text(encoding="utf-8", errors="ignore")) 69 ] 70 for f in offenders[:5]: 71 print(f"external script src survives in {f}", file=sys.stderr) 72 if offenders: 73 print(f"{len(offenders)} file(s) would violate the site CSP", file=sys.stderr) 74 return 1 75 print("no external script src in the generated tree") 76 return 0 77 78 79def process(dirs: list[Path]) -> int: 80 total_files = total_removed = 0 81 for d in dirs: 82 if not d.is_dir(): 83 print(f"strip_external_scripts: no such directory: {d}", file=sys.stderr) 84 return 1 85 (d / "scripts").mkdir(parents=True, exist_ok=True) 86 (d / "scripts" / "path-to-root.js").write_text(SHIM_JS, encoding="utf-8") 87 for f in d.rglob("*.html"): 88 html = f.read_text(encoding="utf-8", errors="ignore") 89 new, removed = strip(html) 90 if new != html: 91 f.write_text(new, encoding="utf-8") 92 total_files += 1 93 total_removed += removed 94 print(f"stripped {total_removed} external <script> tags across {total_files} html files") 95 return 0 96 97 98def self_test() -> int: 99 fails = 0 100 101 def ok(got, want, label): 102 nonlocal fails 103 if got == want: 104 print(f" ok {label}") 105 else: 106 fails += 1 107 print(f" FAIL {label} — got {got!r}, want {want!r}") 108 109 page = ( 110 '<html><head><script type="text/javascript" src="https://d3js.org/d3.v6.min.js"></script>' 111 '<script src="https://scastie.scala-lang.org/embedded.js"></script>' 112 '<script src="scripts/ux.js"></script></head>' 113 '<body class="theme"><script>var pathToRoot = "../";</script>hi</body></html>' 114 ) 115 out, removed = strip(page) 116 ok(removed, 2, "both third-party scripts are removed") 117 ok("d3js.org" in out, False, "no https:// script src survives") 118 ok('src="scripts/ux.js"' in out, True, "the doc's own same-origin scripts are kept") 119 ok("var pathToRoot" in out, False, "the inline pathToRoot script is gone") 120 ok('data-path-to-root="../"' in out, True, "pathToRoot is preserved as a data attribute") 121 ok('class="theme"' in out, True, "existing body attributes survive the rewrite") 122 ok(out.count("path-to-root.js"), 1, "the same-origin shim is linked once") 123 ok(strip("<html><body>plain</body></html>")[1], 0, "a page with no scripts is untouched") 124 # a protocol-relative or http src must go too 125 ok( 126 strip('<script src="http://x.example/a.js"></script>')[1], 127 1, 128 "plain http src is removed as well", 129 ) 130 import tempfile 131 132 with tempfile.TemporaryDirectory() as tmp: 133 d = Path(tmp) 134 (d / "clean.html").write_text("<html><body>no scripts</body></html>", encoding="utf-8") 135 ok(check([d]), 0, "--check passes a tree with no external script") 136 # What pdoc emits for this very file: text *about* script tags, with < escaped. 137 (d / "pdoc.html").write_text( 138 '<html><body><code>EXTERNAL_SCRIPT = <script src="https://x/a.js"></code>' 139 "<p>strips <script src="https://..."> tags</p></body></html>", 140 encoding="utf-8", 141 ) 142 ok(check([d]), 0, "documentation about script tags is not mistaken for one") 143 (d / "real.html").write_text( 144 '<html><head><script src="https://d3js.org/d3.v6.min.js"></script></head></html>', 145 encoding="utf-8", 146 ) 147 ok(check([d]), 1, "a real external script tag is still caught") 148 149 if fails: 150 print(f"strip_external_scripts self-test: {fails} failure(s)", file=sys.stderr) 151 return 1 152 print("strip_external_scripts self-test: ok") 153 return 0 154 155 156def main(argv: list[str] | None = None) -> int: 157 ap = argparse.ArgumentParser( 158 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 159 ) 160 ap.add_argument("dirs", nargs="*", type=Path) 161 ap.add_argument("--self-test", action="store_true") 162 ap.add_argument("--check", action="store_true", help="report survivors, change nothing") 163 args = ap.parse_args(argv) 164 if args.self_test: 165 return self_test() 166 if not args.dirs: 167 ap.print_help() 168 return 2 169 return check(args.dirs) if args.check else process(args.dirs) 170 171 172if __name__ == "__main__": 173 sys.exit(main())
EXTERNAL_SCRIPT =
re.compile('<script[^>]*\\ssrc=["\']https?://[^"\']*["\'][^>]*>\\s*</script>', re.IGNORECASE)
PATH_TO_ROOT =
re.compile('<script[^>]*>\\s*var\\s+pathToRoot\\s*=\\s*["\']([^"\']*)["\']\\s*;?\\s*</script>', re.IGNORECASE)
SHIM =
'<script src="{root}scripts/path-to-root.js"></script>'
def
strip(html: str) -> tuple[str, int]:
33def strip(html: str) -> tuple[str, int]: 34 """Remove third-party scripts and inline pathToRoot; return the new html and how many went.""" 35 removed = 0 36 root = "" 37 m = PATH_TO_ROOT.search(html) 38 if m: 39 root = m.group(1) 40 html = PATH_TO_ROOT.sub("", html, count=1) 41 html = re.sub(r"<body([^>]*)>", rf'<body\1 data-path-to-root="{root}">', html, count=1) 42 html = html.replace("</head>", SHIM.format(root=root) + "</head>", 1) 43 44 def drop(_match: re.Match[str]) -> str: 45 nonlocal removed 46 removed += 1 47 return "" 48 49 return EXTERNAL_SCRIPT.sub(drop, html), removed
Remove third-party scripts and inline pathToRoot; return the new html and how many went.
SHIM_JS =
'// Restores the `pathToRoot` global scaladoc\'s own scripts expect, without an inline\n// <script> — read from <body data-path-to-root>, so the page keeps script-src \'self\'. MIP-0044.\nvar pathToRoot = document.body.getAttribute("data-path-to-root") || "";\n'
def
check(dirs: list[pathlib.Path]) -> int:
58def check(dirs: list[Path]) -> int: 59 """Report any real external <script src> left in the tree, using EXTERNAL_SCRIPT itself. 60 61 The workflow used to grep for the looser `src="http`, which matched this file's own 62 documentation once pdoc rendered it — escaped text about script tags, not a script tag. 63 """ 64 offenders = [ 65 f 66 for d in dirs 67 if d.is_dir() 68 for f in d.rglob("*.html") 69 if EXTERNAL_SCRIPT.search(f.read_text(encoding="utf-8", errors="ignore")) 70 ] 71 for f in offenders[:5]: 72 print(f"external script src survives in {f}", file=sys.stderr) 73 if offenders: 74 print(f"{len(offenders)} file(s) would violate the site CSP", file=sys.stderr) 75 return 1 76 print("no external script src in the generated tree") 77 return 0
Report any real external