Created
February 10, 2026 23:19
-
-
Save lukemelia/22b96ecc84671b645d8108a5adbf50a0 to your computer and use it in GitHub Desktop.
Move <template> blocks to bottom of class bodies in .gjs files
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 | |
| """ | |
| Move <template> blocks to the bottom of class bodies in .gjs files. | |
| In Ember/Glimmer .gjs files, the convention is for the <template> tag to | |
| appear at the bottom of the class body, just before the closing }. | |
| This script finds classes where <template> appears NOT at the bottom and | |
| moves it there. | |
| Usage: | |
| python3 move-template-to-bottom.py [--dry-run] <file_or_dir> [...] | |
| """ | |
| import sys | |
| import os | |
| import re | |
| import argparse | |
| def find_template_block(lines, start): | |
| """Find a <template> ... </template> block starting at line index `start`. | |
| Returns (start_idx, end_idx) inclusive, or None.""" | |
| if '<template>' not in lines[start]: | |
| return None | |
| depth = 0 | |
| for i in range(start, len(lines)): | |
| depth += lines[i].count('<template>') - lines[i].count('</template>') | |
| if depth == 0: | |
| return (start, i) | |
| return None | |
| def find_class_with_top_template(content): | |
| """Parse file content and find classes where <template> is not at the bottom. | |
| Returns a list of fixes to apply, or empty if nothing to do.""" | |
| lines = content.split('\n') | |
| # Find class declarations | |
| # Pattern: "export default class Foo extends Bar {" or "class Foo {" | |
| class_pattern = re.compile(r'^(\s*)(export\s+default\s+)?class\s+\w+') | |
| fixes = [] | |
| i = 0 | |
| while i < len(lines): | |
| m = class_pattern.match(lines[i]) | |
| if m and '{' in lines[i]: | |
| class_start = i | |
| indent = m.group(1) | |
| # Find the matching closing brace for this class | |
| brace_depth = 0 | |
| class_end = None | |
| for j in range(class_start, len(lines)): | |
| brace_depth += lines[j].count('{') - lines[j].count('}') | |
| # Don't count braces inside <template> tags as JS braces | |
| # Actually in .gjs the parser handles this, but for our | |
| # purposes we need to track {{ }} in templates | |
| if brace_depth == 0 and j > class_start: | |
| class_end = j | |
| break | |
| if class_end is None: | |
| i += 1 | |
| continue | |
| # Look for <template> block inside this class | |
| template_block = None | |
| for k in range(class_start + 1, class_end): | |
| stripped = lines[k].strip() | |
| if stripped.startswith('<template>') or stripped == '<template>': | |
| template_block = find_template_block(lines, k) | |
| break | |
| if template_block: | |
| tmpl_start, tmpl_end = template_block | |
| # Check if template is already at the bottom | |
| # "at the bottom" means there's nothing but whitespace/comments | |
| # between template end and class closing brace | |
| is_at_bottom = True | |
| for k in range(tmpl_end + 1, class_end): | |
| if lines[k].strip() and not lines[k].strip().startswith('//'): | |
| is_at_bottom = False | |
| break | |
| if not is_at_bottom: | |
| fixes.append({ | |
| 'class_start': class_start, | |
| 'class_end': class_end, | |
| 'tmpl_start': tmpl_start, | |
| 'tmpl_end': tmpl_end, | |
| }) | |
| i = class_end + 1 | |
| else: | |
| i += 1 | |
| return fixes | |
| def apply_fix(content, fix): | |
| """Move the template block to just before the class closing brace.""" | |
| lines = content.split('\n') | |
| tmpl_start = fix['tmpl_start'] | |
| tmpl_end = fix['tmpl_end'] | |
| class_end = fix['class_end'] | |
| # Extract template block lines | |
| template_lines = lines[tmpl_start:tmpl_end + 1] | |
| # Remove blank lines immediately before and after the template block | |
| # (to avoid leaving gaps) | |
| remove_start = tmpl_start | |
| remove_end = tmpl_end | |
| # Expand removal to include surrounding blank lines | |
| while remove_start > 0 and lines[remove_start - 1].strip() == '': | |
| remove_start -= 1 | |
| while remove_end < len(lines) - 1 and lines[remove_end + 1].strip() == '': | |
| remove_end += 1 | |
| # Build new content: | |
| # 1. Everything before the template block (minus trailing blanks) | |
| before = lines[:remove_start] | |
| # 2. Everything after template block up to (but not including) class closing brace | |
| after_template = lines[remove_end + 1:class_end] | |
| # Remove trailing blank lines from after_template | |
| while after_template and after_template[-1].strip() == '': | |
| after_template.pop() | |
| # 3. Blank line + template block + class closing brace + rest | |
| rest = lines[class_end:] # includes the closing } | |
| new_lines = before + after_template + [''] + template_lines + rest | |
| return '\n'.join(new_lines) | |
| def process_file(filepath, dry_run=False): | |
| """Process a single .gjs file. Returns True if changes were made/needed.""" | |
| with open(filepath, 'r') as f: | |
| content = f.read() | |
| fixes = find_class_with_top_template(content) | |
| if not fixes: | |
| return False | |
| if dry_run: | |
| for fix in fixes: | |
| print(f" WOULD FIX: {filepath} (template at line {fix['tmpl_start'] + 1}, class ends at line {fix['class_end'] + 1})") | |
| return True | |
| # Apply fixes in reverse order (so line numbers stay valid) | |
| for fix in reversed(fixes): | |
| content = apply_fix(content, fix) | |
| with open(filepath, 'w') as f: | |
| f.write(content) | |
| for fix in fixes: | |
| print(f" FIXED: {filepath} (template moved to bottom of class)") | |
| return True | |
| def main(): | |
| parser = argparse.ArgumentParser(description='Move <template> to bottom of class in .gjs files') | |
| parser.add_argument('paths', nargs='+', help='Files or directories to process') | |
| parser.add_argument('--dry-run', action='store_true', help='Show what would be changed without modifying files') | |
| args = parser.parse_args() | |
| files = [] | |
| for path in args.paths: | |
| if os.path.isfile(path) and path.endswith('.gjs'): | |
| files.append(path) | |
| elif os.path.isdir(path): | |
| for root, dirs, filenames in os.walk(path): | |
| # Skip node_modules and hidden dirs | |
| dirs[:] = [d for d in dirs if not d.startswith('.') and d != 'node_modules'] | |
| for fn in filenames: | |
| if fn.endswith('.gjs'): | |
| files.append(os.path.join(root, fn)) | |
| affected = 0 | |
| for f in sorted(files): | |
| if process_file(f, dry_run=args.dry_run): | |
| affected += 1 | |
| mode = "would affect" if args.dry_run else "fixed" | |
| print(f"\n{affected} file(s) {mode} out of {len(files)} .gjs files scanned.") | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment