[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[tor-commits] [Git][tpo/applications/tor-browser-build][main] Bug 41800: Add a script for the new offline manual.



Title: GitLab

henry pushed to branch main at The Tor Project / Applications / tor-browser-build

Commits:

  • fee9e7b2
    by Henry Wilkes at 2026-07-20T14:55:57+00:00
    Bug 41800: Add a script for the new offline manual.
    

2 changed files:

Changes:

  • projects/manual/package_marble_build_artifacts.py
    1
    +import html
    
    2
    +import html.parser
    
    3
    +import os
    
    4
    +import re
    
    5
    +import shutil
    
    6
    +from urllib.parse import quote_plus, urlparse
    
    7
    +
    
    8
    +DEFAULT_LOCALE = "en"
    
    9
    +ORIG_HTML_PATH = "offline/tor-browser/index.html"
    
    10
    +
    
    11
    +# The '#' will be replaced with the locale name.
    
    12
    +HTML_NAME_TEMPLATE = "aboutManual-#.html"
    
    13
    +ASSETS_DIR_NAME = "assets"
    
    14
    +
    
    15
    +
    
    16
    +class ManualParser(html.parser.HTMLParser):
    
    17
    +    """
    
    18
    +    A parser for the Tor Browser manual. Extracts the <body> of the input HTML,
    
    19
    +    swaps attributes, and writes out a new HTML.
    
    20
    +    """
    
    21
    +
    
    22
    +    _CHROME_URI_BASE = "chrome://browser/content/aboutmanual/"
    
    23
    +    _CHROME_ASSET_PATH = _CHROME_URI_BASE + ASSETS_DIR_NAME + "/"
    
    24
    +    _CHROME_JS = _CHROME_URI_BASE + "aboutManual.js"
    
    25
    +
    
    26
    +    # The base for external support pages.
    
    27
    +    _SUPPORT_URI_BASE = "https://support.torproject.org"
    
    28
    +
    
    29
    +    # The name of online page that the offline manual is derived from (the
    
    30
    +    # `data-olm-product` value).
    
    31
    +    # In our case, the offline manual is derived from
    
    32
    +    # https://support.torproject.org/tor-browser/
    
    33
    +    _TOP_PAGE_NAME = "tor-browser"
    
    34
    +
    
    35
    +    # The `id` attribute value that points to the top-page.
    
    36
    +    _TOP_ID = "index"
    
    37
    +
    
    38
    +    # The pages that are covered by the offline manual.
    
    39
    +    # The first value in the tuple specifies the paths that are covered. Every
    
    40
    +    # path that starts with this is considered internal.
    
    41
    +    # The second value indicates whether an anchor reference to this page should
    
    42
    +    # include the start of the path or not.
    
    43
    +    _INTERNAL_PAGES = [
    
    44
    +        ([_TOP_PAGE_NAME], False),
    
    45
    +        (["get-in-touch", "bug-or-feedback"], True),
    
    46
    +        (["get-in-touch", "user-support"], True),
    
    47
    +    ]
    
    48
    +
    
    49
    +    # The content security policy we want for the output.
    
    50
    +    _CSP = "default-src 'none'; style-src chrome:; img-src chrome:; script-src chrome:"
    
    51
    +
    
    52
    +    # Elements that should not appear in the <body>.
    
    53
    +    _FORBIDDEN_BODY_TAGS = [
    
    54
    +        "script",
    
    55
    +        "noscript",
    
    56
    +        "meta",
    
    57
    +        "link",
    
    58
    +        "html",
    
    59
    +        "head",
    
    60
    +        "body",
    
    61
    +    ]
    
    62
    +
    
    63
    +    # HTML elements that are void elements.
    
    64
    +    _VOID_HTML_TAGS = [
    
    65
    +        "area",
    
    66
    +        "base",
    
    67
    +        "br",
    
    68
    +        "col",
    
    69
    +        "embed",
    
    70
    +        "hr",
    
    71
    +        "img",
    
    72
    +        "input",
    
    73
    +        "link",
    
    74
    +        "meta",
    
    75
    +        "source",
    
    76
    +        "track",
    
    77
    +        "wbr",
    
    78
    +    ]
    
    79
    +
    
    80
    +    _VALID_DIR_VALS = ["rtl", "ltr"]
    
    81
    +
    
    82
    +    def __init__(
    
    83
    +        self, locale: str, html_path: str, top_dir: str, all_locales: list[str]
    
    84
    +    ) -> None:
    
    85
    +        """
    
    86
    +        Create a new parser for the offline manual.
    
    87
    +        """
    
    88
    +        super().__init__(convert_charrefs=True)
    
    89
    +        self._locale = locale
    
    90
    +        self._html_path = html_path
    
    91
    +        self._html_dir = os.path.dirname(html_path)
    
    92
    +        self._top_dir = top_dir
    
    93
    +        self._all_locales = all_locales
    
    94
    +
    
    95
    +        # As we parse the HTML, we track which tags we are currently inside, as
    
    96
    +        # well as some other details about those tags.
    
    97
    +        # The last element in the list is the most recent tag.
    
    98
    +        self._ancestor_details: list[dict[str, str]] = []
    
    99
    +
    
    100
    +        # The <body> content we should write to the output.
    
    101
    +        self._body_content = ""
    
    102
    +
    
    103
    +        # Whether we are currently below the <body> tag.
    
    104
    +        self._in_body = False
    
    105
    +        # Whether we have encountered the <body> tag at some point.
    
    106
    +        self._visited_body = False
    
    107
    +        # The lang attribute found on the <html> element.
    
    108
    +        self._lang: None | str = None
    
    109
    +        # The dir attribute found on the <html> element.
    
    110
    +        self._dir: None | str = None
    
    111
    +        # The <title> content we have found.
    
    112
    +        self._title = ""
    
    113
    +        # The stylesheet references to include.
    
    114
    +        self._style_hrefs: list[str] = []
    
    115
    +        # The found asset paths.
    
    116
    +        self._asset_paths: dict[str, str] = {}
    
    117
    +        # A list of all internal references we want to verify exist.
    
    118
    +        # The first member of the tuple gives the document position where the
    
    119
    +        # `href` was found.
    
    120
    +        # The second member of the tuple gives the `href` value.
    
    121
    +        self._internal_hrefs: list[tuple[tuple[int, int], str]] = []
    
    122
    +        # The list of all `id` attributes we found in the <body>.
    
    123
    +        self._all_ids: list[str] = []
    
    124
    +
    
    125
    +    def _error(self, message: str, pos: None | tuple[int, int] = None) -> ValueError:
    
    126
    +        """
    
    127
    +        Create a new parsing error.
    
    128
    +
    
    129
    +        :param message: The message to show.
    
    130
    +        :param pos: The document position associated with the error. Defaults to
    
    131
    +        the current position.
    
    132
    +
    
    133
    +        :returns: A new error instance.
    
    134
    +        """
    
    135
    +        if pos is None:
    
    136
    +            pos = self.getpos()
    
    137
    +        return ValueError(f"{self._html_path}: {pos}: {message}")
    
    138
    +
    
    139
    +    def _in_context(self, expected_ancestors: list[str]) -> bool:
    
    140
    +        """
    
    141
    +        Test if we are currently below the given ancestors.
    
    142
    +
    
    143
    +        :param expected_ancestors: The tag names for the ancestors we expect.
    
    144
    +
    
    145
    +        :returns: Whether we are directly below the specified ancestors.
    
    146
    +        """
    
    147
    +        return [d["tag"] for d in self._ancestor_details] == expected_ancestors
    
    148
    +
    
    149
    +    def _add_to_body(self, data: str) -> None:
    
    150
    +        """
    
    151
    +        Write to the body content.
    
    152
    +
    
    153
    +        :param data: The content to write.
    
    154
    +        """
    
    155
    +        self._body_content += data
    
    156
    +
    
    157
    +    def _add_end_tag_to_body(self, tag: str) -> None:
    
    158
    +        """
    
    159
    +        Write an ending tag to the body.
    
    160
    +
    
    161
    +        :param tag: The name of the tag to close.
    
    162
    +        """
    
    163
    +        self._add_to_body(f"</{tag}>")
    
    164
    +
    
    165
    +    def _add_start_tag_to_body(
    
    166
    +        self, tag: str, attrs: dict[str, None | str], self_close: bool
    
    167
    +    ) -> None:
    
    168
    +        """
    
    169
    +        Write a starting tag to the body.
    
    170
    +
    
    171
    +        :param tag: The name of the tag to open.
    
    172
    +        :param attrs: The attributes for this tag.
    
    173
    +        :param self_close: Whether this tag should be self-closed.
    
    174
    +        """
    
    175
    +        attr_part = ""
    
    176
    +        for name, value in attrs.items():
    
    177
    +            attr_part += f" {name}"
    
    178
    +            if value is not None:
    
    179
    +                attr_part += f'="{html.escape(value, quote=True)}"'
    
    180
    +        close_part = " />" if self_close else ">"
    
    181
    +        self._add_to_body(f"<{tag}{attr_part}{close_part}")
    
    182
    +
    
    183
    +    def _has_class(self, attrs: dict[str, None | str], class_name: str) -> bool:
    
    184
    +        """
    
    185
    +        Test if an element has a certain class.
    
    186
    +
    
    187
    +        :param attrs: The attributes for the element.
    
    188
    +        :param class_name: The name of the class to check for.
    
    189
    +
    
    190
    +        :returns: Whether the element has the given class.
    
    191
    +        """
    
    192
    +        class_val = attrs.get("class")
    
    193
    +        if not class_val:
    
    194
    +            return False
    
    195
    +        return class_name in class_val.split(" ")
    
    196
    +
    
    197
    +    def _handle_html_tag(self, attrs: dict[str, None | str]) -> None:
    
    198
    +        """
    
    199
    +        Process a html tag.
    
    200
    +
    
    201
    +        :param attrs: The tag's attributes.
    
    202
    +        """
    
    203
    +        if not self._in_context([]):
    
    204
    +            raise self._error("html has a parent")
    
    205
    +        if self._lang:
    
    206
    +            raise self._error("More than one html tag")
    
    207
    +        lang = attrs.get("lang")
    
    208
    +        if not lang:
    
    209
    +            raise self._error("html is missing a lang attribute")
    
    210
    +        if lang != self._locale:
    
    211
    +            raise self._error(f"Unexpected lang attribute: {lang}")
    
    212
    +        dir_val = attrs.get("dir")
    
    213
    +        if not dir_val:
    
    214
    +            raise self._error("html is missing a dir attribute")
    
    215
    +        if dir_val not in self._VALID_DIR_VALS:
    
    216
    +            raise self._error(f"Unexpected dir attribute: {dir_val}")
    
    217
    +
    
    218
    +        self._lang = lang
    
    219
    +        self._dir = dir_val
    
    220
    +
    
    221
    +    _NON_SAFE_ASSET_CHARS = re.compile(r"[^a-zA-Z0-9_.-]")
    
    222
    +
    
    223
    +    def _swap_asset_path(self, orig_path: str) -> str:
    
    224
    +        """
    
    225
    +        Swap an an asset path with a new one, and record the old path.
    
    226
    +
    
    227
    +        :param orig_path: The original path for the asset.
    
    228
    +
    
    229
    +        :returns: The new path to use.
    
    230
    +        """
    
    231
    +        # Parse as a URL so we can strip any queries.
    
    232
    +        url = urlparse(orig_path)
    
    233
    +        if url.scheme:
    
    234
    +            raise self._error(f"Unexpected asset path with a scheme: {url.scheme}")
    
    235
    +
    
    236
    +        if url.path.startswith("/"):
    
    237
    +            abs_path = os.path.abspath(os.path.join(self._top_dir, url.path[1:]))
    
    238
    +        else:
    
    239
    +            abs_path = os.path.abspath(os.path.join(self._html_dir, url.path))
    
    240
    +
    
    241
    +        if os.path.commonpath([self._top_dir, abs_path]) != self._top_dir:
    
    242
    +            raise self._error(f"References an asset outside {self._top_dir}")
    
    243
    +        if not os.path.isfile(abs_path):
    
    244
    +            raise self._error(f"References a non-existent asset: {abs_path}")
    
    245
    +
    
    246
    +        # Replace any (unexpected) non-safe characters with underscores.
    
    247
    +        asset_name = self._NON_SAFE_ASSET_CHARS.sub(
    
    248
    +            "_", os.path.relpath(abs_path, start=self._top_dir).replace("/", "__")
    
    249
    +        )
    
    250
    +
    
    251
    +        if asset_name not in self._asset_paths:
    
    252
    +            self._asset_paths[asset_name] = abs_path
    
    253
    +        elif self._asset_paths[asset_name] != abs_path:
    
    254
    +            raise self._error(f"More than one asset with the same name: {asset_name}")
    
    255
    +
    
    256
    +        return self._CHROME_ASSET_PATH + asset_name
    
    257
    +
    
    258
    +    def _handle_link_tag(self, attrs: dict[str, None | str]) -> None:
    
    259
    +        """
    
    260
    +        Process a link tag.
    
    261
    +
    
    262
    +        :param attrs: The tag's attributes.
    
    263
    +        """
    
    264
    +        if attrs.get("rel") != "stylesheet":
    
    265
    +            return
    
    266
    +
    
    267
    +        href = attrs.get("href")
    
    268
    +        if not href:
    
    269
    +            raise self._error("stylesheet link missing an href")
    
    270
    +
    
    271
    +        self._style_hrefs.append(self._swap_asset_path(href))
    
    272
    +
    
    273
    +    def _handle_body_tag(self) -> None:
    
    274
    +        """
    
    275
    +        Process a body tag.
    
    276
    +        """
    
    277
    +        if not self._in_context(["html"]):
    
    278
    +            raise self._error("Wrong context for the body tag")
    
    279
    +        if self._visited_body:
    
    280
    +            raise self._error("More than one body tag")
    
    281
    +        self._visited_body = True
    
    282
    +
    
    283
    +    def _handle_img_tag(self, attrs: dict[str, None | str]) -> None:
    
    284
    +        """
    
    285
    +        Process an img tag.
    
    286
    +
    
    287
    +        :param attrs: The tag's attributes, which may be modified.
    
    288
    +        """
    
    289
    +        if "srcset" in attrs:
    
    290
    +            # In principle, we should also map "srcset" as we map "src", but it
    
    291
    +            # is unexpected and not worth the parsing logic.
    
    292
    +            raise self._error("Unhandled srcset attribute")
    
    293
    +        src = attrs.get("src")
    
    294
    +        if not src:
    
    295
    +            return
    
    296
    +
    
    297
    +        attrs["src"] = self._swap_asset_path(src)
    
    298
    +
    
    299
    +    def _convert_relative_href(self, href: str) -> tuple[str, bool]:
    
    300
    +        """
    
    301
    +        Convert a relative href into a href that can be used in the final HTML.
    
    302
    +
    
    303
    +        :param href: The href to convert.
    
    304
    +
    
    305
    +        :returns: The new href, and whether the href is an internal link.
    
    306
    +        """
    
    307
    +        url = urlparse(href)
    
    308
    +        parts = [p for p in url.path.replace("../../", "").split("/") if p]
    
    309
    +        if not parts or ".." in parts or "." in parts:
    
    310
    +            raise self._error(f"Unexpected path: {href}")
    
    311
    +
    
    312
    +        if parts == [self._TOP_PAGE_NAME] and not url.fragment:
    
    313
    +            href = "#" + self._TOP_ID
    
    314
    +            return href, True
    
    315
    +
    
    316
    +        for page_path, keep_prefix in self._INTERNAL_PAGES:
    
    317
    +            cmp_len = len(page_path)
    
    318
    +            if parts[:cmp_len] == page_path:
    
    319
    +                if not keep_prefix:
    
    320
    +                    parts = parts[cmp_len:]
    
    321
    +                href = "#" + "__".join(parts)
    
    322
    +                if url.fragment:
    
    323
    +                    href += "___" + url.fragment
    
    324
    +                return href, True
    
    325
    +
    
    326
    +        href = self._SUPPORT_URI_BASE + "/"
    
    327
    +        if self._locale != DEFAULT_LOCALE:
    
    328
    +            href += quote_plus(self._locale) + "/"
    
    329
    +        href += "/".join(parts)
    
    330
    +        if url.fragment:
    
    331
    +            href += "#" + url.fragment
    
    332
    +        # Note: we don't expect a query parameter.
    
    333
    +        return href, False
    
    334
    +
    
    335
    +    def _handle_a_tag(
    
    336
    +        self, attrs: dict[str, str | None], details: dict[str, str]
    
    337
    +    ) -> None:
    
    338
    +        """
    
    339
    +        Process an anchor tag.
    
    340
    +
    
    341
    +        :param attrs: The tag's attributes, which may be modified.
    
    342
    +        :param details: The tag details to save, which may be modified.
    
    343
    +        """
    
    344
    +        # Always remove these attributes, and maybe replace them below.
    
    345
    +        # NOTE: Whilst, some "rel" attribute tokens would, in principle, be
    
    346
    +        # valid to keep, we don't expect it to be used in the origin for
    
    347
    +        # anything other than "noopener" or "norefferor" in the original
    
    348
    +        # document.
    
    349
    +        for attr_name in ("rel", "referrerpolicy", "target"):
    
    350
    +            if attr_name in attrs:
    
    351
    +                del attrs[attr_name]
    
    352
    +
    
    353
    +        # Change href if it includes relative path
    
    354
    +        href = attrs.get("href")
    
    355
    +        if not href:
    
    356
    +            return
    
    357
    +
    
    358
    +        is_external = False
    
    359
    +        is_internal = False
    
    360
    +        if href.startswith("http:") or href.startswith("https:"):
    
    361
    +            is_external = True
    
    362
    +        elif href.startswith("mailto:"):
    
    363
    +            # Do not allow mailto:
    
    364
    +            del attrs["href"]
    
    365
    +        elif href.startswith("#"):
    
    366
    +            # Keep as is.
    
    367
    +            is_internal = True
    
    368
    +        elif href.startswith("../../"):
    
    369
    +            href, is_internal = self._convert_relative_href(href)
    
    370
    +            attrs["href"] = href
    
    371
    +            if not is_internal:
    
    372
    +                is_external = True
    
    373
    +        else:
    
    374
    +            raise self._error(f"Unexpected href: {href}")
    
    375
    +
    
    376
    +        if is_external:
    
    377
    +            attrs["target"] = "_blank"  # Implies rel="noopener".
    
    378
    +            # Is noreferrer needed for external links within a chrome: document?
    
    379
    +            attrs["rel"] = "noreferrer"
    
    380
    +        if is_internal:
    
    381
    +            self._internal_hrefs.append((self.getpos(), href))
    
    382
    +
    
    383
    +        return
    
    384
    +
    
    385
    +    def _handle_div_tag(
    
    386
    +        self, attrs: dict[str, str | None], details: dict[str, str]
    
    387
    +    ) -> None:
    
    388
    +        """
    
    389
    +        Process a div tag.
    
    390
    +
    
    391
    +        :param attrs: The tag's attributes, which may be modified.
    
    392
    +        :param details: The tag details to save, which may be modified.
    
    393
    +        """
    
    394
    +        if self._has_class(attrs, "heading-anchor"):
    
    395
    +            # Convert the .heading-anchor id to include a prefix from its
    
    396
    +            # ancestor .olm-page element.
    
    397
    +            el_id = attrs.get("id")
    
    398
    +            if not el_id:
    
    399
    +                raise self._error("Heading is missing an id")
    
    400
    +            page_id = None
    
    401
    +            # Search for the nearest ancestor with a page-id.
    
    402
    +            for ancestor in reversed(self._ancestor_details):
    
    403
    +                page_id = ancestor.get("page-id")
    
    404
    +                if page_id:
    
    405
    +                    break
    
    406
    +            if not page_id:
    
    407
    +                raise self._error("Missing a page to use for the heading id")
    
    408
    +            attrs["id"] = page_id + "___" + el_id
    
    409
    +        elif self._has_class(attrs, "olm-page"):
    
    410
    +            el_id = attrs.get("id")
    
    411
    +            if not el_id:
    
    412
    +                raise self._error("olm-page is missing an id")
    
    413
    +            for other in self._ancestor_details:
    
    414
    +                if "page-id" in other:
    
    415
    +                    raise self._error("olm-page is below another")
    
    416
    +            details["page-id"] = el_id
    
    417
    +
    
    418
    +    def _save_id(self, tag: str, attrs: dict[str, str | None]) -> None:
    
    419
    +        """
    
    420
    +        Maybe save an `id` to the list of `id`s that can be referenced
    
    421
    +        internally.
    
    422
    +        """
    
    423
    +        attr_id = attrs.get("id")
    
    424
    +        if not attr_id or not self._in_body:
    
    425
    +            # Ignore
    
    426
    +            return
    
    427
    +        if attr_id in self._all_ids:
    
    428
    +            if tag == "input" and self._has_class(attrs, "toggler"):
    
    429
    +                # Known issue that *some* <input class="toggler" /> will use
    
    430
    +                # the same id as the heading element above it.
    
    431
    +                # TODO: tor-browser-build#41818. Remove once this element is
    
    432
    +                # removed.
    
    433
    +                del attrs["id"]
    
    434
    +                return
    
    435
    +            raise self._error(f"Duplicate id: {attr_id}")
    
    436
    +        self._all_ids.append(attr_id)
    
    437
    +
    
    438
    +    def _handle_tag(
    
    439
    +        self, tag: str, attr_pairs: list[tuple[str, str | None]], is_closed: bool
    
    440
    +    ) -> None:
    
    441
    +        """
    
    442
    +        Handle a start tag,
    
    443
    +
    
    444
    +        :param tag: The name of the tag.
    
    445
    +        :param attr_pairs: The list of all attributes for this tag.
    
    446
    +        :param is_closed: Whether the tag is self-closing.
    
    447
    +        """
    
    448
    +        details = {"tag": tag, "orig-tag": tag}
    
    449
    +
    
    450
    +        attrs = {}
    
    451
    +        for attr_name, attr_value in attr_pairs:
    
    452
    +            if attr_name in attrs:
    
    453
    +                raise self._error(f"{tag} has a duplicate attribute: {attr_name}")
    
    454
    +            attrs[attr_name] = attr_value
    
    455
    +
    
    456
    +        if not self._in_body:
    
    457
    +            if tag == "html":
    
    458
    +                self._handle_html_tag(attrs)
    
    459
    +            elif tag == "link":
    
    460
    +                self._handle_link_tag(attrs)
    
    461
    +            elif tag == "body":
    
    462
    +                self._handle_body_tag()
    
    463
    +                self._in_body = True
    
    464
    +        elif tag in self._FORBIDDEN_BODY_TAGS:
    
    465
    +            raise self._error(f"Unexpected {tag} tag in body")
    
    466
    +        elif tag == "img":
    
    467
    +            self._handle_img_tag(attrs)
    
    468
    +        elif tag == "a":
    
    469
    +            self._handle_a_tag(attrs, details)
    
    470
    +        elif tag == "div":
    
    471
    +            self._handle_div_tag(attrs, details)
    
    472
    +
    
    473
    +        # The tag may have changed.
    
    474
    +        tag = details["tag"]
    
    475
    +        self._save_id(tag, attrs)
    
    476
    +
    
    477
    +        # NOTE: Technically, we would need to take the `xmlns` into account to
    
    478
    +        # ensure that we are still in a HTML context, but we don't expect these
    
    479
    +        # void tag names to appear in other contexts.
    
    480
    +        is_void_tag = tag in self._VOID_HTML_TAGS
    
    481
    +        is_open_tag = not is_void_tag and not is_closed
    
    482
    +
    
    483
    +        if self._in_body:
    
    484
    +            # Write the tag, which may differ from the original tag.
    
    485
    +            self._add_start_tag_to_body(tag, attrs, is_void_tag)
    
    486
    +            if not is_void_tag and not is_open_tag:
    
    487
    +                # Close immediately to keep this empty.
    
    488
    +                self._add_end_tag_to_body(tag)
    
    489
    +
    
    490
    +        if is_open_tag:
    
    491
    +            # Track this as an ancestor.
    
    492
    +            self._ancestor_details.append(details)
    
    493
    +
    
    494
    +    ## HTMLParser API
    
    495
    +
    
    496
    +    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
    
    497
    +        self._handle_tag(tag, attrs, False)
    
    498
    +
    
    499
    +    def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
    
    500
    +        self._handle_tag(tag, attrs, True)
    
    501
    +
    
    502
    +    def handle_endtag(self, tag: str) -> None:
    
    503
    +        if not self._ancestor_details:
    
    504
    +            raise self._error(f"Unmatched end tag: {tag}")
    
    505
    +        details = self._ancestor_details.pop()
    
    506
    +        if details["orig-tag"] != tag:
    
    507
    +            raise self._error(f"Unmatched end tag: {tag}")
    
    508
    +
    
    509
    +        if self._in_body:
    
    510
    +            # Write the tag found in `details` to match the tag that was *written*
    
    511
    +            # for the start tag, which may differ from `orig-tag`.
    
    512
    +            self._add_end_tag_to_body(details["tag"])
    
    513
    +        if tag == "body":
    
    514
    +            self._in_body = False
    
    515
    +
    
    516
    +    def handle_comment(self, _data: str) -> None:
    
    517
    +        # ignore
    
    518
    +        pass
    
    519
    +
    
    520
    +    def handle_data(self, data: str) -> None:
    
    521
    +        if self._in_body:
    
    522
    +            self._add_to_body(html.escape(data, quote=False))
    
    523
    +        elif self._in_context(["html", "head", "title"]):
    
    524
    +            self._title += data
    
    525
    +
    
    526
    +    def handle_decl(self, decl: str) -> None:
    
    527
    +        if decl != "DOCTYPE html" or not self._in_context([]):
    
    528
    +            raise self._error("Unexpected declaration")
    
    529
    +
    
    530
    +    def handle_pi(self, _data: str) -> None:
    
    531
    +        raise self._error("Unexpected processing instruction")
    
    532
    +
    
    533
    +    def unknown_decl(self, _data: str) -> None:
    
    534
    +        raise self._error("Unknown declaration")
    
    535
    +
    
    536
    +    ## Public methods.
    
    537
    +
    
    538
    +    def process(self) -> tuple[str, dict[str, str]]:
    
    539
    +        """
    
    540
    +        Process the HTML file.
    
    541
    +
    
    542
    +        :returns: A 2-tuple of the filtered HTML content and a dictionary
    
    543
    +          that maps from the name of an asset in the HTML content to its
    
    544
    +          original file path.
    
    545
    +        """
    
    546
    +        with open(self._html_path, encoding="utf-8") as file:
    
    547
    +            self.feed(file.read())
    
    548
    +
    
    549
    +        # Make sure we are not in an invalid state.
    
    550
    +        if self._ancestor_details or self._in_body:
    
    551
    +            raise self._error("Document was not closed")
    
    552
    +        if not self._visited_body:
    
    553
    +            raise self._error("Missing a body element")
    
    554
    +        if not self._lang or not self._dir:
    
    555
    +            raise self._error("Missing lang or dir")
    
    556
    +        if not self._title:
    
    557
    +            raise self._error("Missing a title")
    
    558
    +
    
    559
    +        # Make sure all our internal references actually point to an element.
    
    560
    +        for pos, href in self._internal_hrefs:
    
    561
    +            el_id = href[1:]  # Strip the starting '#'.
    
    562
    +            if el_id not in self._all_ids:
    
    563
    +                raise self._error(f"Missing an element with the id {el_id}", pos=pos)
    
    564
    +
    
    565
    +        lang = html.escape(self._lang, quote=True)
    
    566
    +        dir_val = html.escape(self._dir, quote=True)
    
    567
    +        csp = html.escape(self._CSP, quote=True)
    
    568
    +        js_chrome = html.escape(self._CHROME_JS, quote=True)
    
    569
    +        title_content = html.escape(self._title, quote=False)
    
    570
    +
    
    571
    +        html_output = f"""<!DOCTYPE html>
    
    572
    +<html lang="{lang}" dir="{dir_val}">
    
    573
    +<head>
    
    574
    +  <meta http-equiv="Content-Security-Policy" content="{csp}" />
    
    575
    +  <title>{title_content}</title>
    
    576
    +  <script src="">"{js_chrome}"></script>
    
    577
    +"""
    
    578
    +        for href in self._style_hrefs:
    
    579
    +            html_output += (
    
    580
    +                f'  <link rel="stylesheet" href="">"{html.escape(href, quote=True)}" />\n'
    
    581
    +            )
    
    582
    +        html_output += "</head>\n"
    
    583
    +        html_output += self._body_content
    
    584
    +        html_output += "\n</html>\n"
    
    585
    +
    
    586
    +        return html_output, self._asset_paths.copy()
    
    587
    +
    
    588
    +
    
    589
    +def main(top_dir: str, out_dir: str, out_locales: str) -> None:
    
    590
    +    out_dir = os.path.abspath(out_dir)
    
    591
    +    out_locales = os.path.abspath(out_locales)
    
    592
    +    top_dir = os.path.abspath(top_dir)
    
    593
    +
    
    594
    +    if not os.path.isdir(out_dir):
    
    595
    +        raise ValueError(f"Not a directory: {out_dir}")
    
    596
    +
    
    597
    +    default_path = os.path.join(top_dir, ORIG_HTML_PATH)
    
    598
    +    if not os.path.isfile(default_path):
    
    599
    +        raise ValueError(f"Missing file: {default_path}")
    
    600
    +
    
    601
    +    locale_regex = re.compile("^[a-z]{2}(-[A-Z]{2})?$")
    
    602
    +    locale_html = {DEFAULT_LOCALE: default_path}
    
    603
    +    for maybe_locale in os.listdir(top_dir):
    
    604
    +        if not locale_regex.fullmatch(maybe_locale):
    
    605
    +            continue
    
    606
    +        maybe_path = os.path.join(top_dir, maybe_locale, ORIG_HTML_PATH)
    
    607
    +        if os.path.isfile(maybe_path):
    
    608
    +            locale_html[maybe_locale] = maybe_path
    
    609
    +
    
    610
    +    all_locales = sorted(locale_html.keys())
    
    611
    +    all_asset_paths: dict[str, str] = {}
    
    612
    +
    
    613
    +    for locale, html_path in locale_html.items():
    
    614
    +        parser = ManualParser(
    
    615
    +            locale=locale,
    
    616
    +            html_path=html_path,
    
    617
    +            top_dir=top_dir,
    
    618
    +            all_locales=all_locales,
    
    619
    +        )
    
    620
    +        content, asset_paths = parser.process()
    
    621
    +
    
    622
    +        for asset_name, orig_path in asset_paths.items():
    
    623
    +            if asset_name not in all_asset_paths:
    
    624
    +                all_asset_paths[asset_name] = orig_path
    
    625
    +            elif all_asset_paths[asset_name] != orig_path:
    
    626
    +                raise ValueError(f"Duplicate asset names: {asset_name}")
    
    627
    +
    
    628
    +        out_html_path = os.path.join(out_dir, HTML_NAME_TEMPLATE.replace("#", locale))
    
    629
    +        with open(out_html_path, "w", encoding="utf-8") as file:
    
    630
    +            file.write(content)
    
    631
    +
    
    632
    +    asset_dir = os.path.join(out_dir, ASSETS_DIR_NAME)
    
    633
    +    try:
    
    634
    +        os.mkdir(asset_dir)
    
    635
    +    except FileExistsError:
    
    636
    +        pass
    
    637
    +
    
    638
    +    for asset_name, orig_path in all_asset_paths.items():
    
    639
    +        shutil.copyfile(orig_path, os.path.join(os.path.join(asset_dir, asset_name)))
    
    640
    +
    
    641
    +    with open(out_locales, "w", encoding="utf-8") as file:
    
    642
    +        file.write(",".join(all_locales))
    
    643
    +
    
    644
    +
    
    645
    +if __name__ == "__main__":
    
    646
    +    import argparse
    
    647
    +
    
    648
    +    arg_parser = argparse.ArgumentParser(
    
    649
    +        description="Filter the offline manual HTML files to be used in Tor Browser"
    
    650
    +    )
    
    651
    +
    
    652
    +    arg_parser.add_argument(
    
    653
    +        "--in-dir",
    
    654
    +        required=True,
    
    655
    +        help="The 'public' directory to read the original HTML files from",
    
    656
    +    )
    
    657
    +    arg_parser.add_argument(
    
    658
    +        "--out-dir",
    
    659
    +        required=True,
    
    660
    +        help="The directory to write the output HTML and assets to",
    
    661
    +    )
    
    662
    +    arg_parser.add_argument(
    
    663
    +        "--out-locales",
    
    664
    +        required=True,
    
    665
    +        help="The file to write the list of locales to",
    
    666
    +    )
    
    667
    +
    
    668
    +    args = arg_parser.parse_args()
    
    669
    +    main(args.in_dir, args.out_dir, args.out_locales)

  • projects/manual/test_package_marble_build_artifacts.py
    1
    +import os
    
    2
    +import re
    
    3
    +import shutil
    
    4
    +import tempfile
    
    5
    +import textwrap
    
    6
    +import unittest
    
    7
    +
    
    8
    +from package_marble_build_artifacts import ManualParser, main
    
    9
    +
    
    10
    +EXPECT_CSP_META = '<meta http-equiv="Content-Security-Policy" content="default-src &#x27;none&#x27;; style-src chrome:; img-src chrome:; script-src chrome:" />'
    
    11
    +EXPECT_SCRIPT = (
    
    12
    +    '<script src="">"chrome://browser/content/aboutmanual/aboutManual.js"></script>'
    
    13
    +)
    
    14
    +EXPECT_CHROME_ASSETS = "chrome://browser/content/aboutmanual/assets"
    
    15
    +
    
    16
    +
    
    17
    +class TestSingleHTML(unittest.TestCase):
    
    18
    +    top_dir: str | None = None
    
    19
    +    html_path: str | None = None
    
    20
    +    all_locales = ["en", "ar"]
    
    21
    +
    
    22
    +    # Set the TestCase.maxDiff to a larger number to see the full HTML.
    
    23
    +    maxDiff = 2000
    
    24
    +
    
    25
    +    @classmethod
    
    26
    +    def setUpClass(cls) -> None:
    
    27
    +        cls.top_dir = tempfile.mkdtemp()
    
    28
    +        try:
    
    29
    +            os.chdir(cls.top_dir)
    
    30
    +            html_dir = os.path.join(cls.top_dir, "html/html")
    
    31
    +            assets1_dir = os.path.join(cls.top_dir, "html/assets1")
    
    32
    +            assets2_sub_dir = os.path.join(cls.top_dir, "assets2/sub")
    
    33
    +            os.makedirs(html_dir)
    
    34
    +            os.makedirs(assets1_dir)
    
    35
    +            os.makedirs(assets2_sub_dir)
    
    36
    +            cls.html_path = os.path.join(html_dir, "test.html")
    
    37
    +
    
    38
    +            for filename in (
    
    39
    +                "html/html/neighbour1.svg",
    
    40
    +                "html/html/neighbour2.svg",
    
    41
    +                "html/html/neighbour1.css",
    
    42
    +                "html/html/neighbour2.css",
    
    43
    +                "html/assets1/image1.png",
    
    44
    +                "html/assets1/style1.css",
    
    45
    +                "assets2/image2.png",
    
    46
    +                "assets2/style2.css",
    
    47
    +                "assets2/sub/image2.png",
    
    48
    +                "assets2/sub/style2.css",
    
    49
    +                "html/assets1/image@xxxxxx",
    
    50
    +                "html/assets1/🦭.svg",
    
    51
    +                "html/assets1/~.svg",
    
    52
    +            ):
    
    53
    +                # Create an empty file.
    
    54
    +                open(os.path.join(cls.top_dir, filename), "w", encoding="utf-8").close()
    
    55
    +        except:
    
    56
    +            shutil.rmtree(cls.top_dir)
    
    57
    +            raise
    
    58
    +
    
    59
    +    @classmethod
    
    60
    +    def tearDownClass(cls) -> None:
    
    61
    +        assert cls.top_dir is not None
    
    62
    +        shutil.rmtree(cls.top_dir)
    
    63
    +
    
    64
    +    @staticmethod
    
    65
    +    def dedent(in_str: str) -> str:
    
    66
    +        out_str = textwrap.dedent(in_str)
    
    67
    +        if out_str.startswith("\n"):
    
    68
    +            out_str = out_str[1:]
    
    69
    +        return out_str
    
    70
    +
    
    71
    +    def assert_html_out(
    
    72
    +        self, in_html: str, locale: str, expect_html: str, expect_assets: dict[str, str]
    
    73
    +    ) -> None:
    
    74
    +        assert self.html_path is not None
    
    75
    +        assert self.top_dir is not None
    
    76
    +
    
    77
    +        with open(self.html_path, "w", encoding="utf-8") as file:
    
    78
    +            file.write(self.dedent(in_html))
    
    79
    +        expect_html = self.dedent(expect_html)
    
    80
    +
    
    81
    +        expect_assets = {
    
    82
    +            key: os.path.join(self.top_dir, val) for key, val in expect_assets.items()
    
    83
    +        }
    
    84
    +
    
    85
    +        parser = ManualParser(
    
    86
    +            locale=locale,
    
    87
    +            html_path=self.html_path,
    
    88
    +            top_dir=self.top_dir,
    
    89
    +            all_locales=self.all_locales,
    
    90
    +        )
    
    91
    +        out_html, out_assets = parser.process()
    
    92
    +        self.assertEqual(out_html, expect_html, "HTML should match")
    
    93
    +        self.assertEqual(out_assets, expect_assets)
    
    94
    +
    
    95
    +    def assert_body_out(
    
    96
    +        self,
    
    97
    +        in_body: str,
    
    98
    +        expect_body: str,
    
    99
    +        expect_assets: dict[str, str],
    
    100
    +        locale: str = "ar",
    
    101
    +    ) -> None:
    
    102
    +        indent = "            "
    
    103
    +        in_body = textwrap.indent(self.dedent(in_body), prefix=indent)
    
    104
    +        expect_body = textwrap.indent(self.dedent(expect_body), prefix=indent)
    
    105
    +        in_html = f"""
    
    106
    +            <html lang="{locale}" dir="rtl">
    
    107
    +            <head>
    
    108
    +              <title>Test</title>
    
    109
    +            </head>\n{in_body}{indent}</html>
    
    110
    +        """
    
    111
    +        expect_html = f"""
    
    112
    +            <!DOCTYPE html>
    
    113
    +            <html lang="{locale}" dir="rtl">
    
    114
    +            <head>
    
    115
    +              {EXPECT_CSP_META}
    
    116
    +              <title>Test</title>
    
    117
    +              {EXPECT_SCRIPT}
    
    118
    +            </head>\n{expect_body}{indent}</html>
    
    119
    +        """
    
    120
    +        self.assert_html_out(in_html, locale, expect_html, expect_assets)
    
    121
    +
    
    122
    +    def assert_html_raises(
    
    123
    +        self, in_html: str, locale: str, expect_err_str: str
    
    124
    +    ) -> None:
    
    125
    +        assert self.html_path is not None
    
    126
    +        assert self.top_dir is not None
    
    127
    +
    
    128
    +        with open(self.html_path, "w", encoding="utf-8") as file:
    
    129
    +            file.write(self.dedent(in_html))
    
    130
    +        parser = ManualParser(
    
    131
    +            locale=locale,
    
    132
    +            html_path=self.html_path,
    
    133
    +            top_dir=self.top_dir,
    
    134
    +            all_locales=self.all_locales,
    
    135
    +        )
    
    136
    +        with self.assertRaisesRegex(
    
    137
    +            ValueError,
    
    138
    +            r"^[^:]+.html: \([0-9]+, [0-9]+\): " + re.escape(expect_err_str) + "$",
    
    139
    +        ):
    
    140
    +            parser.process()
    
    141
    +
    
    142
    +    def assert_body_raises(self, in_body: str, expect_err_str: str) -> None:
    
    143
    +        indent = "            "
    
    144
    +        in_body = textwrap.indent(self.dedent(in_body), prefix=indent)
    
    145
    +        in_html = f"""
    
    146
    +            <html lang="ar" dir="rtl">
    
    147
    +            <head>
    
    148
    +              <title>Test</title>
    
    149
    +            </head>\n{in_body}{indent}</html>
    
    150
    +        """
    
    151
    +        self.assert_html_raises(in_html, "ar", expect_err_str)
    
    152
    +
    
    153
    +    def test_empty_body(self) -> None:
    
    154
    +        self.assert_body_out("<body></body>\n", "<body></body>\n", {})
    
    155
    +
    
    156
    +    def test_html_tag(self) -> None:
    
    157
    +        self.assert_html_out(
    
    158
    +            """
    
    159
    +            <html lang="en" dir="rtl">
    
    160
    +            <head>
    
    161
    +              <title>Test</title>
    
    162
    +            </head>
    
    163
    +            <body>
    
    164
    +            </body>
    
    165
    +            </html>
    
    166
    +            """,
    
    167
    +            "en",
    
    168
    +            f"""
    
    169
    +            <!DOCTYPE html>
    
    170
    +            <html lang="en" dir="rtl">
    
    171
    +            <head>
    
    172
    +              {EXPECT_CSP_META}
    
    173
    +              <title>Test</title>
    
    174
    +              {EXPECT_SCRIPT}
    
    175
    +            </head>
    
    176
    +            <body>
    
    177
    +            </body>
    
    178
    +            </html>
    
    179
    +            """,
    
    180
    +            {},
    
    181
    +        )
    
    182
    +        # Invalid.
    
    183
    +        self.assert_html_raises(
    
    184
    +            """
    
    185
    +            <div>
    
    186
    +              <html>
    
    187
    +              </html>
    
    188
    +            </div>
    
    189
    +            """,
    
    190
    +            "ar",
    
    191
    +            "html has a parent",
    
    192
    +        )
    
    193
    +        self.assert_html_raises(
    
    194
    +            """
    
    195
    +            <html lang="ar" dir="rtl">
    
    196
    +            </html>
    
    197
    +            <html lang="en" dir="ltr">
    
    198
    +            </html>
    
    199
    +            """,
    
    200
    +            "ar",
    
    201
    +            "More than one html tag",
    
    202
    +        )
    
    203
    +        self.assert_html_raises(
    
    204
    +            """
    
    205
    +            <html dir="rtl">
    
    206
    +            </html>
    
    207
    +            """,
    
    208
    +            "ar",
    
    209
    +            "html is missing a lang attribute",
    
    210
    +        )
    
    211
    +        self.assert_html_raises(
    
    212
    +            """
    
    213
    +            <html lang="ar">
    
    214
    +            </html>
    
    215
    +            """,
    
    216
    +            "ar",
    
    217
    +            "html is missing a dir attribute",
    
    218
    +        )
    
    219
    +        self.assert_html_raises(
    
    220
    +            """
    
    221
    +            <html lang="en" dir="rtl">
    
    222
    +            </html>
    
    223
    +            """,
    
    224
    +            "ar",
    
    225
    +            "Unexpected lang attribute: en",
    
    226
    +        )
    
    227
    +        self.assert_html_raises(
    
    228
    +            """
    
    229
    +            <html lang="en" dir="LTR">
    
    230
    +            </html>
    
    231
    +            """,
    
    232
    +            "en",
    
    233
    +            "Unexpected dir attribute: LTR",
    
    234
    +        )
    
    235
    +        self.assert_html_raises(
    
    236
    +            """
    
    237
    +            <html lang="en" dir="auto">
    
    238
    +            </html>
    
    239
    +            """,
    
    240
    +            "en",
    
    241
    +            "Unexpected dir attribute: auto",
    
    242
    +        )
    
    243
    +
    
    244
    +    def test_head_ignored(self) -> None:
    
    245
    +        # Most of the tags and attributes in the head are ignored.
    
    246
    +        self.assert_html_out(
    
    247
    +            """
    
    248
    +            <html lang="ar" dir="rtl" other="blah">
    
    249
    +            <head attr="ignored">
    
    250
    +                <meta charset="utf-8">
    
    251
    +                <meta content="width=device-width, initial-scale=1.0" name="viewport" />
    
    252
    +                <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
    
    253
    +                <link rel="stylesheet" href="">"../assets1/style1.css" other="blah">
    
    254
    +                <link rel="other" href="">"https://example.org">
    
    255
    +                <title some-attr="blah">دليل استخدام متصفح تور</title>
    
    256
    +                <script src="">"https://example.org"></script>
    
    257
    +                <script src="">"../script.js"></script>
    
    258
    +                <script>
    
    259
    +                  const val = "hello";
    
    260
    +                </script>
    
    261
    +                <div>oops</div>
    
    262
    +                <style>
    
    263
    +                  body {
    
    264
    +                    display: none;
    
    265
    +                  }
    
    266
    +                </style>
    
    267
    +            </head>
    
    268
    +            <div>oops</div>
    
    269
    +            <body>
    
    270
    +            </body>
    
    271
    +            </html>
    
    272
    +            """,
    
    273
    +            "ar",
    
    274
    +            f"""
    
    275
    +            <!DOCTYPE html>
    
    276
    +            <html lang="ar" dir="rtl">
    
    277
    +            <head>
    
    278
    +              {EXPECT_CSP_META}
    
    279
    +              <title>دليل استخدام متصفح تور</title>
    
    280
    +              {EXPECT_SCRIPT}
    
    281
    +              <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/html__assets1__style1.css" />
    
    282
    +            </head>
    
    283
    +            <body>
    
    284
    +            </body>
    
    285
    +            </html>
    
    286
    +            """,
    
    287
    +            {"html__assets1__style1.css": "html/assets1/style1.css"},
    
    288
    +        )
    
    289
    +
    
    290
    +    def test_title(self) -> None:
    
    291
    +        # NOTE: Expected to fail in python 3.13.5, 3.12.11, 3.11.12, 3.10.18,
    
    292
    +        # 3.9.22 and earlier, due to a bug in html.parser, which fails to treat
    
    293
    +        # the "<div>" as text content. This should not cause any issues in
    
    294
    +        # practice, since the title is not expected include a raw <.
    
    295
    +        # It is tested here for robustness.
    
    296
    +        self.assert_html_out(
    
    297
    +            """
    
    298
    +            <html lang="ar" dir="rtl">
    
    299
    +            <head>
    
    300
    +              <title>title content <div> 'more"</title>
    
    301
    +            </head>
    
    302
    +            <body>
    
    303
    +            </body>
    
    304
    +            </html>
    
    305
    +            """,
    
    306
    +            "ar",
    
    307
    +            f"""
    
    308
    +            <!DOCTYPE html>
    
    309
    +            <html lang="ar" dir="rtl">
    
    310
    +            <head>
    
    311
    +              {EXPECT_CSP_META}
    
    312
    +              <title>title content &lt;div&gt; 'more"</title>
    
    313
    +              {EXPECT_SCRIPT}
    
    314
    +            </head>
    
    315
    +            <body>
    
    316
    +            </body>
    
    317
    +            </html>
    
    318
    +            """,
    
    319
    +            {},
    
    320
    +        )
    
    321
    +        # <title> elements outside the <head> are ignored.
    
    322
    +        self.assert_html_out(
    
    323
    +            """
    
    324
    +            <html lang="ar" dir="rtl">
    
    325
    +            <head>
    
    326
    +              <title>Test</title>
    
    327
    +            </head>
    
    328
    +            <title>Some other title</title>
    
    329
    +            <body>
    
    330
    +              <title>Body title</title>
    
    331
    +            </body>
    
    332
    +            </html>
    
    333
    +            """,
    
    334
    +            "ar",
    
    335
    +            f"""
    
    336
    +            <!DOCTYPE html>
    
    337
    +            <html lang="ar" dir="rtl">
    
    338
    +            <head>
    
    339
    +              {EXPECT_CSP_META}
    
    340
    +              <title>Test</title>
    
    341
    +              {EXPECT_SCRIPT}
    
    342
    +            </head>
    
    343
    +            <body>
    
    344
    +              <title>Body title</title>
    
    345
    +            </body>
    
    346
    +            </html>
    
    347
    +            """,
    
    348
    +            {},
    
    349
    +        )
    
    350
    +
    
    351
    +    def test_stylesheets(self) -> None:
    
    352
    +        self.assert_html_out(
    
    353
    +            """
    
    354
    +            <html lang="ar" dir="rtl">
    
    355
    +            <head>
    
    356
    +              <link rel="stylesheet" href="">"../assets1/style1.css">
    
    357
    +              <link rel="stylesheet" href="">"../../assets2/sub/style2.css?query=ignored">
    
    358
    +              <link rel="stylesheet" href="">"/assets2/style2.css#anchor-ignored">
    
    359
    +              <link rel="stylesheet" href="">"neighbour1.css">
    
    360
    +              <link rel="stylesheet" href="">"./neighbour2.css">
    
    361
    +              <title>دليل استخدام متصفح تور</title>
    
    362
    +            </head>
    
    363
    +            <body>
    
    364
    +            </body>
    
    365
    +            </html>
    
    366
    +            """,
    
    367
    +            "ar",
    
    368
    +            f"""
    
    369
    +            <!DOCTYPE html>
    
    370
    +            <html lang="ar" dir="rtl">
    
    371
    +            <head>
    
    372
    +              {EXPECT_CSP_META}
    
    373
    +              <title>دليل استخدام متصفح تور</title>
    
    374
    +              {EXPECT_SCRIPT}
    
    375
    +              <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/html__assets1__style1.css" />
    
    376
    +              <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/assets2__sub__style2.css" />
    
    377
    +              <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/assets2__style2.css" />
    
    378
    +              <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/html__html__neighbour1.css" />
    
    379
    +              <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/html__html__neighbour2.css" />
    
    380
    +            </head>
    
    381
    +            <body>
    
    382
    +            </body>
    
    383
    +            </html>
    
    384
    +            """,
    
    385
    +            {
    
    386
    +                "html__assets1__style1.css": "html/assets1/style1.css",
    
    387
    +                "assets2__sub__style2.css": "assets2/sub/style2.css",
    
    388
    +                "assets2__style2.css": "assets2/style2.css",
    
    389
    +                "html__html__neighbour1.css": "html/html/neighbour1.css",
    
    390
    +                "html__html__neighbour2.css": "html/html/neighbour2.css",
    
    391
    +            },
    
    392
    +        )
    
    393
    +
    
    394
    +        # Stylesheets and img references are treated the same.
    
    395
    +        self.assert_html_out(
    
    396
    +            """
    
    397
    +            <html lang="ar" dir="rtl">
    
    398
    +            <head>
    
    399
    +              <link rel="stylesheet" href="">"../assets1/style1.css">
    
    400
    +              <link rel="stylesheet" href="">"/assets2/sub/style2.css">
    
    401
    +              <link rel="stylesheet" href="">"neighbour2.css">
    
    402
    +              <title>دليل استخدام متصفح تور</title>
    
    403
    +            </head>
    
    404
    +            <body>
    
    405
    +              <img src="">"../assets1/image1.png">
    
    406
    +              <img src="">"/assets2/image2.png">
    
    407
    +              <img src="">"neighbour2.svg">
    
    408
    +            </body>
    
    409
    +            </html>
    
    410
    +            """,
    
    411
    +            "ar",
    
    412
    +            f"""
    
    413
    +            <!DOCTYPE html>
    
    414
    +            <html lang="ar" dir="rtl">
    
    415
    +            <head>
    
    416
    +              {EXPECT_CSP_META}
    
    417
    +              <title>دليل استخدام متصفح تور</title>
    
    418
    +              {EXPECT_SCRIPT}
    
    419
    +              <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/html__assets1__style1.css" />
    
    420
    +              <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/assets2__sub__style2.css" />
    
    421
    +              <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/html__html__neighbour2.css" />
    
    422
    +            </head>
    
    423
    +            <body>
    
    424
    +              <img src="">"{EXPECT_CHROME_ASSETS}/html__assets1__image1.png" />
    
    425
    +              <img src="">"{EXPECT_CHROME_ASSETS}/assets2__image2.png" />
    
    426
    +              <img src="">"{EXPECT_CHROME_ASSETS}/html__html__neighbour2.svg" />
    
    427
    +            </body>
    
    428
    +            </html>
    
    429
    +            """,
    
    430
    +            {
    
    431
    +                "html__assets1__style1.css": "html/assets1/style1.css",
    
    432
    +                "assets2__sub__style2.css": "assets2/sub/style2.css",
    
    433
    +                "html__html__neighbour2.css": "html/html/neighbour2.css",
    
    434
    +                "html__assets1__image1.png": "html/assets1/image1.png",
    
    435
    +                "assets2__image2.png": "assets2/image2.png",
    
    436
    +                "html__html__neighbour2.svg": "html/html/neighbour2.svg",
    
    437
    +            },
    
    438
    +        )
    
    439
    +
    
    440
    +        # Invalid stylesheets.
    
    441
    +        self.assert_html_raises(
    
    442
    +            """
    
    443
    +            <html lang="ar" dir="rtl">
    
    444
    +            <head>
    
    445
    +              <link rel="stylesheet" href="">"https://example.org">
    
    446
    +            </head>
    
    447
    +            </html>
    
    448
    +            """,
    
    449
    +            "ar",
    
    450
    +            "Unexpected asset path with a scheme: https",
    
    451
    +        )
    
    452
    +        self.assert_html_raises(
    
    453
    +            """
    
    454
    +            <html lang="ar" dir="rtl">
    
    455
    +            <head>
    
    456
    +              <link rel="stylesheet" href="">"./style.css">
    
    457
    +            </head>
    
    458
    +            </html>
    
    459
    +            """,
    
    460
    +            "ar",
    
    461
    +            f"References a non-existent asset: {self.top_dir}/html/html/style.css",
    
    462
    +        )
    
    463
    +        self.assert_html_raises(
    
    464
    +            """
    
    465
    +            <html lang="ar" dir="rtl">
    
    466
    +            <head>
    
    467
    +              <link rel="stylesheet" href="">"style.css">
    
    468
    +            </head>
    
    469
    +            </html>
    
    470
    +            """,
    
    471
    +            "ar",
    
    472
    +            f"References a non-existent asset: {self.top_dir}/html/html/style.css",
    
    473
    +        )
    
    474
    +        self.assert_html_raises(
    
    475
    +            """
    
    476
    +            <html lang="ar" dir="rtl">
    
    477
    +            <head>
    
    478
    +              <link rel="stylesheet" href="">"/style.css">
    
    479
    +            </head>
    
    480
    +            </html>
    
    481
    +            """,
    
    482
    +            "ar",
    
    483
    +            f"References a non-existent asset: {self.top_dir}/style.css",
    
    484
    +        )
    
    485
    +        self.assert_html_raises(
    
    486
    +            """
    
    487
    +            <html lang="ar" dir="rtl">
    
    488
    +            <head>
    
    489
    +              <link rel="stylesheet" href="">"../../../style.css">
    
    490
    +            </head>
    
    491
    +            </html>
    
    492
    +            """,
    
    493
    +            "ar",
    
    494
    +            f"References an asset outside {self.top_dir}",
    
    495
    +        )
    
    496
    +        self.assert_html_raises(
    
    497
    +            """
    
    498
    +            <html lang="ar" dir="rtl">
    
    499
    +            <head>
    
    500
    +              <link rel="stylesheet" href="">"/../style.css">
    
    501
    +            </head>
    
    502
    +            </html>
    
    503
    +            """,
    
    504
    +            "ar",
    
    505
    +            f"References an asset outside {self.top_dir}",
    
    506
    +        )
    
    507
    +        self.assert_html_raises(
    
    508
    +            """
    
    509
    +            <html lang="ar" dir="rtl">
    
    510
    +            <head>
    
    511
    +              <link rel="stylesheet" href="">"../none.css">
    
    512
    +            </head>
    
    513
    +            </html>
    
    514
    +            """,
    
    515
    +            "ar",
    
    516
    +            f"References a non-existent asset: {self.top_dir}/html/none.css",
    
    517
    +        )
    
    518
    +        self.assert_html_raises(
    
    519
    +            """
    
    520
    +            <html lang="ar" dir="rtl">
    
    521
    +            <head>
    
    522
    +              <link rel="stylesheet">
    
    523
    +            </head>
    
    524
    +            </html>
    
    525
    +            """,
    
    526
    +            "ar",
    
    527
    +            "stylesheet link missing an href",
    
    528
    +        )
    
    529
    +        self.assert_html_raises(
    
    530
    +            """
    
    531
    +            <html lang="ar" dir="rtl">
    
    532
    +            <head>
    
    533
    +              <link rel="stylesheet" href>
    
    534
    +            </head>
    
    535
    +            </html>
    
    536
    +            """,
    
    537
    +            "ar",
    
    538
    +            "stylesheet link missing an href",
    
    539
    +        )
    
    540
    +
    
    541
    +    def test_tag_tracking(self) -> None:
    
    542
    +        # Convert a self-closing non-void tag into a pair of tags with no content.
    
    543
    +        self.assert_body_out(
    
    544
    +            """
    
    545
    +            <body>
    
    546
    +              <div class="ok" />
    
    547
    +            </body>
    
    548
    +            """,
    
    549
    +            """
    
    550
    +            <body>
    
    551
    +              <div class="ok"></div>
    
    552
    +            </body>
    
    553
    +            """,
    
    554
    +            {},
    
    555
    +        )
    
    556
    +        # Convert a void tag into a self-closing tag.
    
    557
    +        self.assert_body_out(
    
    558
    +            """
    
    559
    +            <body>
    
    560
    +              <img class="ok">
    
    561
    +              <br>
    
    562
    +              <hr>
    
    563
    +              <hr />
    
    564
    +            </body>
    
    565
    +            """,
    
    566
    +            """
    
    567
    +            <body>
    
    568
    +              <img class="ok" />
    
    569
    +              <br />
    
    570
    +              <hr />
    
    571
    +              <hr />
    
    572
    +            </body>
    
    573
    +            """,
    
    574
    +            {},
    
    575
    +        )
    
    576
    +
    
    577
    +        # Trying to close a void tag.
    
    578
    +        # Missing the </div>.
    
    579
    +        self.assert_body_raises(
    
    580
    +            """
    
    581
    +            <body>
    
    582
    +              <img></img>
    
    583
    +            </body>
    
    584
    +            """,
    
    585
    +            "Unmatched end tag: img",
    
    586
    +        )
    
    587
    +        # Missing the </div>.
    
    588
    +        self.assert_html_raises(
    
    589
    +            """
    
    590
    +            <html lang="ar" dir="rtl">
    
    591
    +            <head>
    
    592
    +              <title>Test</title>
    
    593
    +              <div>
    
    594
    +            </head>
    
    595
    +            <body>
    
    596
    +            </body>
    
    597
    +            </html>
    
    598
    +            """,
    
    599
    +            "ar",
    
    600
    +            "Unmatched end tag: head",
    
    601
    +        )
    
    602
    +        # Closing tag with no starting tag.
    
    603
    +        self.assert_body_raises(
    
    604
    +            """
    
    605
    +            <body>
    
    606
    +              </div >
    
    607
    +            </body>
    
    608
    +            """,
    
    609
    +            "Unmatched end tag: div",
    
    610
    +        )
    
    611
    +        # Missing the </div>
    
    612
    +        self.assert_body_raises(
    
    613
    +            """
    
    614
    +            <body>
    
    615
    +              <div>
    
    616
    +            </body>
    
    617
    +            """,
    
    618
    +            "Unmatched end tag: body",
    
    619
    +        )
    
    620
    +        # Missing the closing </html>
    
    621
    +        self.assert_html_raises(
    
    622
    +            """
    
    623
    +            <html lang="ar" dir="rtl">
    
    624
    +            <head>
    
    625
    +              <title>Test</title>
    
    626
    +            </head>
    
    627
    +            <body>
    
    628
    +            </body>
    
    629
    +            """,
    
    630
    +            "ar",
    
    631
    +            "Document was not closed",
    
    632
    +        )
    
    633
    +        # Closing an unmatched tag in the outer scope.
    
    634
    +        self.assert_html_raises(
    
    635
    +            """
    
    636
    +            <html lang="ar" dir="rtl">
    
    637
    +            <head>
    
    638
    +              <title>Test</title>
    
    639
    +            </head>
    
    640
    +            <body>
    
    641
    +            </body>
    
    642
    +            </html>
    
    643
    +            </div>
    
    644
    +            """,
    
    645
    +            "ar",
    
    646
    +            "Unmatched end tag: div",
    
    647
    +        )
    
    648
    +
    
    649
    +        # Duplicate attributes.
    
    650
    +        self.assert_body_raises(
    
    651
    +            """
    
    652
    +            <body class="ok" class="other"></body>
    
    653
    +            """,
    
    654
    +            "body has a duplicate attribute: class",
    
    655
    +        )
    
    656
    +
    
    657
    +    def test_misc_data(self) -> None:
    
    658
    +        # Comments are ignored.
    
    659
    +        self.assert_body_out(
    
    660
    +            """
    
    661
    +            <body>
    
    662
    +              <div>a<!-- hello -->b<! hello ></div></ bogus comment>
    
    663
    +            </body>
    
    664
    +            """,
    
    665
    +            """
    
    666
    +            <body>
    
    667
    +              <div>ab</div>
    
    668
    +            </body>
    
    669
    +            """,
    
    670
    +            {},
    
    671
    +        )
    
    672
    +        # Attributes and content are escaped.
    
    673
    +        self.assert_body_out(
    
    674
    +            """
    
    675
    +            <body>
    
    676
    +              <div empty-attr attr2="'hello" attr='"<div>inject'>x < "6"</div attr2="hello">
    
    677
    +            </body>
    
    678
    +            """,
    
    679
    +            """
    
    680
    +            <body>
    
    681
    +              <div empty-attr attr2="&#x27;hello" attr="&quot;&lt;div&gt;inject">x &lt; "6"</div>
    
    682
    +            </body>
    
    683
    +            """,
    
    684
    +            {},
    
    685
    +        )
    
    686
    +
    
    687
    +        # CDATA.
    
    688
    +        self.assert_body_raises(
    
    689
    +            """
    
    690
    +            <body>
    
    691
    +              <div>
    
    692
    +                <![CDATA[<img class="ok">]]>
    
    693
    +              </div>
    
    694
    +            </body>
    
    695
    +            """,
    
    696
    +            "Unknown declaration",
    
    697
    +        )
    
    698
    +
    
    699
    +        # A doctype html at the start is ok.
    
    700
    +        self.assert_html_out(
    
    701
    +            """
    
    702
    +            <!DOCTYPE html>
    
    703
    +            <html lang="ar" dir="rtl">
    
    704
    +            <head>
    
    705
    +              <title>Test</title>
    
    706
    +            </head>
    
    707
    +            <body></body>
    
    708
    +            </html>
    
    709
    +            """,
    
    710
    +            "ar",
    
    711
    +            f"""
    
    712
    +            <!DOCTYPE html>
    
    713
    +            <html lang="ar" dir="rtl">
    
    714
    +            <head>
    
    715
    +              {EXPECT_CSP_META}
    
    716
    +              <title>Test</title>
    
    717
    +              {EXPECT_SCRIPT}
    
    718
    +            </head>
    
    719
    +            <body></body>
    
    720
    +            </html>
    
    721
    +            """,
    
    722
    +            {},
    
    723
    +        )
    
    724
    +        # Other declarations are not ok.
    
    725
    +        self.assert_html_raises(
    
    726
    +            """
    
    727
    +            <!DOCTYPE other>
    
    728
    +            <html></html>
    
    729
    +            """,
    
    730
    +            "ar",
    
    731
    +            "Unexpected declaration",
    
    732
    +        )
    
    733
    +        # Declaration further down.
    
    734
    +        self.assert_html_raises(
    
    735
    +            """
    
    736
    +            <html lang="ar" dir="rtl">
    
    737
    +            <!DOCTYPE html>
    
    738
    +            </html>
    
    739
    +            """,
    
    740
    +            "ar",
    
    741
    +            "Unexpected declaration",
    
    742
    +        )
    
    743
    +        # Processing instruction.
    
    744
    +        self.assert_body_raises(
    
    745
    +            """
    
    746
    +            <body>
    
    747
    +              <?xml-stylesheet href="">"style.css"?>
    
    748
    +            </body>
    
    749
    +            """,
    
    750
    +            "Unexpected processing instruction",
    
    751
    +        )
    
    752
    +
    
    753
    +    def test_body_tag(self) -> None:
    
    754
    +        # Attributes are preserved.
    
    755
    +        self.assert_body_out(
    
    756
    +            """
    
    757
    +            <body class="my-class" attr="blah">
    
    758
    +              <div></div>
    
    759
    +            </body>
    
    760
    +            """,
    
    761
    +            """
    
    762
    +            <body class="my-class" attr="blah">
    
    763
    +              <div></div>
    
    764
    +            </body>
    
    765
    +            """,
    
    766
    +            {},
    
    767
    +        )
    
    768
    +        # Invalid.
    
    769
    +        self.assert_html_raises(
    
    770
    +            """
    
    771
    +            <html lang="ar" dir="rtl">
    
    772
    +              <div>
    
    773
    +                <body>
    
    774
    +                </body>
    
    775
    +              </div>
    
    776
    +            </html>
    
    777
    +            """,
    
    778
    +            "ar",
    
    779
    +            "Wrong context for the body tag",
    
    780
    +        )
    
    781
    +        self.assert_html_raises(
    
    782
    +            """
    
    783
    +            <html lang="ar" dir="rtl">
    
    784
    +              <body>
    
    785
    +              </body>
    
    786
    +              <div></div>
    
    787
    +              <body></body>
    
    788
    +            </html>
    
    789
    +            """,
    
    790
    +            "ar",
    
    791
    +            "More than one body tag",
    
    792
    +        )
    
    793
    +        self.assert_body_raises(
    
    794
    +            """
    
    795
    +            <body>
    
    796
    +              <div>
    
    797
    +                <body></body>
    
    798
    +              </div>
    
    799
    +            </body>
    
    800
    +            """,
    
    801
    +            "Unexpected body tag in body",
    
    802
    +        )
    
    803
    +        self.assert_body_raises(
    
    804
    +            """
    
    805
    +            <body>
    
    806
    +              <main>
    
    807
    +                <script src="">"inject">
    
    808
    +                </script>
    
    809
    +              </main>
    
    810
    +            </body>
    
    811
    +            """,
    
    812
    +            "Unexpected script tag in body",
    
    813
    +        )
    
    814
    +        self.assert_body_raises(
    
    815
    +            """
    
    816
    +            <body>
    
    817
    +              <main>
    
    818
    +                <SCRIpT>const val = "inject";</SCRIpT>
    
    819
    +              </main>
    
    820
    +            </body>
    
    821
    +            """,
    
    822
    +            "Unexpected script tag in body",
    
    823
    +        )
    
    824
    +        self.assert_body_raises(
    
    825
    +            """
    
    826
    +            <body>
    
    827
    +              <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
    
    828
    +            </body>
    
    829
    +            """,
    
    830
    +            "Unexpected meta tag in body",
    
    831
    +        )
    
    832
    +        self.assert_body_raises(
    
    833
    +            """
    
    834
    +            <body>
    
    835
    +              <link href="">"inject">
    
    836
    +            </body>
    
    837
    +            """,
    
    838
    +            "Unexpected link tag in body",
    
    839
    +        )
    
    840
    +        self.assert_body_raises(
    
    841
    +            "<body><noscript></noscript></body>", "Unexpected noscript tag in body"
    
    842
    +        )
    
    843
    +        self.assert_body_raises(
    
    844
    +            "<body><html>content</html></body>", "Unexpected html tag in body"
    
    845
    +        )
    
    846
    +        self.assert_body_raises(
    
    847
    +            "<body><head></head></body>", "Unexpected head tag in body"
    
    848
    +        )
    
    849
    +
    
    850
    +    def test_img_tag(self) -> None:
    
    851
    +        self.assert_body_out(
    
    852
    +            """
    
    853
    +            <body>
    
    854
    +              <img src="">"../assets1/image1.png">
    
    855
    +            </body>
    
    856
    +            """,
    
    857
    +            f"""
    
    858
    +            <body>
    
    859
    +              <img src="">"{EXPECT_CHROME_ASSETS}/html__assets1__image1.png" />
    
    860
    +            </body>
    
    861
    +            """,
    
    862
    +            {"html__assets1__image1.png": "html/assets1/image1.png"},
    
    863
    +        )
    
    864
    +        # Multiple and duplicates.
    
    865
    +        self.assert_body_out(
    
    866
    +            """
    
    867
    +            <body>
    
    868
    +              <img src="">"../assets1/image1.png">
    
    869
    +
    
    870
    +              <div>
    
    871
    +                <img src="">"../assets1/image1.png">
    
    872
    +                <p>
    
    873
    +                  <img src="">"../../assets2/image2.png" alt="hello"/>
    
    874
    +                  <img src="">"./neighbour1.svg">
    
    875
    +                </p>
    
    876
    +                <img src="">"../../assets2/sub/image2.png"
    
    877
    +                ><br>
    
    878
    +                <img src="">"/assets2/sub/image2.png">
    
    879
    +                <img src="">"neighbour1.svg">
    
    880
    +                <img src="">"./neighbour2.svg">
    
    881
    +              </div>
    
    882
    +            </body>
    
    883
    +            """,
    
    884
    +            f"""
    
    885
    +            <body>
    
    886
    +              <img src="">"{EXPECT_CHROME_ASSETS}/html__assets1__image1.png" />
    
    887
    +
    
    888
    +              <div>
    
    889
    +                <img src="">"{EXPECT_CHROME_ASSETS}/html__assets1__image1.png" />
    
    890
    +                <p>
    
    891
    +                  <img src="">"{EXPECT_CHROME_ASSETS}/assets2__image2.png" alt="hello" />
    
    892
    +                  <img src="">"{EXPECT_CHROME_ASSETS}/html__html__neighbour1.svg" />
    
    893
    +                </p>
    
    894
    +                <img src="">"{EXPECT_CHROME_ASSETS}/assets2__sub__image2.png" /><br />
    
    895
    +                <img src="">"{EXPECT_CHROME_ASSETS}/assets2__sub__image2.png" />
    
    896
    +                <img src="">"{EXPECT_CHROME_ASSETS}/html__html__neighbour1.svg" />
    
    897
    +                <img src="">"{EXPECT_CHROME_ASSETS}/html__html__neighbour2.svg" />
    
    898
    +              </div>
    
    899
    +            </body>
    
    900
    +            """,
    
    901
    +            {
    
    902
    +                "html__assets1__image1.png": "html/assets1/image1.png",
    
    903
    +                "assets2__image2.png": "assets2/image2.png",
    
    904
    +                "assets2__sub__image2.png": "assets2/sub/image2.png",
    
    905
    +                "html__html__neighbour1.svg": "html/html/neighbour1.svg",
    
    906
    +                "html__html__neighbour2.svg": "html/html/neighbour2.svg",
    
    907
    +            },
    
    908
    +        )
    
    909
    +
    
    910
    +        # Invalid.
    
    911
    +        self.assert_body_raises(
    
    912
    +            """
    
    913
    +            <body>
    
    914
    +              <img src="">"https://example.org">
    
    915
    +            </body>
    
    916
    +            """,
    
    917
    +            "Unexpected asset path with a scheme: https",
    
    918
    +        )
    
    919
    +        self.assert_body_raises(
    
    920
    +            """
    
    921
    +            <body>
    
    922
    +              <img src="">"./image.png">
    
    923
    +            </body>
    
    924
    +            """,
    
    925
    +            f"References a non-existent asset: {self.top_dir}/html/html/image.png",
    
    926
    +        )
    
    927
    +        self.assert_body_raises(
    
    928
    +            """
    
    929
    +            <body>
    
    930
    +              <img src="">"image.png">
    
    931
    +            </body>
    
    932
    +            """,
    
    933
    +            f"References a non-existent asset: {self.top_dir}/html/html/image.png",
    
    934
    +        )
    
    935
    +        self.assert_body_raises(
    
    936
    +            """
    
    937
    +            <body>
    
    938
    +              <img src="">"/image.png">
    
    939
    +            </body>
    
    940
    +            """,
    
    941
    +            f"References a non-existent asset: {self.top_dir}/image.png",
    
    942
    +        )
    
    943
    +        self.assert_body_raises(
    
    944
    +            """
    
    945
    +            <body>
    
    946
    +              <img src="">"../../../image.css">
    
    947
    +            </body>
    
    948
    +            """,
    
    949
    +            f"References an asset outside {self.top_dir}",
    
    950
    +        )
    
    951
    +        self.assert_body_raises(
    
    952
    +            """
    
    953
    +            <body>
    
    954
    +              <img src="">"/../image.css">
    
    955
    +            </body>
    
    956
    +            """,
    
    957
    +            f"References an asset outside {self.top_dir}",
    
    958
    +        )
    
    959
    +        self.assert_body_raises(
    
    960
    +            """
    
    961
    +            <body>
    
    962
    +              <img src="">"../none.png">
    
    963
    +            </body>
    
    964
    +            """,
    
    965
    +            f"References a non-existent asset: {self.top_dir}/html/none.png",
    
    966
    +        )
    
    967
    +        self.assert_body_raises(
    
    968
    +            """
    
    969
    +            <body>
    
    970
    +              <img srcset="../assets1/image1.png 2x, ../assets1/image1.png" />
    
    971
    +            </body>
    
    972
    +            """,
    
    973
    +            "Unhandled srcset attribute",
    
    974
    +        )
    
    975
    +
    
    976
    +        # Special characters are substituted to safe characters.
    
    977
    +        self.assert_body_out(
    
    978
    +            """
    
    979
    +            <body>
    
    980
    +              <img src="">"../assets1/🦭.svg">
    
    981
    +            </body>
    
    982
    +            """,
    
    983
    +            f"""
    
    984
    +            <body>
    
    985
    +              <img src="">"{EXPECT_CHROME_ASSETS}/html__assets1___.svg" />
    
    986
    +            </body>
    
    987
    +            """,
    
    988
    +            {"html__assets1___.svg": "html/assets1/🦭.svg"},
    
    989
    +        )
    
    990
    +        self.assert_body_out(
    
    991
    +            """
    
    992
    +            <body>
    
    993
    +              <img src="">"../assets1/~.svg">
    
    994
    +              <img src="">"../assets1/image@xxxxxx">
    
    995
    +            </body>
    
    996
    +            """,
    
    997
    +            f"""
    
    998
    +            <body>
    
    999
    +              <img src="">"{EXPECT_CHROME_ASSETS}/html__assets1___.svg" />
    
    1000
    +              <img src="">"{EXPECT_CHROME_ASSETS}/html__assets1__image_2x.png" />
    
    1001
    +            </body>
    
    1002
    +            """,
    
    1003
    +            {
    
    1004
    +                "html__assets1___.svg": "html/assets1/~.svg",
    
    1005
    +                "html__assets1__image_2x.png": "html/assets1/image@xxxxxx",
    
    1006
    +            },
    
    1007
    +        )
    
    1008
    +        # Conflicting names.
    
    1009
    +        self.assert_body_raises(
    
    1010
    +            """
    
    1011
    +            <body>
    
    1012
    +              <img src="">"../assets1/~.svg">
    
    1013
    +              <img src="">"../assets1/🦭.svg">
    
    1014
    +            </body>
    
    1015
    +            """,
    
    1016
    +            "More than one asset with the same name: html__assets1___.svg",
    
    1017
    +        )
    
    1018
    +
    
    1019
    +    def test_a_tag(self) -> None:
    
    1020
    +        # Internal or no href.
    
    1021
    +        self.assert_body_out(
    
    1022
    +            """
    
    1023
    +            <body>
    
    1024
    +              <div>
    
    1025
    +                <a href="">"#index">index</a>
    
    1026
    +                <a href="">"#index" rel="author" class="ok">in<span>d</span>ex</a>
    
    1027
    +                <a rel="author noreferrer">none</a>
    
    1028
    +              </div>
    
    1029
    +              <div id="index"></div>
    
    1030
    +            </body>
    
    1031
    +            """,
    
    1032
    +            """
    
    1033
    +            <body>
    
    1034
    +              <div>
    
    1035
    +                <a href="">"#index">index</a>
    
    1036
    +                <a href="">"#index" class="ok">in<span>d</span>ex</a>
    
    1037
    +                <a>none</a>
    
    1038
    +              </div>
    
    1039
    +              <div id="index"></div>
    
    1040
    +            </body>
    
    1041
    +            """,
    
    1042
    +            {},
    
    1043
    +        )
    
    1044
    +
    
    1045
    +        # External.
    
    1046
    +        self.assert_body_out(
    
    1047
    +            """
    
    1048
    +            <body>
    
    1049
    +              <a href="">"https://example.org">example</a>
    
    1050
    +              <a href="">"https://example.org?query=ok" rel="noopener" class="hello">example2</a>
    
    1051
    +              <a href="">"https://example.net" referrerpolicy="no-referrer" target="self">example3</a>
    
    1052
    +            </body>
    
    1053
    +            """,
    
    1054
    +            """
    
    1055
    +            <body>
    
    1056
    +              <a href="">"https://example.org" target="_blank" rel="noreferrer">example</a>
    
    1057
    +              <a href="">"https://example.org?query=ok" class="hello" target="_blank" rel="noreferrer">example2</a>
    
    1058
    +              <a href="">"https://example.net" target="_blank" rel="noreferrer">example3</a>
    
    1059
    +            </body>
    
    1060
    +            """,
    
    1061
    +            {},
    
    1062
    +        )
    
    1063
    +
    
    1064
    +        # Relative: tor-browser.
    
    1065
    +        self.assert_body_out(
    
    1066
    +            """
    
    1067
    +            <body>
    
    1068
    +              <a href="">"../../tor-browser/sub-page/">other section</a>
    
    1069
    +              <a href="">"../../tor-browser/sub-page" rel="author prev">other section2</a>
    
    1070
    +              <div>
    
    1071
    +                <a href="">"../../tor-browser/sub-page#anchor">other section3</a>
    
    1072
    +                <a href="">"../../tor-browser/sub-page/further#anchor" class="ok">other section4</a>
    
    1073
    +              </div>
    
    1074
    +              <div id="sub-page">
    
    1075
    +                <span id="sub-page___anchor"></span>
    
    1076
    +                <div id="sub-page__further">
    
    1077
    +                  <span id="sub-page__further___anchor"></span>
    
    1078
    +                </div>
    
    1079
    +              </div>
    
    1080
    +            </body>
    
    1081
    +            """,
    
    1082
    +            """
    
    1083
    +            <body>
    
    1084
    +              <a href="">"#sub-page">other section</a>
    
    1085
    +              <a href="">"#sub-page">other section2</a>
    
    1086
    +              <div>
    
    1087
    +                <a href="">"#sub-page___anchor">other section3</a>
    
    1088
    +                <a href="">"#sub-page__further___anchor" class="ok">other section4</a>
    
    1089
    +              </div>
    
    1090
    +              <div id="sub-page">
    
    1091
    +                <span id="sub-page___anchor"></span>
    
    1092
    +                <div id="sub-page__further">
    
    1093
    +                  <span id="sub-page__further___anchor"></span>
    
    1094
    +                </div>
    
    1095
    +              </div>
    
    1096
    +            </body>
    
    1097
    +            """,
    
    1098
    +            {},
    
    1099
    +        )
    
    1100
    +        # A plain "tor-browser/" will point to "#index" as a special case.
    
    1101
    +        self.assert_body_out(
    
    1102
    +            """
    
    1103
    +            <body>
    
    1104
    +              <a href="">"../../tor-browser/">top</a>
    
    1105
    +              <div id="index"></div>
    
    1106
    +            </body>
    
    1107
    +            """,
    
    1108
    +            """
    
    1109
    +            <body>
    
    1110
    +              <a href="">"#index">top</a>
    
    1111
    +              <div id="index"></div>
    
    1112
    +            </body>
    
    1113
    +            """,
    
    1114
    +            {},
    
    1115
    +        )
    
    1116
    +        # Relative: outside tor-browser and get-in-touch.
    
    1117
    +        self.assert_body_out(
    
    1118
    +            """
    
    1119
    +            <body>
    
    1120
    +              <a href="">"../../tor-vpn/sub-page">Tor VPN</a>
    
    1121
    +              <a href="">"../../tor-vpn/sub-page?query=ok#anchor">Tor VPN</a>
    
    1122
    +            </body>
    
    1123
    +            """,
    
    1124
    +            """
    
    1125
    +            <body>
    
    1126
    +              <a href="">"https://support.torproject.org/ar/tor-vpn/sub-page" target="_blank" rel="noreferrer">Tor VPN</a>
    
    1127
    +              <a href="">"https://support.torproject.org/ar/tor-vpn/sub-page#anchor" target="_blank" rel="noreferrer">Tor VPN</a>
    
    1128
    +            </body>
    
    1129
    +            """,
    
    1130
    +            {},
    
    1131
    +            locale="ar",
    
    1132
    +        )
    
    1133
    +        # For the "en" locale, we do not include /en/ in the support URL.
    
    1134
    +        self.assert_body_out(
    
    1135
    +            """
    
    1136
    +            <body>
    
    1137
    +              <a href="">"../../tor-vpn/sub-page">Tor VPN</a>
    
    1138
    +              <a href="">"../../tor-vpn/sub-page?query=ok#anchor">Tor VPN</a>
    
    1139
    +            </body>
    
    1140
    +            """,
    
    1141
    +            """
    
    1142
    +            <body>
    
    1143
    +              <a href="">"https://support.torproject.org/tor-vpn/sub-page" target="_blank" rel="noreferrer">Tor VPN</a>
    
    1144
    +              <a href="">"https://support.torproject.org/tor-vpn/sub-page#anchor" target="_blank" rel="noreferrer">Tor VPN</a>
    
    1145
    +            </body>
    
    1146
    +            """,
    
    1147
    +            {},
    
    1148
    +            locale="en",
    
    1149
    +        )
    
    1150
    +        # Relative: get-in-touch.
    
    1151
    +        # Only "bug-or-feedback" or "user-support" is expected.
    
    1152
    +        self.assert_body_out(
    
    1153
    +            """
    
    1154
    +            <body>
    
    1155
    +              <a href="">"../../get-in-touch/bug-or-feedback" class="hello">bug</a>
    
    1156
    +              <a href="">"../../get-in-touch/user-support#anchor">support</a>
    
    1157
    +              <a href="">"../../get-in-touch/other">get in touch other</a>
    
    1158
    +              <a href="">"../../get-in-touch">get in touch top</a>
    
    1159
    +              <div id="get-in-touch__bug-or-feedback"></div>
    
    1160
    +              <div id="get-in-touch__user-support___anchor"></div>
    
    1161
    +            </body>
    
    1162
    +            """,
    
    1163
    +            """
    
    1164
    +            <body>
    
    1165
    +              <a href="">"#get-in-touch__bug-or-feedback" class="hello">bug</a>
    
    1166
    +              <a href="">"#get-in-touch__user-support___anchor">support</a>
    
    1167
    +              <a href="">"https://support.torproject.org/ar/get-in-touch/other" target="_blank" rel="noreferrer">get in touch other</a>
    
    1168
    +              <a href="">"https://support.torproject.org/ar/get-in-touch" target="_blank" rel="noreferrer">get in touch top</a>
    
    1169
    +              <div id="get-in-touch__bug-or-feedback"></div>
    
    1170
    +              <div id="get-in-touch__user-support___anchor"></div>
    
    1171
    +            </body>
    
    1172
    +            """,
    
    1173
    +            {},
    
    1174
    +            locale="ar",
    
    1175
    +        )
    
    1176
    +
    
    1177
    +        # mailto href is removed.
    
    1178
    +        self.assert_body_out(
    
    1179
    +            """
    
    1180
    +            <body>
    
    1181
    +              <a href="">"mailto:me@xxxxxxxxx" class="ok">email</a>
    
    1182
    +            </body>
    
    1183
    +            """,
    
    1184
    +            """
    
    1185
    +            <body>
    
    1186
    +              <a class="ok">email</a>
    
    1187
    +            </body>
    
    1188
    +            """,
    
    1189
    +            {},
    
    1190
    +        )
    
    1191
    +
    
    1192
    +        # Missing internal id.
    
    1193
    +        self.assert_body_raises(
    
    1194
    +            """
    
    1195
    +            <body>
    
    1196
    +              <a href="">"#top"></a>
    
    1197
    +            </body>
    
    1198
    +            """,
    
    1199
    +            "Missing an element with the id top",
    
    1200
    +        )
    
    1201
    +
    
    1202
    +        # Missing internal id.
    
    1203
    +        self.assert_body_raises(
    
    1204
    +            """
    
    1205
    +            <body>
    
    1206
    +              <a href="">"../../tor-browser/sub-page"></a>
    
    1207
    +            </body>
    
    1208
    +            """,
    
    1209
    +            "Missing an element with the id sub-page",
    
    1210
    +        )
    
    1211
    +
    
    1212
    +        # Unhandled hrefs.
    
    1213
    +        self.assert_body_raises(
    
    1214
    +            """
    
    1215
    +            <body>
    
    1216
    +              <a href="">"../page"></a>
    
    1217
    +            </body>
    
    1218
    +            """,
    
    1219
    +            "Unexpected href: ../page",
    
    1220
    +        )
    
    1221
    +        self.assert_body_raises(
    
    1222
    +            """
    
    1223
    +            <body>
    
    1224
    +              <a href="">"chrome://page"></a>
    
    1225
    +            </body>
    
    1226
    +            """,
    
    1227
    +            "Unexpected href: chrome://page",
    
    1228
    +        )
    
    1229
    +        self.assert_body_raises(
    
    1230
    +            """
    
    1231
    +            <body>
    
    1232
    +              <a href="">"../../../page"></a>
    
    1233
    +            </body>
    
    1234
    +            """,
    
    1235
    +            "Unexpected path: ../../../page",
    
    1236
    +        )
    
    1237
    +        self.assert_body_raises(
    
    1238
    +            """
    
    1239
    +            <body>
    
    1240
    +              <a href="">"../../page/./"></a>
    
    1241
    +            </body>
    
    1242
    +            """,
    
    1243
    +            "Unexpected path: ../../page/./",
    
    1244
    +        )
    
    1245
    +        self.assert_body_raises(
    
    1246
    +            """
    
    1247
    +            <body>
    
    1248
    +              <a href="">"../../page/../other"></a>
    
    1249
    +            </body>
    
    1250
    +            """,
    
    1251
    +            "Unexpected path: ../../page/../other",
    
    1252
    +        )
    
    1253
    +
    
    1254
    +    def test_ids(self) -> None:
    
    1255
    +        # The "id" of the heading-anchor adopts a prefix from the nearest
    
    1256
    +        # olm-page.
    
    1257
    +        self.assert_body_out(
    
    1258
    +            """
    
    1259
    +            <body>
    
    1260
    +              <div class="other olm-page" id="somesection">
    
    1261
    +                <main>
    
    1262
    +                  <div id="somename" class="heading-anchor other"></div>
    
    1263
    +                  <h2>Some heading</h2>
    
    1264
    +                </main>
    
    1265
    +              </div>
    
    1266
    +            </body>
    
    1267
    +            """,
    
    1268
    +            """
    
    1269
    +            <body>
    
    1270
    +              <div class="other olm-page" id="somesection">
    
    1271
    +                <main>
    
    1272
    +                  <div id="somesection___somename" class="heading-anchor other"></div>
    
    1273
    +                  <h2>Some heading</h2>
    
    1274
    +                </main>
    
    1275
    +              </div>
    
    1276
    +            </body>
    
    1277
    +            """,
    
    1278
    +            {},
    
    1279
    +        )
    
    1280
    +
    
    1281
    +        # Multiple.
    
    1282
    +        self.assert_body_out(
    
    1283
    +            """
    
    1284
    +            <body>
    
    1285
    +              <div class="other olm-page" id="somesection">
    
    1286
    +                <main>
    
    1287
    +                  <div id="somename" class="heading-anchor other"></div>
    
    1288
    +                  <h2>Some heading</h2>
    
    1289
    +                </main>
    
    1290
    +              </div>
    
    1291
    +              <div>
    
    1292
    +                <div class="olm-page" id="somesection__2">
    
    1293
    +                  <main>
    
    1294
    +                    <div>
    
    1295
    +                      <div id="somename2" class="heading-anchor"></div>
    
    1296
    +                      <h2>Some heading</h2>
    
    1297
    +                    </div>
    
    1298
    +                  </main>
    
    1299
    +                </div>
    
    1300
    +              </div>
    
    1301
    +            </body>
    
    1302
    +            """,
    
    1303
    +            """
    
    1304
    +            <body>
    
    1305
    +              <div class="other olm-page" id="somesection">
    
    1306
    +                <main>
    
    1307
    +                  <div id="somesection___somename" class="heading-anchor other"></div>
    
    1308
    +                  <h2>Some heading</h2>
    
    1309
    +                </main>
    
    1310
    +              </div>
    
    1311
    +              <div>
    
    1312
    +                <div class="olm-page" id="somesection__2">
    
    1313
    +                  <main>
    
    1314
    +                    <div>
    
    1315
    +                      <div id="somesection__2___somename2" class="heading-anchor"></div>
    
    1316
    +                      <h2>Some heading</h2>
    
    1317
    +                    </div>
    
    1318
    +                  </main>
    
    1319
    +                </div>
    
    1320
    +              </div>
    
    1321
    +            </body>
    
    1322
    +            """,
    
    1323
    +            {},
    
    1324
    +        )
    
    1325
    +
    
    1326
    +        # Duplicate ids.
    
    1327
    +        self.assert_body_raises(
    
    1328
    +            """
    
    1329
    +            <body>
    
    1330
    +              <div id="first"></div>
    
    1331
    +              <div id="first"></div>
    
    1332
    +            </body>
    
    1333
    +            """,
    
    1334
    +            "Duplicate id: first",
    
    1335
    +        )
    
    1336
    +        self.assert_body_raises(
    
    1337
    +            """
    
    1338
    +            <body>
    
    1339
    +              <div id="somesection___somename"></div>
    
    1340
    +              <div class="olm-page" id="somesection">
    
    1341
    +                <div id="somename" class="heading-anchor"></div>
    
    1342
    +              </div>
    
    1343
    +            </body>
    
    1344
    +            """,
    
    1345
    +            "Duplicate id: somesection___somename",
    
    1346
    +        )
    
    1347
    +
    
    1348
    +        # Ignore duplicate ids on a toggler element.
    
    1349
    +        # TODO: tor-browser-build#41818. Remove this part of the test.
    
    1350
    +        self.assert_body_out(
    
    1351
    +            """
    
    1352
    +            <body>
    
    1353
    +              <div id="first"></div>
    
    1354
    +              <input id="first" class="toggler">
    
    1355
    +            </body>
    
    1356
    +            """,
    
    1357
    +            """
    
    1358
    +            <body>
    
    1359
    +              <div id="first"></div>
    
    1360
    +              <input class="toggler" />
    
    1361
    +            </body>
    
    1362
    +            """,
    
    1363
    +            {},
    
    1364
    +        )
    
    1365
    +
    
    1366
    +        # Nested olm-page.
    
    1367
    +        self.assert_body_raises(
    
    1368
    +            """
    
    1369
    +            <body>
    
    1370
    +              <div class="olm-page" id="outer">
    
    1371
    +                <main>
    
    1372
    +                  <div class="olm-page" id="outer__sub"></div>
    
    1373
    +                </main>
    
    1374
    +              </div>
    
    1375
    +            </body>
    
    1376
    +            """,
    
    1377
    +            "olm-page is below another",
    
    1378
    +        )
    
    1379
    +
    
    1380
    +        # olm-page with no id.
    
    1381
    +        self.assert_body_raises(
    
    1382
    +            """
    
    1383
    +            <body>
    
    1384
    +              <div class="olm-page"></div>
    
    1385
    +            </body>
    
    1386
    +            """,
    
    1387
    +            "olm-page is missing an id",
    
    1388
    +        )
    
    1389
    +
    
    1390
    +        # Missing olm-page.
    
    1391
    +        self.assert_body_raises(
    
    1392
    +            """
    
    1393
    +            <body>
    
    1394
    +              <div class="olm-page" id="ok"></div>
    
    1395
    +              <div class="heading-anchor" id="ok2"></div>
    
    1396
    +            </body>
    
    1397
    +            """,
    
    1398
    +            "Missing a page to use for the heading id",
    
    1399
    +        )
    
    1400
    +
    
    1401
    +        # heading-anchor with no id.
    
    1402
    +        self.assert_body_raises(
    
    1403
    +            """
    
    1404
    +            <body>
    
    1405
    +              <div class="olm-page" id="ok">
    
    1406
    +                <div class="heading-anchor"></div>
    
    1407
    +              </div>
    
    1408
    +            </body>
    
    1409
    +            """,
    
    1410
    +            "Heading is missing an id",
    
    1411
    +        )
    
    1412
    +
    
    1413
    +
    
    1414
    +class TestMain(unittest.TestCase):
    
    1415
    +    # Set the TestCase.maxDiff to a larger number to see the full HTML.
    
    1416
    +    maxDiff = 2000
    
    1417
    +
    
    1418
    +    def setUp(self) -> None:
    
    1419
    +        self.root_dir = tempfile.mkdtemp()
    
    1420
    +        try:
    
    1421
    +            os.chdir(self.root_dir)
    
    1422
    +            self.public_dir = os.path.join(self.root_dir, "public")
    
    1423
    +            self.out_dir = os.path.join(self.root_dir, "output")
    
    1424
    +            self.out_locales = os.path.join(self.root_dir, "locales/available")
    
    1425
    +            os.mkdir(self.public_dir)
    
    1426
    +            os.mkdir(self.out_dir)
    
    1427
    +            os.mkdir(os.path.dirname(self.out_locales))
    
    1428
    +        except:
    
    1429
    +            shutil.rmtree(self.root_dir)
    
    1430
    +            raise
    
    1431
    +
    
    1432
    +    def tearDown(self) -> None:
    
    1433
    +        shutil.rmtree(self.root_dir)
    
    1434
    +
    
    1435
    +    def assert_out_content(self, path: str, expect_content: str) -> None:
    
    1436
    +        with open(os.path.join(self.out_dir, path), encoding="utf-8") as file:
    
    1437
    +            self.assertEqual(file.read(), expect_content)
    
    1438
    +
    
    1439
    +    def test_locales(self) -> None:
    
    1440
    +        base = "offline/tor-browser/index.html"
    
    1441
    +        for locale, rel_path in (
    
    1442
    +            ("en", base),
    
    1443
    +            ("ar", f"ar/{base}"),
    
    1444
    +            ("zh-CN", f"zh-CN/{base}"),
    
    1445
    +            # Locales with the wrong lang tags should be ignored.
    
    1446
    +            ("inv", f"inv/{base}"),
    
    1447
    +            ("in-VAL", f"in-VAL/{base}"),
    
    1448
    +            (",", f",/{base}"),
    
    1449
    +        ):
    
    1450
    +            path = os.path.join(self.public_dir, rel_path)
    
    1451
    +            os.makedirs(os.path.dirname(path))
    
    1452
    +            with open(path, "w", encoding="utf-8") as file:
    
    1453
    +                file.write(
    
    1454
    +                    "<!DOCTYPE html>"
    
    1455
    +                    f'<html lang={locale} dir="rtl">'
    
    1456
    +                    f"<head><title>{locale} manual</title></head><body>"
    
    1457
    +                    '<a href="">"../../other">content</a>'
    
    1458
    +                    '<a href="">"../../tor-browser/sub-page"></a><div id="sub-page"></div>'
    
    1459
    +                    "</body></html>"
    
    1460
    +                )
    
    1461
    +        # Directories with the correct code, but no index.html file in the
    
    1462
    +        # expected place are ignored.
    
    1463
    +        os.makedirs(os.path.join(self.public_dir, "js"))
    
    1464
    +        os.makedirs(os.path.join(self.public_dir, "de/offline/tor-browser"))
    
    1465
    +        # File in wrong place:
    
    1466
    +        open(
    
    1467
    +            os.path.join(self.public_dir, "de/offline/index.html"),
    
    1468
    +            "w",
    
    1469
    +            encoding="utf-8",
    
    1470
    +        ).close()
    
    1471
    +        # Not a file:
    
    1472
    +        os.makedirs(os.path.join(self.public_dir, f"bb/{base}"))
    
    1473
    +
    
    1474
    +        main(self.public_dir, self.out_dir, self.out_locales)
    
    1475
    +
    
    1476
    +        self.assertCountEqual(
    
    1477
    +            os.listdir(self.out_dir),
    
    1478
    +            [
    
    1479
    +                "assets",
    
    1480
    +                "aboutManual-en.html",
    
    1481
    +                "aboutManual-ar.html",
    
    1482
    +                "aboutManual-zh-CN.html",
    
    1483
    +            ],
    
    1484
    +        )
    
    1485
    +        self.assertEqual(os.listdir(os.path.join(self.out_dir, "assets")), [])
    
    1486
    +
    
    1487
    +        with open(self.out_locales, encoding="utf-8") as file:
    
    1488
    +            self.assertEqual(file.read(), "ar,en,zh-CN", "list matches")
    
    1489
    +
    
    1490
    +        for locale, is_default in (("en", True), ("ar", False), ("zh-CN", False)):
    
    1491
    +            filename = f"aboutManual-{locale}.html"
    
    1492
    +            support_page = (
    
    1493
    +                "https://support.torproject.org/other"
    
    1494
    +                if is_default
    
    1495
    +                else f"https://support.torproject.org/{locale}/other"
    
    1496
    +            )
    
    1497
    +            self.assert_out_content(
    
    1498
    +                filename,
    
    1499
    +                "<!DOCTYPE html>\n"
    
    1500
    +                f'<html lang="{locale}" dir="rtl">\n<head>\n'
    
    1501
    +                f"  {EXPECT_CSP_META}\n"
    
    1502
    +                f"  <title>{locale} manual</title>\n"
    
    1503
    +                f"  {EXPECT_SCRIPT}\n"
    
    1504
    +                "</head>\n<body>"
    
    1505
    +                f'<a href="">"{support_page}" target="_blank" rel="noreferrer">content</a>'
    
    1506
    +                '<a href="">"#sub-page"></a><div id="sub-page"></div>'
    
    1507
    +                "</body>\n</html>\n",
    
    1508
    +            )
    
    1509
    +
    
    1510
    +        # Missing default locale's HTML.
    
    1511
    +        default_html = os.path.join(self.public_dir, base)
    
    1512
    +        os.unlink(default_html)
    
    1513
    +        with self.assertRaisesRegex(
    
    1514
    +            ValueError, rf"Missing file: {re.escape(default_html)}"
    
    1515
    +        ):
    
    1516
    +            main(self.public_dir, self.out_dir, self.out_locales)
    
    1517
    +
    
    1518
    +    def test_assets(self) -> None:
    
    1519
    +        def write_html(
    
    1520
    +            path: str, locale: str, style1: str, style2: str, image1: str, image2: str
    
    1521
    +        ) -> None:
    
    1522
    +            path = os.path.join(self.public_dir, path)
    
    1523
    +            os.makedirs(os.path.dirname(path), exist_ok=True)
    
    1524
    +            with open(path, "w", encoding="utf-8") as file:
    
    1525
    +                file.write(
    
    1526
    +                    "<!DOCTYPE html>\n"
    
    1527
    +                    f'<html lang={locale} dir="rtl">\n'
    
    1528
    +                    "<head>"
    
    1529
    +                    f'<link rel="stylesheet" href="">{style1}>'
    
    1530
    +                    "<title>Test</title>"
    
    1531
    +                    f'<link rel="stylesheet" href="">{style2}>'
    
    1532
    +                    "</head><body>"
    
    1533
    +                    f'<img src="">"{image1}"><img src="">"{image2}">'
    
    1534
    +                    "</body></html>"
    
    1535
    +                )
    
    1536
    +
    
    1537
    +        for rel_path, content in (
    
    1538
    +            ("offline/ltr.min.css", "0"),
    
    1539
    +            ("offline/rtl.min.css", "1"),
    
    1540
    +            ("static/style.css", "2"),
    
    1541
    +            ("offline/image.png", "3"),
    
    1542
    +            ("ar/offline/image.png", "4"),
    
    1543
    +            ("sub/page/image@xxxxxx", "5"),
    
    1544
    +            ("sub/page/image~2x.png", "6"),
    
    1545
    +        ):
    
    1546
    +            orig_path = os.path.join(self.public_dir, rel_path)
    
    1547
    +            os.makedirs(os.path.dirname(orig_path), exist_ok=True)
    
    1548
    +            with open(orig_path, "w", encoding="utf-8") as file:
    
    1549
    +                file.write(content)
    
    1550
    +
    
    1551
    +        write_html(
    
    1552
    +            "offline/tor-browser/index.html",
    
    1553
    +            "en",
    
    1554
    +            "../ltr.min.css",
    
    1555
    +            "../../static/style.css",
    
    1556
    +            "../image.png",
    
    1557
    +            "../../sub/page/image@xxxxxx",
    
    1558
    +        )
    
    1559
    +        write_html(
    
    1560
    +            "ar/offline/tor-browser/index.html",
    
    1561
    +            "ar",
    
    1562
    +            "../../../offline/rtl.min.css",
    
    1563
    +            "../../../static/style.css",
    
    1564
    +            "../image.png",
    
    1565
    +            "../../../sub/page/image@xxxxxx",
    
    1566
    +        )
    
    1567
    +
    
    1568
    +        main(self.public_dir, self.out_dir, self.out_locales)
    
    1569
    +
    
    1570
    +        self.assertCountEqual(
    
    1571
    +            os.listdir(self.out_dir),
    
    1572
    +            ["assets", "aboutManual-en.html", "aboutManual-ar.html"],
    
    1573
    +        )
    
    1574
    +        self.assertCountEqual(
    
    1575
    +            os.listdir(os.path.join(self.out_dir, "assets")),
    
    1576
    +            [
    
    1577
    +                "offline__ltr.min.css",
    
    1578
    +                "offline__rtl.min.css",
    
    1579
    +                "static__style.css",
    
    1580
    +                "offline__image.png",
    
    1581
    +                "ar__offline__image.png",
    
    1582
    +                "sub__page__image_2x.png",
    
    1583
    +            ],
    
    1584
    +        )
    
    1585
    +
    
    1586
    +        self.assert_out_content(
    
    1587
    +            "aboutManual-en.html",
    
    1588
    +            "<!DOCTYPE html>\n"
    
    1589
    +            '<html lang="en" dir="rtl">\n'
    
    1590
    +            "<head>\n"
    
    1591
    +            f"  {EXPECT_CSP_META}\n"
    
    1592
    +            "  <title>Test</title>\n"
    
    1593
    +            f"  {EXPECT_SCRIPT}\n"
    
    1594
    +            f'  <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/offline__ltr.min.css" />\n'
    
    1595
    +            f'  <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/static__style.css" />\n'
    
    1596
    +            "</head>\n<body>"
    
    1597
    +            f'<img src="">"{EXPECT_CHROME_ASSETS}/offline__image.png" />'
    
    1598
    +            f'<img src="">"{EXPECT_CHROME_ASSETS}/sub__page__image_2x.png" />'
    
    1599
    +            "</body>\n</html>\n",
    
    1600
    +        )
    
    1601
    +
    
    1602
    +        self.assert_out_content(
    
    1603
    +            "aboutManual-ar.html",
    
    1604
    +            "<!DOCTYPE html>\n"
    
    1605
    +            '<html lang="ar" dir="rtl">\n'
    
    1606
    +            "<head>\n"
    
    1607
    +            f"  {EXPECT_CSP_META}\n"
    
    1608
    +            "  <title>Test</title>\n"
    
    1609
    +            f"  {EXPECT_SCRIPT}\n"
    
    1610
    +            f'  <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/offline__rtl.min.css" />\n'
    
    1611
    +            f'  <link rel="stylesheet" href="">"{EXPECT_CHROME_ASSETS}/static__style.css" />\n'
    
    1612
    +            "</head>\n<body>"
    
    1613
    +            f'<img src="">"{EXPECT_CHROME_ASSETS}/ar__offline__image.png" />'
    
    1614
    +            f'<img src="">"{EXPECT_CHROME_ASSETS}/sub__page__image_2x.png" />'
    
    1615
    +            "</body>\n</html>\n",
    
    1616
    +        )
    
    1617
    +
    
    1618
    +        # Make sure the new assets were copied over.
    
    1619
    +        self.assert_out_content("assets/offline__ltr.min.css", "0")
    
    1620
    +        self.assert_out_content("assets/offline__rtl.min.css", "1")
    
    1621
    +        self.assert_out_content("assets/static__style.css", "2")
    
    1622
    +        self.assert_out_content("assets/offline__image.png", "3")
    
    1623
    +        self.assert_out_content("assets/ar__offline__image.png", "4")
    
    1624
    +        self.assert_out_content("assets/sub__page__image_2x.png", "5")
    
    1625
    +
    
    1626
    +        # Duplicate asset names, from different files.
    
    1627
    +        write_html(
    
    1628
    +            "offline/tor-browser/index.html",
    
    1629
    +            "en",
    
    1630
    +            "../ltr.min.css",
    
    1631
    +            "../../static/style.css",
    
    1632
    +            "../image.png",
    
    1633
    +            "../../sub/page/image~2x.png",
    
    1634
    +        )
    
    1635
    +        with self.assertRaisesRegex(
    
    1636
    +            ValueError, r"^Duplicate asset names: sub__page__image_2x.png$"
    
    1637
    +        ):
    
    1638
    +            main(self.public_dir, self.out_dir, self.out_locales)
    
    1639
    +
    
    1640
    +        # Wrong relative path.
    
    1641
    +        write_html(
    
    1642
    +            "ar/offline/tor-browser/index.html",
    
    1643
    +            "ar",
    
    1644
    +            "../../../offline/rtl.min.css",
    
    1645
    +            # Wrong relative path for the "ar" directory:
    
    1646
    +            "../static/style.css",
    
    1647
    +            "../image.png",
    
    1648
    +            "../../../sub/page/image~2x.png",
    
    1649
    +        )
    
    1650
    +        with self.assertRaisesRegex(
    
    1651
    +            ValueError,
    
    1652
    +            r"^.*: References a non-existent asset: .*/ar/offline/static/style\.css$",
    
    1653
    +        ):
    
    1654
    +            main(self.public_dir, self.out_dir, self.out_locales)

  • _______________________________________________
    tor-commits mailing list -- tor-commits@xxxxxxxxxxxxxxxxxxxx
    To unsubscribe send an email to tor-commits-leave@xxxxxxxxxxxxxxxxxxxx