Created
May 29, 2025 10:10
-
-
Save jonsherrard/5fb931b40f1bd1011b6066b0cd059a8a to your computer and use it in GitHub Desktop.
Create expo-router file direectoy structure from mobx-router views file
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 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