# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. # pylint: disable=redefined-outer-name, missing-module-docstring import argparse import hashlib import os import re from collections.abc import Callable from html.parser import HTMLParser from urllib.parse import urlparse import requests # NOTE: This script is called by the Makefile via `make htmldepoly`. # It's not called every time the docs are built on CI. However, it's # can be only called during deployment stage, instead of building the docs. # Also, we can download the resources manually before running this script to # avoid the overhead of downloading the resources every time the docs are built. # Set to store unique external URLs found during processing BASE_URL = "https://tvm.apache.org" SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) HTML_DIR = os.path.join(SCRIPT_DIR, "_build/html") class ExternalURLParser(HTMLParser): """HTML Parser to find external URLs in HTML content.""" def __init__(self): super().__init__() self.external_urls: list[str] = [] self.base_domain = urlparse(BASE_URL).netloc # Tags and their attributes that might contain external resources self.tags_to_check = { "img": "src", "script": "src", "iframe": "src", "video": "src", "audio": "src", "link": "href", "source": "src", "embed": "src", } def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: """Handle HTML start tags to find external URLs.""" if tag not in self.tags_to_check: return attr_name = self.tags_to_check[tag] for name, value in attrs: if name != attr_name or not value: continue if value.startswith(("http://", "https://")): domain = urlparse(value).netloc if domain and domain != self.base_domain: self.external_urls.append(value) def detect_html_external_urls(html_content: str) -> list[str]: """ Detect third-party embedded resources in HTML content. Parameters ---------- html_content : str The HTML content to analyze Returns ------- List[str] List of external URLs found in the HTML content """ parser = ExternalURLParser() parser.feed(html_content) return parser.external_urls def detect_css_external_urls(css_content: str) -> list[str]: """ Detect external URLs in CSS content. Parameters ---------- css_content : str The CSS content to analyze Returns ------- List[str] List of external URLs found in the CSS content """ external_urls: list[str] = [] # Regex to find URLs in CSS url_pattern = re.compile(r'url\(["\']?(.*?)["\']?\)') matches = url_pattern.findall(css_content) for match in matches: if match.startswith(("http://", "https://")) and not match.startswith(BASE_URL): external_urls.append(match) return external_urls def all_files_in_dir(path: str) -> list[str]: """ Get a list of all files in a directory and its subdirectories. Parameters ---------- path : str The root directory path to search Returns ------- List[str] List of full paths to all files found """ return [os.path.join(root, file) for root, _, files in os.walk(path) for file in files] def detect_urls(files: list[str], verbose: bool = False) -> list[str]: """ Detect external URLs in the given HTML and CSS files. Parameters ---------- files : List[str] List of file paths to check for external URLs verbose : bool, optional Whether to print verbose output, by default False Returns ------- List[str] List of external URLs found in the files """ external_urls: set[str] = set() for file in files: f_detect: Callable[[str, str], list[str]] | None = None if file.endswith(".html"): f_detect = detect_html_external_urls elif file.endswith(".css"): f_detect = detect_css_external_urls else: continue with open(file, encoding="utf-8") as f: content = f.read() urls = f_detect(content) if verbose: print(f"Processing {file}") exist_urls, new_urls = 0, 0 for url in urls: if url in external_urls: exist_urls += 1 else: new_urls += 1 if verbose: print(f"Found new {url}") print(f"Found {exist_urls} existing resources and {new_urls} new resources") external_urls.update(urls) if verbose: print(f"Total {len(external_urls)} external resources") print("External resources:") print("\n".join(external_urls)) return list(external_urls) def download_external_urls( external_urls: list[str], verbose: bool = False ) -> tuple[dict[str, str], list[str]]: """ Download external URLs and save them to docs/_static/downloads. Parameters ---------- external_urls : List[str] List of external URLs to download verbose : bool, optional Whether to print verbose output, by default False Returns ------- Tuple[Dict[str, str], List[str]] A tuple containing: - Dictionary mapping original URLs to their downloaded file paths - List of paths to all downloaded files (including source maps) """ download_dir = os.path.join(HTML_DIR, "_static/downloads") os.makedirs(download_dir, exist_ok=True) used_file_names: set[str] = set() downloaded_files: list[str] = [] remap_urls: dict[str, str] = {} for url in external_urls: query = urlparse(url).query if url.startswith("https://fonts.googleapis.com/css2"): file_name = f"{hashlib.md5(url.encode()).hexdigest()}.css" elif query: raise ValueError(f"Unsupported URL with query: {url}") else: file_name = urlparse(url).path.split("/")[-1] if verbose: print(f"remapping {url} to {file_name}") if file_name in used_file_names: raise ValueError(f"File name {file_name} already exists") used_file_names.add(file_name) response = requests.get(url, timeout=30) body = response.content with open(os.path.join(download_dir, file_name), "wb") as f: f.write(body) remap_urls[url] = os.path.join(download_dir, file_name) downloaded_files.append(os.path.join(download_dir, file_name)) # Also download the sourceMappingURL if not url.startswith("https://fonts.googleapis.com/css2"): map_file_name = f"{file_name}.map" response = requests.get(f"{url}.map", timeout=30) if response.status_code == 200: body = response.content with open(os.path.join(download_dir, map_file_name), "wb") as f: f.write(body) if verbose: print(f"Downloaded {map_file_name} for {url}") downloaded_files.append(os.path.join(download_dir, map_file_name)) return remap_urls, downloaded_files def replace_urls_in_files(remap_urls: dict[str, str], verbose: bool = False): """ Replace external URLs with their downloaded versions in HTML/CSS files. Parameters ---------- remap_urls : Dict[str, str] Dictionary mapping original URLs to their downloaded file paths verbose : bool, optional Whether to print verbose output, by default False """ for root, _, files in os.walk(HTML_DIR): for file in files: if not (file.endswith(".html") or file.endswith(".css")): continue file_path = os.path.join(root, file) if verbose: print(f"Processing {file_path}") # Calculate relative path from current file to _static/downloads rel_path = os.path.relpath( os.path.join(HTML_DIR, "_static/downloads"), os.path.dirname(file_path) ) with open(file_path, encoding="utf-8") as f: content = f.read() new_content = content for original_url, new_path in remap_urls.items(): relative_url = os.path.join(rel_path, os.path.basename(new_path)) new_content = new_content.replace(original_url, relative_url) if new_content != content: with open(file_path, "w", encoding="utf-8") as f: f.write(new_content) if verbose: print(f"Updated {file_path}") def download_and_replace_urls(files: list[str] | None = None, verbose: bool = False): """ Download external URLs found in files and replace them with local copies. Recursively processes any new external URLs found in downloaded content. Parameters ---------- files : Optional[List[str]], optional List of files to check for external URLs. If None, checks all files under HTML_DIR verbose : bool, optional Whether to print verbose output, by default False """ if files is None: files = all_files_in_dir(HTML_DIR) remap_urls = {} while True: external_urls = detect_urls(files, verbose=verbose) if not external_urls: break round_remap_urls, files = download_external_urls(external_urls, verbose=verbose) remap_urls.update(round_remap_urls) replace_urls_in_files(remap_urls, verbose=verbose) if __name__ == "__main__": args = argparse.ArgumentParser() args.add_argument("-v", "--verbose", action="store_true") args.add_argument("-p", "--path", type=str, default=None) args = args.parse_args() if args.path is not None: HTML_DIR = args.path download_and_replace_urls(verbose=args.verbose)