toes/apps/code/20260130-000000/src/server/index.tsx
2026-01-30 19:48:07 -08:00

296 lines
7.7 KiB
TypeScript

import { Hype } from '@because/hype'
import { theme, define, stylesToCSS, initScript, baseStyles } from '@because/toes/tools'
import { readdir, stat } from 'fs/promises'
import { readFileSync } from 'fs'
import { join, extname, basename } from 'path'
import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false })
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
maxWidth: '1200px',
margin: '0 auto',
color: theme('colors-text'),
})
const Header = define('Header', {
marginBottom: '20px',
paddingBottom: '10px',
borderBottom: `2px solid ${theme('colors-border')}`,
})
const Title = define('Title', {
margin: 0,
fontSize: '24px',
fontWeight: 'bold',
})
const Subtitle = define('Subtitle', {
color: theme('colors-textMuted'),
fontSize: '18px',
marginTop: '5px',
})
const FileList = define('FileList', {
listStyle: 'none',
padding: 0,
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
const FileItem = define('FileItem', {
padding: '10px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
states: {
':last-child': {
borderBottom: 'none',
},
':hover': {
backgroundColor: theme('colors-bgHover'),
},
}
})
const FileLink = define('FileLink', {
base: 'a',
textDecoration: 'none',
color: theme('colors-link'),
display: 'flex',
alignItems: 'center',
gap: '8px',
states: {
':hover': {
textDecoration: 'underline',
},
}
})
const FileIcon = define('FileIcon', {
base: 'svg',
width: '18px',
height: '18px',
flexShrink: 0,
fill: theme('colors-textMuted'),
})
const CodeBlock = define('CodeBlock', {
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflowX: 'auto',
selectors: {
'& pre': {
margin: 0,
padding: '15px',
whiteSpace: 'pre',
backgroundColor: theme('colors-bgSubtle'),
},
'& pre code': {
whiteSpace: 'pre',
fontFamily: theme('fonts-mono'),
},
},
})
const CodeHeader = define('CodeHeader', {
padding: '10px 15px',
backgroundColor: theme('colors-bgElement'),
borderBottom: `1px solid ${theme('colors-border')}`,
fontWeight: 'bold',
fontSize: '14px',
})
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
margin: '20px 0',
})
const BackLink = define('BackLink', {
base: 'a',
textDecoration: 'none',
color: theme('colors-link'),
display: 'inline-flex',
alignItems: 'center',
gap: '5px',
marginBottom: '15px',
states: {
':hover': {
textDecoration: 'underline',
},
},
})
const FolderIcon = () => (
<FileIcon viewBox="0 0 24 24">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
</FileIcon>
)
const FileIconSvg = () => (
<FileIcon viewBox="0 0 24 24">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h6v6h6v10H6z" />
</FileIcon>
)
interface LayoutProps {
title: string
subtitle?: string
children: Child
highlight?: boolean
}
function Layout({ title, subtitle, children, highlight }: LayoutProps) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
{highlight && (
<>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
</>
)}
</head>
<body>
<script dangerouslySetInnerHTML={{ __html: initScript }} />
<Container>
{children}
</Container>
{highlight && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}
</body>
</html>
)
}
app.get('/styles.css', c => c.text(baseStyles + 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 = entries.map(entry => ({
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 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 version = c.req.query('version') || 'current'
const filePath = c.req.query('file') || ''
if (!appName) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox>
</Layout>
)
}
const appPath = join(APPS_DIR, appName, version)
const versionSuffix = version !== 'current' ? ` @ ${version}` : ''
try {
await stat(appPath)
} catch {
return c.html(
<Layout title="Code Browser">
<ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
</Layout>
)
}
const fullPath = join(appPath, filePath)
let fileStats
try {
fileStats = await stat(fullPath)
} catch {
return c.html(
<Layout title="Code Browser" subtitle={appName + versionSuffix}>
<ErrorBox>Path "{filePath}" not found</ErrorBox>
</Layout>
)
}
const parentPath = filePath.split('/').slice(0, -1).join('/')
const versionParam = version !== 'current' ? `&version=${version}` : ''
const backUrl = `/?app=${appName}${versionParam}${parentPath ? `&file=${parentPath}` : ''}`
if (fileStats.isFile()) {
const content = readFileSync(fullPath, 'utf-8')
const language = getLanguage(basename(fullPath))
return c.html(
<Layout title={`${appName}/${filePath}`} subtitle={`${appName}${versionSuffix}/${filePath}`} highlight>
<BackLink href={backUrl}> Back</BackLink>
<CodeBlock>
<CodeHeader>{basename(fullPath)}</CodeHeader>
<pre><code class={`language-${language}`}>{content}</code></pre>
</CodeBlock>
</Layout>
)
}
const files = await listFiles(appPath, filePath)
return c.html(
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`} subtitle={`${appName}${versionSuffix}${filePath ? `/${filePath}` : ''}`}>
{filePath && <BackLink href={backUrl}> Back</BackLink>}
<FileList>
{files.map(file => (
<FileItem>
<FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
<span>{file.name}</span>
</FileLink>
</FileItem>
))}
</FileList>
</Layout>
)
})
export default app.defaults