Skip to content

Commit 74cb95d

Browse files
committed
Backport wabac changes up to wabac.js 2.26.6
1 parent 780a267 commit 74cb95d

4 files changed

Lines changed: 141 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Changed
1616

1717
- Upgrade Python and Javascript dependencies (especially wombat 3.10.6) (#319, #328)
18+
- Backport wabac changes up to wabac.js 2.26.6 (#330)
1819
- Migrate `javascript/` from Yarn Classic to Yarn Berry (#320)
1920
- Test bogus Content-Length HTTP header behavior (#318)
2021

rules/rules.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
# ones) but just rewriting to proper path.
77
#
88
# This file is in sync with content at commit
9-
# https://github.com/webrecorder/wabac.js/commit/f62756661d06e721bc57ff25199c73ce51227916
10-
# from October 29, 2025
9+
# https://github.com/webrecorder/wabac.js/commit/067214314f86249ecc7610bf8ac14b7f54c5f285
10+
# from Apr 29, 2026
1111
#
1212
# This file should be updated at every release of scraperlib
1313
#

src/zimscraperlib/rewriting/js.py

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
1515
This code is based on https://github.com/webrecorder/wabac.js/blob/main/src/rewrite/jsrewriter.ts
1616
Last backport of upstream changes is from wabac.js commit:
17-
Feb 20, 2026 - 25061cb53ff113d5cff28f2f1354819f6c41034b
17+
Jul 30, 2026 - 0564e36993f4044f17119e71dd7b2892512ee59c
1818
"""
1919

2020
import re
2121
from collections.abc import Callable, Iterable
22-
from typing import Any
22+
from typing import Any, Literal
2323

2424
from zimscraperlib.rewriting.rx_replacer import (
2525
RxRewriter,
@@ -33,6 +33,12 @@
3333
from zimscraperlib.rewriting.url_rewriting import ArticleUrlRewriter, ZimPath
3434

3535
# The regex used to rewrite `import ...` in module code.
36+
IMPORT_RX = re.compile(
37+
r"""^\s*?import\s*?[{"'*]""",
38+
)
39+
EXPORT_RX = re.compile(
40+
r"""\s*?export\s*?({([\s\w,$\n]+?)}[\s;]*|default|class)\s+""", re.MULTILINE
41+
)
3642
IMPORT_EXPORT_MATCH_RX = re.compile(
3743
r"""(^|;)\s*?(?:im|ex)port(?:['"\s]*(?:[\w*${}\s,]+from\s*)?['"\s]?['"\s])(?:.*?)['"\s]""",
3844
)
@@ -56,12 +62,16 @@
5662
"opener",
5763
]
5864

59-
GLOBALS_RX = re.compile(
65+
WORKER_GLOBAL_OVERRIDES = ["globalThis", "self", "location"]
66+
67+
GLOBALS_CONCAT_STR = (
6068
r"("
6169
+ "|".join([r"(?:^|[^$.])\b" + x + r"\b(?:$|[^$])" for x in GLOBAL_OVERRIDES])
6270
+ ")"
6371
)
6472

73+
GLOBALS_RX = re.compile(GLOBALS_CONCAT_STR)
74+
6575
# This will replace `this` in code. The `_____WB$wombat$check$this$function_____`
6676
# will "see" with wombat and may return a "wrapper" around `this`
6777
this_rw = "_____WB$wombat$check$this$function_____(this)"
@@ -84,7 +94,7 @@ def remove_args_if_strict(
8494
return target
8595

8696

87-
def add_suffix_non_prop(suffix: str) -> TransformationAction:
97+
def add_suffix(suffix: str) -> TransformationAction:
8898
"""
8999
Create a rewrite_function which add a `suffix` to the match str.
90100
The suffix is added only if the match is not preceded by `.` or `$`.
@@ -108,7 +118,7 @@ def replace_this() -> TransformationAction:
108118
return replace("this", this_rw)
109119

110120

111-
def replace_this_non_prop() -> TransformationAction:
121+
def replace_this_prop() -> TransformationAction:
112122
"""
113123
Create a rewrite_function replacing "this" by `this_rw`.
114124
@@ -117,12 +127,12 @@ def replace_this_non_prop() -> TransformationAction:
117127

118128
def f(m_object: re.Match[str], _opts: dict[str, Any] | None) -> str:
119129
offset = m_object.start()
120-
prev = m_object.string[offset - 1] if offset > 0 else ""
121-
if prev == "\n":
130+
first_char = m_object.string[offset - 1] if offset > 0 else ""
131+
if first_char == "\n":
122132
# This detection of new line is probably buggy, plus it is hard to get the
123133
# intent of this, see https://github.com/openzim/warc2zim/issues/410
124134
return m_object[0].replace("this", ";" + this_rw)
125-
if prev not in ".$":
135+
if first_char not in ".$":
126136
return m_object[0].replace("this", this_rw)
127137
return m_object[0]
128138

@@ -186,10 +196,14 @@ def create_js_rules() -> list[TransformationRule]:
186196
(re.compile(r"var\s+self"), replace("var", "let")),
187197
# rewriting `.postMessage` -> `__WB_pmw(self).postMessage`
188198
(re.compile(r"\.postMessage\b\("), add_prefix(".__WB_pmw(self)")),
199+
# Avoid doing the below rewrite for `let/const` assignments,
200+
# which will break the scoping
201+
# See: https://github.com/webrecorder/wabac.js/issues/336
202+
(re.compile(r"(?:let|const)\s+location\s*="), m2str(lambda x: x)),
189203
# rewriting `location = ` to custom expression `(...).href =` assignement
190204
(
191205
re.compile(r"(?:^|[^$.+*/%^-])\s?\blocation\b\s*[=]\s*(?![\s\d=>])"),
192-
add_suffix_non_prop(check_loc),
206+
add_suffix(check_loc),
193207
),
194208
# rewriting `return this`
195209
(re.compile(r"\breturn\s+this\b\s*(?![\s\w.$])"), replace_this()),
@@ -199,7 +213,7 @@ def create_js_rules() -> list[TransformationRule]:
199213
re.compile(
200214
rf"[^$.]\s?\bthis\b(?=(?:\.(?:{'|'.join(GLOBAL_OVERRIDES)})\b))"
201215
),
202-
replace_this_non_prop(),
216+
replace_this_prop(),
203217
),
204218
# rewrite `= this` or `, this`
205219
(re.compile(r"[=,]\s*\bthis\b\s*(?![\s\w:.$])"), replace_this()),
@@ -239,7 +253,7 @@ def __init__(
239253
):
240254
super().__init__(None)
241255
self.first_buff = self._init_local_declaration(GLOBAL_OVERRIDES)
242-
self.last_buff = "\n}"
256+
self.last_buff = "\n\n}"
243257
self.url_rewriter = url_rewriter
244258
self.notify_js_module = notify_js_module
245259
self.base_href = base_href
@@ -277,29 +291,20 @@ def _get_module_decl(self, local_decls: Iterable[str]) -> str:
277291
f"""import {{ {", ".join(local_decls)} }} from "{wb_module_decl_url}";\n"""
278292
)
279293

280-
def _detect_strict_mode(self, text: str) -> bool:
294+
def _detect_module_or_strict(self, text: str) -> Literal["strict", "module", "lax"]:
281295
"""
282-
Detect if the JavaScript code is in strict mode.
283-
284-
Returns True if the code contains:
285-
- "use strict"; directive
286-
- import statements
287-
- export statements
288-
- class declarations
296+
Detect if the JavaScript code mode.
289297
"""
290-
# Check for "use strict"; directive
291-
if '"use strict";' in text or "'use strict';" in text:
292-
return True
298+
if "import" in text and IMPORT_RX.search(text):
299+
return "module"
293300

294-
# Check for import or export statements
295-
if re.search(r"(?:^|\s)(?:im|ex)port\s+", text):
296-
return True
301+
if '"use strict";' in text:
302+
return "strict"
297303

298-
# Check for class declaration
299-
if re.search(r"\bclass\s+", text):
300-
return True
304+
if "export" in text and EXPORT_RX.search(text):
305+
return "module"
301306

302-
return False
307+
return "lax"
303308

304309
def rewrite(self, text: str | bytes, opts: dict[str, Any] | None = None) -> str:
305310
"""
@@ -310,18 +315,24 @@ def rewrite(self, text: str | bytes, opts: dict[str, Any] | None = None) -> str:
310315

311316
opts = opts or {}
312317

313-
is_module = opts.get("isModule", False)
314-
315-
# Detect and set strict mode
316-
# Modules are always strict mode
317-
if is_module:
318+
if "isModule" not in opts:
319+
match self._detect_module_or_strict(text):
320+
case "module":
321+
opts["isModule"] = True
322+
opts["isStrict"] = True
323+
case "strict":
324+
opts["isModule"] = False
325+
opts["isStrict"] = True
326+
case _:
327+
pass
328+
329+
elif opts["isModule"]:
318330
opts["isStrict"] = True
319-
elif "isStrict" not in opts: # pragma: no branch
320-
# Detect strict mode from the code itself
321-
opts["isStrict"] = self._detect_strict_mode(text)
322331

323332
rules = REWRITE_JS_RULES[:]
324333

334+
is_module = opts.get("isModule", False)
335+
325336
if is_module:
326337
rules.append(self._get_esm_import_rule())
327338

@@ -332,12 +343,16 @@ def rewrite(self, text: str | bytes, opts: dict[str, Any] | None = None) -> str:
332343
if is_module:
333344
return self._get_module_decl(GLOBAL_OVERRIDES) + new_text
334345

335-
if GLOBALS_RX.search(text):
336-
new_text = self.first_buff + new_text + self.last_buff
346+
wrap_globals = GLOBALS_RX.search(text) is not None
337347

338348
if opts.get("inline", False):
339349
new_text = new_text.replace("\n", " ")
340350

351+
# This is not totally correctly handling globals,
352+
# see https://github.com/openzim/python-scraperlib/issues/329
353+
if wrap_globals:
354+
new_text = self.first_buff + new_text + self.last_buff
355+
341356
return new_text
342357

343358
def _get_esm_import_rule(self) -> TransformationRule:

tests/rewriting/test_js_rewriting.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,48 @@ def wrap_script(text: str) -> str:
173173
"\n"
174174
f"{text}"
175175
"\n"
176+
"\n"
177+
"}"
178+
)
179+
180+
181+
class WrappedNoLocationOverrideTestContent(ContentForTests):
182+
def __init__(
183+
self,
184+
input_: str | bytes,
185+
expected: str | bytes | None = None,
186+
article_url: str = "https://kiwix.org",
187+
) -> None:
188+
super().__init__(input_=input_, expected=expected, article_url=article_url)
189+
self.expected = self.wrap_script(self.expected_str)
190+
191+
@staticmethod
192+
def wrap_script(text: str) -> str:
193+
"""
194+
A small wrapper to help generate the expected content.
195+
196+
JsRewriter must add this local definition around all js code (when we access on
197+
of the local varibles)
198+
"""
199+
return (
200+
"var _____WB$wombat$assign$function_____ = function(name) {return (self."
201+
"_wb_wombat && self._wb_wombat.local_init && self._wb_wombat.local_init"
202+
"(name)) || self[name]; };\n"
203+
"if (!self.__WB_pmw) { self.__WB_pmw = function(obj) { this.__WB_source ="
204+
" obj; return this; } }\n"
205+
"{\n"
206+
'let window = _____WB$wombat$assign$function_____("window");\n'
207+
'let globalThis = _____WB$wombat$assign$function_____("globalThis");\n'
208+
'let self = _____WB$wombat$assign$function_____("self");\n'
209+
'let document = _____WB$wombat$assign$function_____("document");\n'
210+
'let top = _____WB$wombat$assign$function_____("top");\n'
211+
'let parent = _____WB$wombat$assign$function_____("parent");\n'
212+
'let frames = _____WB$wombat$assign$function_____("frames");\n'
213+
'let opener = _____WB$wombat$assign$function_____("opener");\n'
214+
"let arguments;\n"
215+
"\n"
216+
f"{text}"
217+
"\n"
176218
"}"
177219
)
178220

@@ -235,6 +277,27 @@ class A {}
235277
"self.__WB_check_loc(location, [])) || {}).maybeHref "
236278
"""= "http://example.com/2" """,
237279
),
280+
# Ensure these *don't* get rewritten
281+
WrappedTestContent(
282+
input_='function() { const location = "http://example.com/" }',
283+
),
284+
WrappedTestContent(
285+
input_='function() { let location = "http://example.com/"; }',
286+
),
287+
WrappedTestContent(
288+
input_="function() { const location = foo.location }",
289+
),
290+
WrappedTestContent(
291+
input_="function() { const location = window.location }",
292+
),
293+
# this. is still rewritten
294+
WrappedTestContent(
295+
input_="function() { const location = this.location; }",
296+
expected=(
297+
"function() { const location ="
298+
" _____WB$wombat$check$this$function_____(this).location; }"
299+
),
300+
),
238301
WrappedTestContent(input_=" var self ", expected=" let self "),
239302
]
240303
)
@@ -389,7 +452,19 @@ def rewrite_import_content(request: pytest.FixtureRequest):
389452
yield request.param
390453

391454

392-
def test_import_rewrite(rewrite_import_content: ImportTestContent):
455+
def test_import_rewrite_module_detect(rewrite_import_content: ImportTestContent):
456+
url_rewriter = ArticleUrlRewriter(
457+
article_url=HttpUrl(rewrite_import_content.article_url)
458+
)
459+
assert (
460+
JsRewriter(
461+
url_rewriter=url_rewriter, base_href=None, notify_js_module=None
462+
).rewrite(rewrite_import_content.input_str)
463+
== rewrite_import_content.expected_str
464+
)
465+
466+
467+
def test_import_rewrite_force_module(rewrite_import_content: ImportTestContent):
393468
url_rewriter = ArticleUrlRewriter(
394469
article_url=HttpUrl(rewrite_import_content.article_url)
395470
)
@@ -401,6 +476,14 @@ def test_import_rewrite(rewrite_import_content: ImportTestContent):
401476
)
402477

403478

479+
# Check that forcing isModule to False works as expected
480+
def test_import_rewrite_force_not_module(simple_js_rewriter: JsRewriter):
481+
assert (
482+
simple_js_rewriter.rewrite("""import "foo";""", opts={"isModule": False})
483+
== """import "foo";"""
484+
)
485+
486+
404487
@pytest.fixture(
405488
params=[
406489
"return this.abc",

0 commit comments

Comments
 (0)