|
| 1 | +#!/usr/bin/env python3 |
| 2 | +import argparse |
| 3 | +import re |
| 4 | +import sys |
| 5 | +from pathlib import Path |
| 6 | + |
| 7 | +try: |
| 8 | + import tomllib |
| 9 | +except ModuleNotFoundError: |
| 10 | + import tomli as tomllib |
| 11 | + |
| 12 | + |
| 13 | +def parse_redirects(filepath): |
| 14 | + rules = [] |
| 15 | + with open(filepath) as f: |
| 16 | + for i, line in enumerate(f, 1): |
| 17 | + line = line.strip() |
| 18 | + if not line or line.startswith("#"): |
| 19 | + continue |
| 20 | + rule = parse_redirect_line(line) |
| 21 | + if rule: |
| 22 | + rules.append(rule) |
| 23 | + else: |
| 24 | + print( |
| 25 | + f"Warning: Could not parse line {i}: {line}", |
| 26 | + file=sys.stderr, |
| 27 | + ) |
| 28 | + return rules |
| 29 | + |
| 30 | + |
| 31 | +def parse_redirect_line(line): |
| 32 | + parts = line.split() |
| 33 | + if len(parts) < 3: |
| 34 | + return None |
| 35 | + |
| 36 | + status_raw = parts[-1] |
| 37 | + status_match = re.match(r"^(\d{3})(!)?$", status_raw) |
| 38 | + if not status_match: |
| 39 | + return None |
| 40 | + status = int(status_match.group(1)) |
| 41 | + force = bool(status_match.group(2)) |
| 42 | + |
| 43 | + before_status = parts[:-1] |
| 44 | + to_url = before_status[-1] |
| 45 | + from_parts = before_status[:-1] |
| 46 | + |
| 47 | + if not from_parts: |
| 48 | + return None |
| 49 | + |
| 50 | + from_pattern = from_parts[0] |
| 51 | + query_param_parts = from_parts[1:] |
| 52 | + |
| 53 | + query_params = [] |
| 54 | + for qp in query_param_parts: |
| 55 | + if "=" in qp: |
| 56 | + key, val = qp.split("=", 1) |
| 57 | + query_params.append((key, val)) |
| 58 | + |
| 59 | + return { |
| 60 | + "from": from_pattern, |
| 61 | + "to": to_url, |
| 62 | + "status": status, |
| 63 | + "force": force, |
| 64 | + "query_params": query_params, |
| 65 | + } |
| 66 | + |
| 67 | + |
| 68 | +def redirect_to_htaccess(rule): |
| 69 | + lines = [] |
| 70 | + from_pattern = rule["from"] |
| 71 | + to_url = rule["to"] |
| 72 | + status = rule["status"] |
| 73 | + query_params = rule["query_params"] |
| 74 | + |
| 75 | + if status == 404 and from_pattern == "/*": |
| 76 | + return [f"ErrorDocument 404 {to_url}"] |
| 77 | + |
| 78 | + if from_pattern.startswith(("http://", "https://")): |
| 79 | + url_match = re.match(r"^(https?://)?([^/]+)(/.*)?$", from_pattern) |
| 80 | + if url_match: |
| 81 | + scheme = url_match.group(1) |
| 82 | + host = url_match.group(2) |
| 83 | + from_path = url_match.group(3) |
| 84 | + if from_path is None: |
| 85 | + from_path = "/*" |
| 86 | + host_escaped = re.escape(host) |
| 87 | + lines.append(f"RewriteCond %{{HTTP_HOST}} ^{host_escaped}$ [NC]") |
| 88 | + if scheme == "http://": |
| 89 | + lines.append("RewriteCond %{HTTPS} off") |
| 90 | + elif scheme == "https://": |
| 91 | + lines.append("RewriteCond %{HTTPS} on") |
| 92 | + else: |
| 93 | + return [] |
| 94 | + else: |
| 95 | + from_path = from_pattern |
| 96 | + |
| 97 | + from_regex_chars = [] |
| 98 | + i = 0 |
| 99 | + while i < len(from_path): |
| 100 | + ch = from_path[i] |
| 101 | + if ch == "*": |
| 102 | + from_regex_chars.append("(.*)") |
| 103 | + elif ch in ".+?^${}[]|\\()": |
| 104 | + from_regex_chars.append("\\" + ch) |
| 105 | + else: |
| 106 | + from_regex_chars.append(ch) |
| 107 | + i += 1 |
| 108 | + from_regex = "".join(from_regex_chars) |
| 109 | + |
| 110 | + if from_regex.startswith("/"): |
| 111 | + from_regex = from_regex[1:] |
| 112 | + |
| 113 | + replacement = to_url.replace(":splat", "$1") |
| 114 | + base_url, _, qs = replacement.partition("?") |
| 115 | + if qs and re.search(r":\w+", qs): |
| 116 | + replacement = base_url |
| 117 | + |
| 118 | + flags_parts = [f"R={status}", "L"] |
| 119 | + if query_params: |
| 120 | + flags_parts.append("QSA") |
| 121 | + flags = ",".join(flags_parts) |
| 122 | + |
| 123 | + for key, val in query_params: |
| 124 | + escaped_key = re.escape(key) |
| 125 | + if val and not val.startswith(":"): |
| 126 | + escaped_val = re.escape(val) |
| 127 | + cond = f"(?:^|&){escaped_key}={escaped_val}(?:&|$)" |
| 128 | + else: |
| 129 | + cond = f"(?:^|&){escaped_key}=[^&]*(?:&|$)" |
| 130 | + lines.append(f"RewriteCond %{{QUERY_STRING}} {cond} [NC]") |
| 131 | + |
| 132 | + lines.append(f"RewriteRule ^{from_regex}$ {replacement} [{flags}]") |
| 133 | + return lines |
| 134 | + |
| 135 | + |
| 136 | +def parse_netlify_headers(filepath): |
| 137 | + with open(filepath, "rb") as f: |
| 138 | + data = tomllib.load(f) |
| 139 | + |
| 140 | + headers_list = data.get("headers", []) |
| 141 | + if not isinstance(headers_list, list): |
| 142 | + return [] |
| 143 | + |
| 144 | + result = [] |
| 145 | + for entry in headers_list: |
| 146 | + for_val = entry.get("for", "/*") |
| 147 | + values = entry.get("values", {}) |
| 148 | + if values: |
| 149 | + result.append({"for": for_val, "values": dict(values)}) |
| 150 | + return result |
| 151 | + |
| 152 | + |
| 153 | +def headers_to_htaccess(headers): |
| 154 | + lines = ["<IfModule mod_headers.c>"] |
| 155 | + |
| 156 | + for entry in headers: |
| 157 | + for_val = entry["for"] |
| 158 | + values = entry["values"] |
| 159 | + |
| 160 | + if for_val == "/*": |
| 161 | + for key, val in values.items(): |
| 162 | + lines.append(f' Header set {key} "{val}"') |
| 163 | + else: |
| 164 | + apache_pattern = for_val.replace("*", ".*") |
| 165 | + if apache_pattern.startswith("/"): |
| 166 | + apache_pattern = "^" + apache_pattern |
| 167 | + env_name = re.sub(r"[^a-zA-Z0-9_]+", "_", for_val).strip("_") |
| 168 | + env_var = "hdr_" + env_name if env_name else "hdr_custom" |
| 169 | + lines.append( |
| 170 | + f' SetEnvIf Request_URI "{apache_pattern}" {env_var}' |
| 171 | + ) |
| 172 | + for key, val in values.items(): |
| 173 | + lines.append(f' Header set {key} "{val}" env={env_var}') |
| 174 | + |
| 175 | + lines.append("</IfModule>") |
| 176 | + return lines |
| 177 | + |
| 178 | + |
| 179 | +def strip_template_tags(content): |
| 180 | + out_lines = [] |
| 181 | + for line in content.splitlines(): |
| 182 | + stripped = line.strip() |
| 183 | + if stripped in ( |
| 184 | + "{% verbatim %}", |
| 185 | + "{% endverbatim %}", |
| 186 | + "{%verbatim%}", |
| 187 | + "{%endverbatim%}", |
| 188 | + ): |
| 189 | + continue |
| 190 | + out_lines.append(line) |
| 191 | + return "\n".join(out_lines) |
| 192 | + |
| 193 | + |
| 194 | +def main(): |
| 195 | + parser = argparse.ArgumentParser( |
| 196 | + description=( |
| 197 | + "Generate .htaccess from Netlify _redirects and netlify.toml" |
| 198 | + ) |
| 199 | + ) |
| 200 | + parser.add_argument("--template", "-t", help="Base htaccess template file") |
| 201 | + parser.add_argument( |
| 202 | + "--redirects", |
| 203 | + "-r", |
| 204 | + default="content/_redirects", |
| 205 | + help="Path to _redirects file (default: content/_redirects)", |
| 206 | + ) |
| 207 | + parser.add_argument( |
| 208 | + "--netlify-config", |
| 209 | + "-n", |
| 210 | + default="netlify.toml", |
| 211 | + help="Path to netlify.toml (default: netlify.toml)", |
| 212 | + ) |
| 213 | + parser.add_argument("--output", "-o", help="Output file (default: stdout)") |
| 214 | + args = parser.parse_args() |
| 215 | + |
| 216 | + out_lines = [] |
| 217 | + |
| 218 | + if args.template: |
| 219 | + template_path = Path(args.template) |
| 220 | + if template_path.exists(): |
| 221 | + content = strip_template_tags(template_path.read_text()) |
| 222 | + if content.strip(): |
| 223 | + out_lines.append(content.rstrip()) |
| 224 | + else: |
| 225 | + print( |
| 226 | + f"Warning: template file not found: {args.template}", |
| 227 | + file=sys.stderr, |
| 228 | + ) |
| 229 | + |
| 230 | + if args.redirects: |
| 231 | + redirects_path = Path(args.redirects) |
| 232 | + if redirects_path.exists(): |
| 233 | + rules = parse_redirects(args.redirects) |
| 234 | + redirect_lines = [] |
| 235 | + for rule in rules: |
| 236 | + rule_lines = redirect_to_htaccess(rule) |
| 237 | + if rule_lines: |
| 238 | + redirect_lines.extend(rule_lines) |
| 239 | + redirect_lines.append("") |
| 240 | + if redirect_lines: |
| 241 | + if out_lines and out_lines[-1] != "": |
| 242 | + out_lines.append("") |
| 243 | + out_lines.append("# Redirects from content/_redirects") |
| 244 | + out_lines.extend(redirect_lines) |
| 245 | + else: |
| 246 | + print( |
| 247 | + f"Warning: redirects file not found: {args.redirects}", |
| 248 | + file=sys.stderr, |
| 249 | + ) |
| 250 | + |
| 251 | + if args.netlify_config: |
| 252 | + config_path = Path(args.netlify_config) |
| 253 | + if config_path.exists(): |
| 254 | + headers = parse_netlify_headers(args.netlify_config) |
| 255 | + if headers: |
| 256 | + if out_lines and out_lines[-1] != "": |
| 257 | + out_lines.append("") |
| 258 | + header_lines = headers_to_htaccess(headers) |
| 259 | + out_lines.extend(header_lines) |
| 260 | + out_lines.append("") |
| 261 | + else: |
| 262 | + print( |
| 263 | + f"Warning: netlify config not found: {args.netlify_config}", |
| 264 | + file=sys.stderr, |
| 265 | + ) |
| 266 | + |
| 267 | + content = "\n".join(out_lines) |
| 268 | + |
| 269 | + if args.output: |
| 270 | + out_path = Path(args.output) |
| 271 | + out_path.parent.mkdir(parents=True, exist_ok=True) |
| 272 | + out_path.write_text(content) |
| 273 | + else: |
| 274 | + sys.stdout.write(content) |
| 275 | + sys.stdout.write("\n") |
| 276 | + |
| 277 | + |
| 278 | +if __name__ == "__main__": |
| 279 | + main() |
0 commit comments