Skip to content

Instantly share code, notes, and snippets.

@lukemelia
Created February 10, 2026 23:19
Show Gist options
  • Select an option

  • Save lukemelia/22b96ecc84671b645d8108a5adbf50a0 to your computer and use it in GitHub Desktop.

Select an option

Save lukemelia/22b96ecc84671b645d8108a5adbf50a0 to your computer and use it in GitHub Desktop.
Move <template> blocks to bottom of class bodies in .gjs files
#!/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