Initial commit
This commit is contained in:
239
skills/revalidation-strategy-planner/scripts/analyze_routes.py
Normal file
239
skills/revalidation-strategy-planner/scripts/analyze_routes.py
Normal file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze Next.js routes and recommend revalidation strategies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class RouteAnalyzer:
|
||||
def __init__(self, app_dir: Path):
|
||||
self.app_dir = app_dir
|
||||
self.routes = []
|
||||
|
||||
def analyze(self):
|
||||
"""Analyze all routes in the app directory."""
|
||||
self.routes = []
|
||||
self._scan_directory(self.app_dir)
|
||||
return self.routes
|
||||
|
||||
def _scan_directory(self, directory: Path, route_path: str = ""):
|
||||
"""Recursively scan directory for routes."""
|
||||
if not directory.exists():
|
||||
print(f"Warning: Directory not found: {directory}")
|
||||
return
|
||||
|
||||
for item in directory.iterdir():
|
||||
if item.name.startswith('_') or item.name.startswith('.'):
|
||||
continue
|
||||
|
||||
if item.is_dir():
|
||||
# Handle route groups (groupName) and dynamic routes [param]
|
||||
if item.name.startswith('(') and item.name.endswith(')'):
|
||||
# Route group - doesn't affect URL
|
||||
self._scan_directory(item, route_path)
|
||||
elif item.name.startswith('[') and item.name.endswith(']'):
|
||||
# Dynamic route segment
|
||||
param = item.name
|
||||
self._scan_directory(item, f"{route_path}/{param}")
|
||||
else:
|
||||
# Regular route segment
|
||||
self._scan_directory(item, f"{route_path}/{item.name}")
|
||||
|
||||
elif item.name == 'page.tsx' or item.name == 'page.js':
|
||||
# Found a page route
|
||||
route = self._analyze_route_file(item, route_path or '/')
|
||||
if route:
|
||||
self.routes.append(route)
|
||||
|
||||
def _analyze_route_file(self, file_path: Path, route_path: str) -> Optional[Dict]:
|
||||
"""Analyze a page file and determine recommendations."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Detect existing configuration
|
||||
has_revalidate = 'export const revalidate' in content
|
||||
has_dynamic = 'export const dynamic' in content
|
||||
has_fetch = 'fetch(' in content or 'await' in content
|
||||
has_suspense = 'Suspense' in content
|
||||
has_params = '[' in route_path and ']' in route_path
|
||||
has_searchparams = 'searchParams' in content
|
||||
|
||||
# Determine route characteristics
|
||||
is_dynamic_route = has_params
|
||||
is_personalized = any(
|
||||
keyword in content.lower()
|
||||
for keyword in ['session', 'user', 'auth', 'dashboard']
|
||||
)
|
||||
is_list = any(
|
||||
keyword in route_path.lower()
|
||||
for keyword in ['/entities', '/timeline', '/characters', '/locations']
|
||||
)
|
||||
is_detail = is_dynamic_route and not is_list
|
||||
|
||||
# Make recommendations
|
||||
recommendation = self._recommend_strategy(
|
||||
is_personalized=is_personalized,
|
||||
is_list=is_list,
|
||||
is_detail=is_detail,
|
||||
has_fetch=has_fetch,
|
||||
route_path=route_path,
|
||||
)
|
||||
|
||||
return {
|
||||
'path': route_path,
|
||||
'file': str(file_path.relative_to(self.app_dir.parent)),
|
||||
'characteristics': {
|
||||
'dynamic_route': is_dynamic_route,
|
||||
'personalized': is_personalized,
|
||||
'list_page': is_list,
|
||||
'detail_page': is_detail,
|
||||
'has_data_fetching': has_fetch,
|
||||
'has_suspense': has_suspense,
|
||||
},
|
||||
'current_config': {
|
||||
'has_revalidate': has_revalidate,
|
||||
'has_dynamic': has_dynamic,
|
||||
},
|
||||
'recommendation': recommendation,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error analyzing {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def _recommend_strategy(
|
||||
self, is_personalized: bool, is_list: bool, is_detail: bool, has_fetch: bool, route_path: str
|
||||
) -> Dict:
|
||||
"""Recommend rendering and caching strategy."""
|
||||
|
||||
# Personalized content needs SSR
|
||||
if is_personalized:
|
||||
return {
|
||||
'strategy': 'SSR',
|
||||
'config': "export const dynamic = 'force-dynamic';",
|
||||
'revalidate': None,
|
||||
'cache_tags': [],
|
||||
'reasoning': 'Personalized content requires server-side rendering for each request',
|
||||
'priority': 'high',
|
||||
}
|
||||
|
||||
# Detail pages benefit from ISR
|
||||
if is_detail:
|
||||
return {
|
||||
'strategy': 'ISR',
|
||||
'config': 'export const revalidate = 1800; // 30 minutes',
|
||||
'revalidate': 1800,
|
||||
'cache_tags': [self._extract_entity_tag(route_path), 'entities'],
|
||||
'reasoning': 'Detail pages can use ISR with moderate revalidation for balance of performance and freshness',
|
||||
'priority': 'medium',
|
||||
}
|
||||
|
||||
# List pages with frequent updates
|
||||
if is_list:
|
||||
return {
|
||||
'strategy': 'ISR',
|
||||
'config': 'export const revalidate = 300; // 5 minutes',
|
||||
'revalidate': 300,
|
||||
'cache_tags': ['entities', 'timeline', 'characters'],
|
||||
'reasoning': 'List pages benefit from shorter revalidation to show recent updates',
|
||||
'priority': 'medium',
|
||||
}
|
||||
|
||||
# Static pages
|
||||
if not has_fetch:
|
||||
return {
|
||||
'strategy': 'SSG',
|
||||
'config': '// Static page, no revalidation needed',
|
||||
'revalidate': None,
|
||||
'cache_tags': [],
|
||||
'reasoning': 'No data fetching detected, suitable for static generation',
|
||||
'priority': 'low',
|
||||
}
|
||||
|
||||
# Default to ISR with moderate interval
|
||||
return {
|
||||
'strategy': 'ISR',
|
||||
'config': 'export const revalidate = 600; // 10 minutes',
|
||||
'revalidate': 600,
|
||||
'cache_tags': [],
|
||||
'reasoning': 'Default ISR strategy provides good balance for most pages',
|
||||
'priority': 'low',
|
||||
}
|
||||
|
||||
def _extract_entity_tag(self, route_path: str) -> str:
|
||||
"""Extract entity name from route for cache tagging."""
|
||||
parts = route_path.split('/')
|
||||
for i, part in enumerate(parts):
|
||||
if part.startswith('[') and part.endswith(']'):
|
||||
if i > 0:
|
||||
entity_type = parts[i - 1].rstrip('s') # Remove plural 's'
|
||||
return f"{entity_type}-{{id}}"
|
||||
return 'entity-{id}'
|
||||
|
||||
def print_report(self):
|
||||
"""Print analysis report."""
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f"Next.js Revalidation Strategy Analysis")
|
||||
print(f"{'=' * 80}\n")
|
||||
|
||||
print(f"Analyzed {len(self.routes)} routes\n")
|
||||
|
||||
# Group by strategy
|
||||
strategies = {}
|
||||
for route in self.routes:
|
||||
strategy = route['recommendation']['strategy']
|
||||
if strategy not in strategies:
|
||||
strategies[strategy] = []
|
||||
strategies[strategy].append(route)
|
||||
|
||||
for strategy, routes in strategies.items():
|
||||
print(f"\n{strategy} Routes ({len(routes)}):")
|
||||
print("-" * 80)
|
||||
for route in routes:
|
||||
rec = route['recommendation']
|
||||
print(f"\nRoute: {route['path']}")
|
||||
print(f" File: {route['file']}")
|
||||
print(f" Strategy: {rec['strategy']}")
|
||||
print(f" Config: {rec['config']}")
|
||||
if rec['cache_tags']:
|
||||
print(f" Cache Tags: {', '.join(rec['cache_tags'])}")
|
||||
print(f" Reasoning: {rec['reasoning']}")
|
||||
|
||||
def export_json(self, output_path: Path):
|
||||
"""Export analysis to JSON file."""
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump(self.routes, f, indent=2)
|
||||
print(f"\nExported analysis to: {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Analyze Next.js routes for revalidation strategies')
|
||||
parser.add_argument('app_dir', help='Path to Next.js app directory')
|
||||
parser.add_argument('--output', '-o', help='Output JSON file path')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
app_dir = Path(args.app_dir)
|
||||
if not app_dir.exists():
|
||||
print(f"Error: Directory not found: {app_dir}")
|
||||
return 1
|
||||
|
||||
analyzer = RouteAnalyzer(app_dir)
|
||||
analyzer.analyze()
|
||||
analyzer.print_report()
|
||||
|
||||
if args.output:
|
||||
analyzer.export_json(Path(args.output))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
Reference in New Issue
Block a user