328 lines
8.1 KiB
TypeScript
328 lines
8.1 KiB
TypeScript
import { Hype } from '@because/hype'
|
||
import { define, stylesToCSS } from '@because/forge'
|
||
import { readdir, stat } from 'fs/promises'
|
||
import { readFileSync } from 'fs'
|
||
import { join, extname, basename } from 'path'
|
||
|
||
const APPS_DIR = process.env.APPS_DIR!
|
||
|
||
const app = new Hype({ prettyHTML: false })
|
||
|
||
// Styles
|
||
const Container = define('CodeBrowserContainer', {
|
||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||
padding: '20px',
|
||
maxWidth: '1200px',
|
||
margin: '0 auto',
|
||
})
|
||
|
||
const Header = define('Header', {
|
||
marginBottom: '20px',
|
||
paddingBottom: '10px',
|
||
borderBottom: '2px solid #333',
|
||
})
|
||
|
||
const Title = define('Title', {
|
||
margin: 0,
|
||
fontSize: '24px',
|
||
fontWeight: 'bold',
|
||
})
|
||
|
||
const AppName = define('AppName', {
|
||
color: '#666',
|
||
fontSize: '18px',
|
||
marginTop: '5px',
|
||
})
|
||
|
||
const FileList = define('FileList', {
|
||
listStyle: 'none',
|
||
padding: 0,
|
||
margin: '20px 0',
|
||
border: '1px solid #ddd',
|
||
borderRadius: '4px',
|
||
overflow: 'hidden',
|
||
})
|
||
|
||
const FileItem = define('FileItem', {
|
||
padding: '10px 15px',
|
||
borderBottom: '1px solid #eee',
|
||
states: {
|
||
':last-child': {
|
||
borderBottom: 'none',
|
||
},
|
||
':hover': {
|
||
backgroundColor: '#f5f5f5',
|
||
},
|
||
}
|
||
})
|
||
|
||
const FileLink = define('FileLink', {
|
||
base: 'a',
|
||
textDecoration: 'none',
|
||
color: '#0066cc',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
states: {
|
||
':hover': {
|
||
textDecoration: 'underline',
|
||
},
|
||
}
|
||
})
|
||
|
||
const CodeBlock = define('CodeBlock', {
|
||
margin: '20px 0',
|
||
border: '1px solid #ddd',
|
||
borderRadius: '4px',
|
||
overflow: 'auto',
|
||
selectors: {
|
||
'& pre': {
|
||
margin: 0,
|
||
padding: '15px',
|
||
overflow: 'auto',
|
||
whiteSpace: 'pre',
|
||
},
|
||
'& pre code': {
|
||
whiteSpace: 'pre',
|
||
fontFamily: 'monospace',
|
||
},
|
||
},
|
||
})
|
||
|
||
const CodeHeader = define('CodeHeader', {
|
||
padding: '10px 15px',
|
||
backgroundColor: '#f5f5f5',
|
||
borderBottom: '1px solid #ddd',
|
||
fontWeight: 'bold',
|
||
fontSize: '14px',
|
||
})
|
||
|
||
const Error = define('Error', {
|
||
color: '#d32f2f',
|
||
padding: '20px',
|
||
backgroundColor: '#ffebee',
|
||
borderRadius: '4px',
|
||
margin: '20px 0',
|
||
})
|
||
|
||
const BackLink = define('BackLink', {
|
||
base: 'a',
|
||
textDecoration: 'none',
|
||
color: '#0066cc',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '5px',
|
||
marginBottom: '15px',
|
||
states: {
|
||
':hover': {
|
||
textDecoration: 'underline',
|
||
},
|
||
},
|
||
})
|
||
|
||
app.get('/styles.css', c => c.text(stylesToCSS(), 200, {
|
||
'Content-Type': 'text/css; charset=utf-8',
|
||
}))
|
||
|
||
async function listFiles(appPath: string, subPath: string = '') {
|
||
const fullPath = join(appPath, subPath)
|
||
const entries = await readdir(fullPath, { withFileTypes: true })
|
||
|
||
const items = await Promise.all(
|
||
entries.map(async entry => {
|
||
const itemPath = join(fullPath, entry.name)
|
||
const stats = await stat(itemPath)
|
||
return {
|
||
name: entry.name,
|
||
isDirectory: entry.isDirectory(),
|
||
path: subPath ? `${subPath}/${entry.name}` : entry.name,
|
||
}
|
||
})
|
||
)
|
||
|
||
return items.sort((a, b) => {
|
||
if (a.isDirectory !== b.isDirectory) {
|
||
return a.isDirectory ? -1 : 1
|
||
}
|
||
return a.name.localeCompare(b.name)
|
||
})
|
||
}
|
||
|
||
function escapeHtml(text: string): string {
|
||
return text
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
function getLanguage(filename: string): string {
|
||
const ext = extname(filename).toLowerCase()
|
||
const langMap: Record<string, string> = {
|
||
'.js': 'javascript',
|
||
'.jsx': 'javascript',
|
||
'.ts': 'typescript',
|
||
'.tsx': 'typescript',
|
||
'.json': 'json',
|
||
'.css': 'css',
|
||
'.html': 'html',
|
||
'.md': 'markdown',
|
||
'.sh': 'bash',
|
||
'.yml': 'yaml',
|
||
'.yaml': 'yaml',
|
||
}
|
||
return langMap[ext] || 'plaintext'
|
||
}
|
||
|
||
app.get('/', async c => {
|
||
const appName = c.req.query('app')
|
||
const filePath = c.req.query('file') || ''
|
||
|
||
if (!appName) {
|
||
return c.html(
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Code Browser</title>
|
||
<link rel="stylesheet" href="/styles.css" />
|
||
</head>
|
||
<body>
|
||
<Container>
|
||
<Header>
|
||
<Title>Code Browser</Title>
|
||
</Header>
|
||
<Error>Please specify an app name with ?app=<name></Error>
|
||
</Container>
|
||
</body>
|
||
</html>
|
||
)
|
||
}
|
||
|
||
const appPath = join(APPS_DIR, appName)
|
||
|
||
try {
|
||
await stat(appPath)
|
||
} catch {
|
||
return c.html(
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Code Browser</title>
|
||
<link rel="stylesheet" href="/styles.css" />
|
||
</head>
|
||
<body>
|
||
<Container>
|
||
<Header>
|
||
<Title>Code Browser</Title>
|
||
</Header>
|
||
<Error>App "{appName}" not found</Error>
|
||
</Container>
|
||
</body>
|
||
</html>
|
||
)
|
||
}
|
||
|
||
const fullPath = join(appPath, filePath)
|
||
let stats
|
||
|
||
try {
|
||
stats = await stat(fullPath)
|
||
} catch {
|
||
return c.html(
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Code Browser</title>
|
||
<link rel="stylesheet" href="/styles.css" />
|
||
</head>
|
||
<body>
|
||
<Container>
|
||
<Header>
|
||
<Title>Code Browser</Title>
|
||
</Header>
|
||
<Error>Path "{filePath}" not found in app "{appName}"</Error>
|
||
</Container>
|
||
</body>
|
||
</html>
|
||
)
|
||
}
|
||
|
||
if (stats.isFile()) {
|
||
const content = readFileSync(fullPath, 'utf-8')
|
||
const language = getLanguage(basename(fullPath))
|
||
const parentPath = filePath.split('/').slice(0, -1).join('/')
|
||
|
||
return c.html(
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>{`${appName}/${filePath}`}</title>
|
||
<link rel="stylesheet" href="/styles.css" />
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" />
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||
</head>
|
||
<body>
|
||
<Container>
|
||
<Header>
|
||
<Title>Code Browser</Title>
|
||
<AppName>{appName}/{filePath}</AppName>
|
||
</Header>
|
||
<BackLink href={`/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`}>
|
||
⬅️ Back
|
||
</BackLink>
|
||
<CodeBlock>
|
||
<CodeHeader>{basename(fullPath)}</CodeHeader>
|
||
<pre><code class={`language-${language}`}>{content}</code></pre>
|
||
</CodeBlock>
|
||
</Container>
|
||
<script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />
|
||
</body>
|
||
</html>
|
||
)
|
||
}
|
||
|
||
const files = await listFiles(appPath, filePath)
|
||
const parentPath = filePath.split('/').slice(0, -1).join('/')
|
||
|
||
return c.html(
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>{`${appName}${filePath ? `/${filePath}` : ''}`}</title>
|
||
<link rel="stylesheet" href="/styles.css" />
|
||
</head>
|
||
<body>
|
||
<Container>
|
||
<Header>
|
||
<Title>Code Browser</Title>
|
||
<AppName>{appName}{filePath ? `/${filePath}` : ''}</AppName>
|
||
</Header>
|
||
{filePath && (
|
||
<BackLink href={`/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`}>
|
||
⬅️ Back
|
||
</BackLink>
|
||
)}
|
||
<FileList>
|
||
{files.map(file => (
|
||
<FileItem>
|
||
<FileLink href={`/?app=${appName}&file=${file.path}`}>
|
||
{file.isDirectory ? '📁' : '📄'}
|
||
<span>{file.name}</span>
|
||
</FileLink>
|
||
</FileItem>
|
||
))}
|
||
</FileList>
|
||
</Container>
|
||
</body>
|
||
</html>
|
||
)
|
||
})
|
||
|
||
export default app.defaults
|