Skip to content

Carry a script's top-level declarations across the wombat block - #335

Open
epheterson wants to merge 3 commits into
openzim:mainfrom
epheterson:fix/329-block-scoped-globals
Open

Carry a script's top-level declarations across the wombat block#335
epheterson wants to merge 3 commits into
openzim:mainfrom
epheterson:fix/329-block-scoped-globals

Conversation

@epheterson

Copy link
Copy Markdown

Fixes #329.

Ports wabac.js's parseGlobals (src/rewrite/jsrewriter.ts), keeping its variable names and matching its output byte for byte:

  • let x is pre-declared before the block and its keyword removed inside, so the assignment writes the outer binding.
  • const x and class X go out through self.___WB_const_x and are re-declared after the block, and the carrier deleted.
  • A name that shadows a wombat global is left to the script, and that global is dropped from the wrapper.
  • A top-level document.write() still gets its document.close().
  • Nested scopes are untouched, and a script that cannot be parsed is wrapped exactly as before.

Parser. Behind rewriting/js_ast.py, one function, so it is one import to change. I proposed esprima earlier in this issue and then measured it: it is ES2017 and refuses optional chaining, class fields and for await, all ordinary in shipped code. Since a parse failure falls back to the old behaviour, that would restore the bug on the modern pages most likely to hit it. tree-sitter parses all three, tolerates broken input, and ships wheels. If the dependency is unwelcome, it can be made optional with a fallback, or replaced with a top-level-only tokenizer.

Tests. tests/rewriting/test_js_globals.py ports every case in wabac.js's test/rewriteJS.ts that exercises this branch, expected strings kept verbatim: the combined class/const/let/var fixture, several declarators on one line, let+var+const, document.write alone and with a carried const, a shadowed global, nested scopes, and an unparsable script. Plus modern syntax, which wabac's suite predates.

Two existing fixtures in test_js_rewriting.py encoded the old behaviour and are updated. One is worth flagging: var self was emitting let self twice in the same block, a SyntaxError that killed the whole script.

Suite: 504 passed.

Found on nerdfonts.com/cheat-sheet, which declares const glyphs in one inline script and reads it from another: the table was in the ZIM, rewritten correctly, and unreachable. I have not run this against a large corpus of real captures; if you have a fixture set for that, point me at it.

The rewriter wraps a script in a block so wombat can shadow window,
document and the rest. A block is a scope, so every top-level const, let
and class in that script becomes block-scoped with it and stops being
visible to any other script on the page. The script still runs, so
nothing reports an error; the page is simply wrong.

This ports wabac.js's approach (parseGlobals in src/rewrite/jsrewriter.ts),
keeping its names and its output byte for byte:

  * let is declared before the block and its keyword removed inside, so
    the assignment writes the outer binding
  * const and class are handed out through self.___WB_const_<name> and
    re-declared after the block, and the carrier deleted
  * a name that shadows one of the wombat globals is left to the script
    and that global is dropped from the wrapper
  * a top-level document.write() still gets its document.close()

Parsing is behind rewriting/js_ast.py so the parser is one import to
change. tree-sitter rather than a pure-Python parser because the input is
whatever the live web served: esprima is ES2017 and refuses optional
chaining, class fields and for-await, all ordinary in shipped code, and a
parse failure silently restores the bug. A script that still cannot be
parsed is wrapped exactly as before rather than corrupted.

Two existing fixtures encoded the old behaviour and are updated. One of
them, a script declaring 'var self', was emitting 'let self' twice in the
same block: a SyntaxError that killed the whole script.

Fixes openzim#329
Adds the remaining cases from wabac.js's test/rewriteJS.ts that exercise
this branch: several declarators on one line, let+var+const together, and
a carried const alongside a top-level document.write().

The AST walk now sits inside the same try/except as the parse, matching
wabac.js, which wraps its whole parseGlobals call. Nothing here may throw
into a scrape.

Adds the changelog entry.
@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.52459% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.48%. Comparing base (994354b) to head (bc2b056).

Files with missing lines Patch % Lines
src/zimscraperlib/rewriting/js_ast.py 85.52% 5 Missing and 6 partials ⚠️
src/zimscraperlib/rewriting/js.py 93.47% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##              main     #335      +/-   ##
===========================================
- Coverage   100.00%   99.48%   -0.52%     
===========================================
  Files           41       42       +1     
  Lines         2572     2693     +121     
  Branches       366      397      +31     
===========================================
+ Hits          2572     2679     +107     
- Misses           0        6       +6     
- Partials         0        8       +8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@benoit74
benoit74 self-requested a review September 8, 2026 07:14

@benoit74 benoit74 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very promising, thank you.

I like the choice of tree-sitter and it is compatible with our expectations. We do have a separate issue to make library more dynamic in terms of dependencies one which to have installed (#244), so it is clearly outside this PR scope.

Please fix CI: linting QA is failing + code coverage is not at 100% anymore ; regarding code coverage, I'm OK to consider some code does not need to be covered if it is just too painful and/or useless to test, but this obviously needs to be justified.

Linting: imports moved to the top of the file, unused noqa directives
dropped, one long line wrapped. tree-sitter is a declared dependency, so
the guarded import it had was neither needed nor lintable.

Types: pyright strict had no types for the parser because nothing
annotated the Node parameters. Annotated, and _text now takes an optional
node and reads a missing one as empty, so its callers ask for optional
fields without a guard at each site.

Coverage is back to 100% with branches on both files. Two of the
uncovered branches were dead code rather than untested code, and are
gone: a lexical_declaration is only ever const or let (a "using"
declaration is its own node type in the grammar), and a member_expression
always has both of its fields. parse_top_level's own try/except is the
net for any parser surprise, and that net is now tested directly.

The rest is covered by four new cases: a destructured declaration is left
alone, an inline script stays on one line, a script the parser cannot
read is wrapped unchanged, and a parser that raises answers None rather
than throwing into a scrape.
@epheterson

Copy link
Copy Markdown
Author

Both fixed, thanks for the quick look.

Linting — imports at the top, unused noqa directives gone, one long line wrapped.

Coverage — back to 100% with branches on both files, and nothing is excluded. Two of the uncovered branches turned out to be dead code rather than untested code, so they are gone instead of tested: a lexical_declaration is only ever const or let (a using declaration is its own node type in the grammar), and a member_expression always has both of its fields. parse_top_level wraps everything in one try/except, the way wabac.js wraps parseGlobals, and that net is now tested directly with a parser that raises. The rest is four new cases: a destructured declaration is left alone, an inline script stays on one line, a script the parser cannot read is wrapped unchanged, and a raising parser answers None rather than throwing into a scrape.

Types — pyright strict had no types for the parser at all, since nothing annotated the Node parameters. Annotated now, and _text takes an optional node and reads a missing one as empty so its callers can ask for an optional field without a guard at each site. pyright reports 0 errors on both files.

Good to know about #244; happy to move the dependency behind whatever shape that lands on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Correctly wrap JS globals when rewriting JS code

2 participants