import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } 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',
paddingTop: 0,
maxWidth: '1200px',
margin: '0 auto',
color: theme('colors-text'),
})
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',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
margin: '20px 0',
})
const Breadcrumb = define('Breadcrumb', {
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '14px',
marginBottom: '15px',
flexWrap: 'wrap',
})
const BreadcrumbLink = define('BreadcrumbLink', {
base: 'a',
textDecoration: 'none',
color: theme('colors-link'),
states: {
':hover': {
textDecoration: 'underline',
},
},
})
const BreadcrumbSeparator = define('BreadcrumbSeparator', {
color: theme('colors-textMuted'),
})
const BreadcrumbCurrent = define('BreadcrumbCurrent', {
color: theme('colors-text'),
fontWeight: 500,
})
const MediaContainer = define('MediaContainer', {
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
backgroundColor: theme('colors-bgSubtle'),
})
const MediaHeader = define('MediaHeader', {
padding: '10px 15px',
backgroundColor: theme('colors-bgElement'),
borderBottom: `1px solid ${theme('colors-border')}`,
fontWeight: 'bold',
fontSize: '14px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})
const MediaContent = define('MediaContent', {
padding: '20px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})
const ImagePreview = define('ImagePreview', {
base: 'img',
maxWidth: '100%',
maxHeight: '600px',
objectFit: 'contain',
})
const AudioPlayer = define('AudioPlayer', {
base: 'audio',
width: '100%',
maxWidth: '500px',
})
const VideoPlayer = define('VideoPlayer', {
base: 'video',
maxWidth: '100%',
maxHeight: '600px',
})
const DownloadButton = define('DownloadButton', {
base: 'a',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: theme('colors-primary'),
color: 'white',
textDecoration: 'none',
borderRadius: theme('radius-md'),
fontSize: '14px',
states: {
':hover': {
opacity: 0.9,
},
},
})
const EditButton = define('EditButton', {
base: 'button',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
backgroundColor: theme('colors-bgElement'),
color: theme('colors-text'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
fontSize: '13px',
cursor: 'pointer',
states: {
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const EditLink = define('EditLink', {
base: 'a',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
backgroundColor: theme('colors-bgElement'),
color: theme('colors-text'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
fontSize: '13px',
cursor: 'pointer',
textDecoration: 'none',
states: {
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const FolderIcon = () => (
)
const FileIconSvg = () => (
)
interface LayoutProps {
title: string
children: Child
highlight?: boolean
editable?: boolean
}
const fileMemoryScript = `
(function() {
var params = new URLSearchParams(window.location.search);
var app = params.get('app');
var file = params.get('file');
var version = params.get('version') || 'current';
if (!app) return;
var key = 'code-app:' + app + ':' + version + ':file';
if (params.has('file')) {
// Explicit file param (even empty) - save it
if (file) localStorage.setItem(key, file);
else localStorage.removeItem(key);
} else {
// No file param - restore saved location
var saved = localStorage.getItem(key);
if (saved) {
var url = '/?app=' + encodeURIComponent(app);
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
url += '&file=' + encodeURIComponent(saved);
window.location.replace(url);
}
}
})();
`
function Layout({ title, children, highlight, editable }: LayoutProps) {
return (
{title}
{highlight && !editable && (
<>
>
)}
{editable && (
<>
>
)}
{children}
{highlight && !editable && }
)
}
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
app.get('/raw', async c => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file')
if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400)
}
const fullPath = join(APPS_DIR, appName, version, filePath)
const file = Bun.file(fullPath)
if (!await file.exists()) {
return c.text(`File not found: ${fullPath}`, 404)
}
return new Response(file)
})
app.post('/save', async c => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file')
if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400)
}
const fullPath = join(APPS_DIR, appName, version, filePath)
const content = await c.req.text()
try {
await Bun.write(fullPath, content)
return c.text('OK')
} catch (err) {
return c.text(`Failed to save: ${err}`, 500)
}
})
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)
})
}
interface BreadcrumbProps {
appName: string
filePath: string
versionParam: string
}
function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
const parts = filePath ? filePath.split('/').filter(Boolean) : []
const crumbs: { name: string; path: string }[] = []
let currentPath = ''
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part
crumbs.push({ name: part, path: currentPath })
}
return (
{crumbs.length > 0 ? (
{appName}
) : (
{appName}
)}
{crumbs.map((crumb, i) => (
<>
/
{i === crumbs.length - 1 ? (
{crumb.name}
) : (
{crumb.name}
)}
>
))}
)
}
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp'])
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'])
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi'])
const BINARY_EXTS = new Set(['.pdf', '.zip', '.tar', '.gz', '.exe', '.dmg', '.woff', '.woff2', '.ttf', '.otf', '.eot'])
type FileType = 'text' | 'image' | 'audio' | 'video' | 'binary'
function getFileType(filename: string): FileType {
const ext = extname(filename).toLowerCase()
if (IMAGE_EXTS.has(ext)) return 'image'
if (AUDIO_EXTS.has(ext)) return 'audio'
if (VIDEO_EXTS.has(ext)) return 'video'
if (BINARY_EXTS.has(ext)) return 'binary'
return 'text'
}
function getLanguage(filename: string): string {
const ext = extname(filename).toLowerCase()
const langMap: Record = {
'.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'
}
function getPrismLanguage(filename: string): string {
const ext = extname(filename).toLowerCase()
const langMap: Record = {
'.js': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.json': 'json',
'.css': 'css',
'.html': 'html',
'.md': 'markdown',
'.sh': 'bash',
'.yml': 'yaml',
'.yaml': 'yaml',
'.py': 'python',
'.rb': 'ruby',
'.go': 'go',
'.rs': 'rust',
'.sql': 'sql',
}
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(
Please specify an app name with ?app=<name>
)
}
const appPath = join(APPS_DIR, appName, version)
try {
await stat(appPath)
} catch {
return c.html(
App "{appName}" (version: {version}) not found
)
}
const fullPath = join(appPath, filePath)
let fileStats
try {
fileStats = await stat(fullPath)
} catch {
return c.html(
Path "{filePath}" not found
)
}
const versionParam = version !== 'current' ? `&version=${version}` : ''
if (fileStats.isFile()) {
const filename = basename(fullPath)
const fileType = getFileType(filename)
const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
const downloadUrl = `${rawUrl}&download=1`
if (fileType === 'image') {
return c.html(
{filename}
Download
)
}
if (fileType === 'audio') {
return c.html(
{filename}
Download
)
}
if (fileType === 'video') {
return c.html(
{filename}
Download
)
}
if (fileType === 'binary') {
return c.redirect(downloadUrl)
}
// Text file - show with syntax highlighting
const content = readFileSync(fullPath, 'utf-8')
const language = getLanguage(filename)
const prismLang = getPrismLanguage(filename)
const edit = c.req.query('edit') === '1'
if (edit) {
const editorScript = `
import { CodeJar } from 'https://cdn.jsdelivr.net/npm/codejar@4.2.0/dist/codejar.js';
const editor = document.getElementById('editor');
const saveBtn = document.getElementById('save-btn');
const status = document.getElementById('save-status');
let dirty = false;
const highlight = (el) => {
Prism.highlightElement(el);
};
const jar = CodeJar(editor, highlight, { tab: ' ', addClosing: false });
// Initial highlight
highlight(editor);
jar.onUpdate(() => {
if (!dirty) {
dirty = true;
saveBtn.textContent = 'Save *';
}
});
saveBtn.onclick = async () => {
if (!dirty) return;
saveBtn.disabled = true;
status.textContent = 'Saving...';
try {
const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: jar.toString()
});
if (!res.ok) throw new Error('Save failed');
dirty = false;
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
status.textContent = 'Saved!';
setTimeout(() => { status.textContent = ''; }, 2000);
} catch (err) {
saveBtn.disabled = false;
status.textContent = 'Error: ' + err.message;
}
};
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveBtn.click();
}
});
`
return c.html(
{filename}
Save
Done
{content}
)
}
return c.html(
{filename}
Edit
{content}
)
}
const files = await listFiles(appPath, filePath)
return c.html(
{files.map(file => (
{file.isDirectory ? : }
{file.name}
))}
)
})
export default app.defaults