Skip to content

Instantly share code, notes, and snippets.

@jonsherrard
Created May 29, 2025 10:10
Show Gist options
  • Save jonsherrard/5fb931b40f1bd1011b6066b0cd059a8a to your computer and use it in GitHub Desktop.
Save jonsherrard/5fb931b40f1bd1011b6066b0cd059a8a to your computer and use it in GitHub Desktop.
Create expo-router file direectoy structure from mobx-router views file
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
/**
* Script to generate Expo Router v5 file structure from existing views.js routes
*/
// Configuration
const VIEWS_FILE_PATH = path.join(__dirname, '../../viewer-desktop/src/config/views.js');
const OUTPUT_DIR = path.join(__dirname, '../app');
/**
* Parse the views.js file to extract route information
*/
function parseViewsFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
// Find the views object definition
const viewsMatch = content.match(/const\s+views\s*=\s*\{([\s\S]*)\};?\s*export\s+default\s+views/);
if (!viewsMatch) {
throw new Error('Could not find views object in file');
}
const viewsContent = viewsMatch[1];
const routes = [];
// Split by individual route definitions
// Look for patterns like "routeName: new Route("
const routeStartPattern = /(\w+):\s*new\s+Route\s*\(/g;
const routeStarts = [];
let match;
while ((match = routeStartPattern.exec(viewsContent)) !== null) {
routeStarts.push({
name: match[1],
start: match.index,
fullMatch: match[0]
});
}
// Process each route by extracting its full definition
for (let i = 0; i < routeStarts.length; i++) {
const currentRoute = routeStarts[i];
const nextRoute = routeStarts[i + 1];
// Extract the route definition from current position to next route (or end)
const endPos = nextRoute ? nextRoute.start : viewsContent.length;
const routeDefinition = viewsContent.substring(currentRoute.start, endPos);
// Find the complete Route(...) block
const routeStart = routeDefinition.indexOf('new Route(');
if (routeStart === -1) continue;
let braceCount = 0;
let parenCount = 0;
let inString = false;
let stringChar = '';
let escaped = false;
let foundConfig = false;
let configStart = -1;
let configEnd = -1;
// Find the opening { after new Route(
for (let j = routeStart + 'new Route('.length; j < routeDefinition.length; j++) {
const char = routeDefinition[j];
if (escaped) {
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (inString) {
if (char === stringChar) {
inString = false;
stringChar = '';
}
continue;
}
if (char === '"' || char === "'" || char === '`') {
inString = true;
stringChar = char;
continue;
}
if (char === '(') {
parenCount++;
continue;
}
if (char === ')') {
parenCount--;
if (parenCount === 0 && braceCount === 0) {
// Found the end of the Route(...) call
configEnd = j;
break;
}
continue;
}
if (char === '{') {
if (!foundConfig) {
foundConfig = true;
configStart = j;
}
braceCount++;
continue;
}
if (char === '}') {
braceCount--;
if (braceCount === 0 && foundConfig) {
configEnd = j + 1;
break;
}
continue;
}
}
if (configStart === -1 || configEnd === -1) {
console.warn(`Could not parse route configuration for ${currentRoute.name}`);
continue;
}
// Extract the configuration object
const routeConfig = routeDefinition.substring(configStart + 1, configEnd - 1);
// Extract path from route config
const pathMatch = routeConfig.match(/path:\s*['"`]([^'"`]+)['"`]/);
if (!pathMatch) {
console.warn(`No path found for route ${currentRoute.name}`);
continue;
}
const routePath = pathMatch[1];
// Extract component information
const componentMatch = routeConfig.match(/component:\s*(?:<([^>\s]+)|([A-Z][a-zA-Z]*)|(\([^)]*\)))/);
let componentName = null;
if (componentMatch) {
componentName = componentMatch[1] || componentMatch[2] || 'ComplexComponent';
}
// Extract restricted flag
const restrictedMatch = routeConfig.match(/restricted:\s*(true|false)/);
const isRestricted = restrictedMatch ? restrictedMatch[1] === 'true' : true; // default to true
// Check if it's a redirect route
const isRedirect = routeConfig.includes('beforeEnter') && routeConfig.includes('return false');
routes.push({
name: currentRoute.name,
path: routePath,
component: componentName,
restricted: isRestricted,
isRedirect,
originalConfig: routeConfig
});
}
return routes;
}
/**
* Convert a route path to Expo Router file path
*/
function convertPathToFilePath(routePath) {
// Remove leading slash
let filePath = routePath.replace(/^\//, '');
// Handle root path
if (filePath === '') {
return 'index.tsx';
}
// Convert dynamic segments from :param to [param]
filePath = filePath.replace(/:(\w+)/g, '[$1]');
// Split path into segments
const segments = filePath.split('/');
// Convert each segment
const convertedSegments = segments.map((segment, index) => {
// Last segment becomes the filename
if (index === segments.length - 1) {
return `${segment}.tsx`;
}
return segment;
});
return convertedSegments.join('/');
}
/**
* Create directory structure if it doesn't exist
*/
function ensureDirectoryExists(filePath) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, {recursive: true});
}
}
/**
* Generate a basic page component
*/
function generatePageComponent(route) {
const componentName = route.component ?? 'DefaultPage';
const routeName = route.name;
const isRestricted = route.restricted;
// Handle redirect routes
if (route.isRedirect) {
return `import { Redirect } from 'expo-router';
export default function ${routeName}Page() {
return <Redirect href="/" />;
}
`;
}
// Extract dynamic parameters from path
const params = (route.path.match(/:(\w+)/g) ?? []).map(param => param.slice(1));
const paramImports =
params.length > 0
? `
import { useLocalSearchParams } from 'expo-router';`
: '';
const paramUsage =
params.length > 0
? `
const { ${params.join(', ')} } = useLocalSearchParams();`
: '';
return `import React from 'react';
import { View, Text } from 'react-native';${paramImports}
export default function ${routeName}Page() {${paramUsage}
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Route: ${route.path}</Text>
<Text>Component: ${componentName}</Text>
<Text>Restricted: ${isRestricted}</Text>${
params.length > 0
? `
<Text>Parameters: ${params.map(p => `${p}={${p}}`).join(', ')}</Text>`
: ''
}
</View>
);
}
`;
}
/**
* Generate root layout file
*/
function generateRootLayout() {
return `import { Stack } from 'expo-router';
import { useEffect } from 'react';
export default function RootLayout() {
useEffect(() => {
// Initialize app-level code here (fonts, splash screen, etc.)
}, []);
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Explorer' }} />
<Stack.Screen name="domain-login" options={{ title: 'Domain Login' }} />
<Stack.Screen name="sign-in/[loginType]" options={{ title: 'Sign In' }} />
<Stack.Screen name="search-legacy" options={{ title: 'Search (Legacy)' }} />
<Stack.Screen name="search" options={{ title: 'Search' }} />
<Stack.Screen name="guide" options={{ headerShown: false }} />
<Stack.Screen name="privacy-page" options={{ title: 'Privacy' }} />
<Stack.Screen name="change-password" options={{ title: 'Change Password' }} />
<Stack.Screen name="request-reset-password" options={{ title: 'Reset Password' }} />
<Stack.Screen name="request-reset-password-phone" options={{ title: 'Reset Password (Phone)' }} />
<Stack.Screen name="reset-password" options={{ title: 'Reset Password' }} />
<Stack.Screen name="reset-password-phone" options={{ title: 'Reset Password (Phone)' }} />
<Stack.Screen name="set-password" options={{ title: 'Set Password' }} />
<Stack.Screen name="activation-code" options={{ title: 'Activation Code' }} />
</Stack>
);
}
`;
}
/**
* Generate guide layout for nested guide routes
*/
function generateGuideLayout() {
return `import { Stack } from 'expo-router';
export default function GuideLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Guide Explorer' }} />
<Stack.Screen name="[guideSlug]" options={{ headerShown: false }} />
</Stack>
);
}
`;
}
/**
* Generate dynamic guide layout
*/
function generateDynamicGuideLayout() {
return `import { Stack } from 'expo-router';
import { useLocalSearchParams } from 'expo-router';
export default function GuideSlugLayout() {
const { guideSlug } = useLocalSearchParams();
return (
<Stack>
<Stack.Screen name="index" options={{ title: \`Guide: \${guideSlug}\` }} />
<Stack.Screen name="[topicSlug]" options={{ headerShown: false }} />
</Stack>
);
}
`;
}
/**
* Main function to generate the file structure
*/
function generateExpoRouterStructure() {
console.log('πŸš€ Starting Expo Router v5 file generation...');
// Parse the views.js file
const routes = parseViewsFile(VIEWS_FILE_PATH);
console.log(`πŸ“‹ Found ${routes.length} routes to process`);
// Create output directory
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, {recursive: true});
console.log(`πŸ“ Created output directory: ${OUTPUT_DIR}`);
}
// Generate root layout
const rootLayoutPath = path.join(OUTPUT_DIR, '_layout.tsx');
fs.writeFileSync(rootLayoutPath, generateRootLayout());
console.log('βœ… Generated root _layout.tsx');
// Process each route
routes.forEach(route => {
const filePath = convertPathToFilePath(route.path);
const fullPath = path.join(OUTPUT_DIR, filePath);
console.log(`πŸ“„ Processing route: ${route.path} β†’ ${filePath}`);
// Ensure directory exists
ensureDirectoryExists(fullPath);
// Generate and write the component
const componentContent = generatePageComponent(route);
fs.writeFileSync(fullPath, componentContent);
console.log(`βœ… Created: ${filePath}`);
});
// Generate special layouts for nested guide routes
const guideLayoutPath = path.join(OUTPUT_DIR, 'guide', '_layout.tsx');
ensureDirectoryExists(guideLayoutPath);
fs.writeFileSync(guideLayoutPath, generateGuideLayout());
console.log('βœ… Generated guide/_layout.tsx');
const dynamicGuideLayoutPath = path.join(OUTPUT_DIR, 'guide', '[guideSlug]', '_layout.tsx');
ensureDirectoryExists(dynamicGuideLayoutPath);
fs.writeFileSync(dynamicGuideLayoutPath, generateDynamicGuideLayout());
console.log('βœ… Generated guide/[guideSlug]/_layout.tsx');
console.log(`\nπŸŽ‰ Successfully generated Expo Router v5 file structure!`);
console.log(`πŸ“ Output directory: ${OUTPUT_DIR}`);
console.log(`πŸ“Š Total files created: ${routes.length + 3} (${routes.length} pages + 3 layouts)`);
}
// Run the script
if (require.main === module) {
try {
generateExpoRouterStructure();
} catch (error) {
console.error('❌ Error generating file structure:', error);
process.exit(1);
}
}
module.exports = {
generateExpoRouterStructure,
parseViewsFile,
convertPathToFilePath
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment