Created
September 26, 2026 00:53
-
-
Save nyanpasu64/cc1dc692bb15eb98966187fb64660e63 to your computer and use it in GitHub Desktop.
AI-generated script to migrate Zola pages to 0.22 to 0.23 (does not fix non-strings)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Migrate old Tera macro calls in Markdown files: | |
| {{ youtube(id="abc", autoplay=true) }} | |
| becomes: | |
| {{ <youtube id="abc" autoplay=true /> }} | |
| Commas inside quoted strings or backtick-delimited strings are preserved. | |
| Nested parentheses are also handled. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| from pathlib import Path | |
| def matching_paren(text: str, opening: int) -> int: | |
| """Return the index of the parenthesis matching text[opening].""" | |
| depth = 0 | |
| quote: str | None = None | |
| escaped = False | |
| for i in range(opening, len(text)): | |
| char = text[i] | |
| if quote: | |
| if escaped: | |
| escaped = False | |
| elif char == "\\" and quote != "`": | |
| escaped = True | |
| elif char == quote: | |
| quote = None | |
| continue | |
| if char in "\"'`": | |
| quote = char | |
| elif char == "(": | |
| depth += 1 | |
| elif char == ")": | |
| depth -= 1 | |
| if depth == 0: | |
| return i | |
| raise ValueError("Unmatched opening parenthesis") | |
| def split_arguments(arguments: str) -> list[str]: | |
| """ | |
| Split function arguments on commas that are not inside strings or | |
| nested parentheses/brackets/braces. | |
| """ | |
| result: list[str] = [] | |
| start = 0 | |
| stack: list[str] = [] | |
| quote: str | None = None | |
| escaped = False | |
| pairs = {")": "(", "]": "[", "}": "{"} | |
| for i, char in enumerate(arguments): | |
| if quote: | |
| if escaped: | |
| escaped = False | |
| elif char == "\\" and quote != "`": | |
| escaped = True | |
| elif char == quote: | |
| quote = None | |
| continue | |
| if char in "\"'`": | |
| quote = char | |
| elif char in "([{": | |
| stack.append(char) | |
| elif char in ")]}": | |
| if stack and stack[-1] == pairs[char]: | |
| stack.pop() | |
| elif char == "," and not stack: | |
| result.append(arguments[start:i].strip()) | |
| start = i + 1 | |
| result.append(arguments[start:].strip()) | |
| return [item for item in result if item] | |
| def migrate_expression(expression: str) -> str: | |
| """ | |
| Convert: | |
| youtube(id="abc", autoplay=true) | |
| into: | |
| <youtube id="abc" autoplay=true /> | |
| """ | |
| expression = expression.strip() | |
| # Find the first opening parenthesis outside a string. | |
| quote: str | None = None | |
| escaped = False | |
| opening = -1 | |
| for i, char in enumerate(expression): | |
| if quote: | |
| if escaped: | |
| escaped = False | |
| elif char == "\\" and quote != "`": | |
| escaped = True | |
| elif char == quote: | |
| quote = None | |
| elif char in "\"'`": | |
| quote = char | |
| elif char == "(": | |
| opening = i | |
| break | |
| # Not a function call; leave it unchanged. | |
| if opening == -1: | |
| return expression | |
| closing = matching_paren(expression, opening) | |
| # Only migrate expressions whose outermost call ends at the end. | |
| if expression[closing + 1 :].strip(): | |
| return expression | |
| name = expression[:opening].strip() | |
| arguments = expression[opening + 1 : closing] | |
| # Optional: convert macros::youtube(...) to youtube(...). | |
| # Remove this line if namespace preservation is required. | |
| name = name.rsplit("::", 1)[-1] | |
| args = split_arguments(arguments) | |
| if args: | |
| return f"<{name} {' '.join(args)} />" | |
| return f"<{name} />" | |
| def migrate_mustaches(text: str) -> str: | |
| """Migrate all {{ ... }} expressions in a Markdown document.""" | |
| output: list[str] = [] | |
| position = 0 | |
| while True: | |
| start = text.find("{{", position) | |
| if start == -1: | |
| output.append(text[position:]) | |
| break | |
| end = text.find("}}", start + 2) | |
| if end == -1: | |
| output.append(text[position:]) | |
| break | |
| output.append(text[position:start]) | |
| expression = text[start + 2 : end] | |
| try: | |
| migrated = migrate_expression(expression) | |
| except ValueError: | |
| # Leave malformed expressions unchanged. | |
| migrated = expression.strip() | |
| output.append("{{ " + migrated + " }}") | |
| position = end + 2 | |
| return "".join(output) | |
| def migrate_file(path: Path, output_dir: Path | None, in_place: bool) -> None: | |
| original = path.read_text(encoding="utf-8") | |
| migrated = migrate_mustaches(original) | |
| if in_place: | |
| destination = path | |
| elif output_dir: | |
| destination = output_dir / path.name | |
| destination.parent.mkdir(parents=True, exist_ok=True) | |
| else: | |
| print(migrated, end="") | |
| return | |
| destination.write_text(migrated, encoding="utf-8") | |
| print(f"Migrated {path} -> {destination}") | |
| def main() -> None: | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("path", type=Path, help="Markdown file or directory") | |
| parser.add_argument( | |
| "--in-place", | |
| action="store_true", | |
| help="Modify files directly", | |
| ) | |
| parser.add_argument( | |
| "--output-dir", | |
| type=Path, | |
| help="Write migrated files to this directory", | |
| ) | |
| args = parser.parse_args() | |
| if args.in_place and args.output_dir: | |
| parser.error("--in-place and --output-dir cannot be used together") | |
| if args.path.is_file(): | |
| files = [args.path] | |
| else: | |
| files = sorted(args.path.rglob("*.md")) | |
| for path in files: | |
| migrate_file(path, args.output_dir, args.in_place) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment