This commit is contained in:
Chris Wanstrath 2025-09-21 12:54:54 -07:00
parent a8dda2d47d
commit 01d614a5d9
2 changed files with 250 additions and 0 deletions

106
nose/bin/cat.ts Normal file
View File

@ -0,0 +1,106 @@
import { escapeHTML } from "bun"
import { readdirSync } from "fs"
import { join, extname } from "path"
import type { CommandOutput } from "@/shared/types"
import { NOSE_WWW } from "@/config"
import { getState } from "@/state"
import { appPath } from "@/webapp"
import { highlight } from "../lib/highlight"
export default async function (path: string) {
const state = getState()
if (!state) return { error: "no state" }
const project = state.project
if (!project) return { error: "no project loaded" }
const root = appPath(project)
if (!root) return { error: "error loading project" }
let files: string[] = []
for (const file of readdirSync(root, { withFileTypes: true })) {
files.push(file.name)
}
if (root === NOSE_WWW) {
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
}
if (!files.includes(path))
return { error: `file not found: ${path}` }
return await readFile(join(root, path))
}
async function readFile(path: string): Promise<CommandOutput> {
const ext = extname(path).slice(1)
const file = Bun.file(path)
switch (ext) {
case "jpeg": case "jpg": case "png": case "gif": case "webp":
const img = await file.arrayBuffer()
return { html: `<img src="data:image/${ext};base64,${Buffer.from(img).toString('base64')}" />` }
case "mp3": case "wav":
case "mp4": case "mov": case "avi": case "mkv": case "webm":
return "Not implemented"
case "js": case "ts": case "tsx": case "json":
return {
html: `<div style='white-space: pre;'>${highlight(convertIndent(await file.text()))}</div>`
}
default:
if (await isBinaryContent(file))
throw "Cannot display binary file"
return {
html: `<div style="white-space: pre;">${escapeHTML(await file.text())}</div>`
}
}
}
// cut down 4space and 2space indents into 1space, for readability on a small screen
function convertIndent(str: string) {
const lines = str.split("\n")
// find the smallest non-zero indent
const minIndent = Math.min(
...lines
.map(l => (l.match(/^ +/) || [""])[0].length)
.filter(len => len > 0)
)
return lines
.map(line =>
line.replace(/^( +)/, (_, spaces) => " ".repeat(spaces.length / minIndent))
)
.join("\n")
}
async function isBinaryContent(file: Bun.FileBlob): Promise<boolean> {
// Create a stream to read just the beginning
const stream = file.stream()
const reader = stream.getReader()
try {
// Read first chunk (typically 16KB, which is more than enough to detect binary)
const { value } = await reader.read()
if (!value) return false
// Check first 512 bytes or less
const bytes = new Uint8Array(value)
const checkLength = Math.min(bytes.length, 512)
for (let i = 0; i < checkLength; i++) {
const byte = bytes[i]!
if (byte === 0) return true // null byte
if (byte < 32 && ![9, 10, 13].includes(byte)) return true // control char
}
return false
} finally {
reader.releaseLock()
stream.cancel()
}
}

144
nose/lib/highlight.ts Normal file
View File

@ -0,0 +1,144 @@
// simple-ts-highlighter.ts — regex-only, self-hostable
export type TokenType =
| "string" | "number" | "keyword" | "boolean" | "null" | "undefined"
| "comment" | "identifier" | "punctuation" | "whitespace" | "unknown"
export type Token = { type: TokenType; value: string; start: number; end: number }
export type Program = { type: "Program"; tokens: Token[] }
const RE = {
// regex literal: /.../flags (handles escapes and [...] classes; still simple)
regex: /^\/(?![/*])(?:\\.|\[(?:\\.|[^\]\\])*\]|[^\\/\n\r])+\/[a-zA-Z]*/,
// comments
lineComment: /^\/\/[^\n\r]*/,
blockComment: /^\/\*[\s\S]*?\*\//,
// strings
sng: /^'(?:\\.|[^'\\])*'/,
dbl: /^"(?:\\.|[^"\\])*"/,
bkt: /^`(?:\\.|[^`\\])*`/,
// numbers
number: /^(?:0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+|\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)/,
// literals
boolNullUndef: /^(?:true|false|null|undefined)\b/,
// keywords
keywords: /^(?:async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|function|if|import|in|instanceof|let|new|return|super|switch|this|throw|try|typeof|var|void|while|with|yield|as|implements|interface|package|private|protected|public|readonly|abstract|declare|type|from|of)\b/,
// identifier / punct / whitespace
ident: /^[A-Za-z_$][A-Za-z0-9_$]*/,
punct: /^[()[\]{}.,;:?~!%^&*+\-=/|<>]+/,
ws: /^\s+/,
}
const types = ["string", "number", "boolean", "any", "void"]
export function highlight(code: string): string {
const tokens = tokenize(code).tokens
return `<style> .string { color: #C62828; } .number { color: #C4A000; } .keyword { color: #7C3AED; } .comment { color: #E91E63; } </style>` + tokens.map(t => tokenToHTML(t)).join("")
}
export function tokenize(src: string): Program {
const tokens: Token[] = []
let i = 0
const eat = (re: RegExp): string | null => {
const m = re.exec(src.slice(i))
return m ? m[0] : null
}
while (i < src.length) {
let v: string | null
// If current char is '/', disambiguate regex/comment upfront
if (src[i] === "/") {
if (src[i + 1] === "/") {
v = eat(RE.lineComment)
if (v) { tokens.push({ type: "comment", value: v, start: i, end: i + v.length }); i += v.length; continue }
} else if (src[i + 1] === "*") {
v = eat(RE.blockComment)
if (v) { tokens.push({ type: "comment", value: v, start: i, end: i + v.length }); i += v.length; continue }
} else if ((v = eat(RE.regex))) {
// Treat regex literal as a "string" for your minimal category set
tokens.push({ type: "string", value: v, start: i, end: i + v.length }); i += v.length; continue
}
}
// Strings
if ((v = eat(RE.sng) || eat(RE.dbl) || eat(RE.bkt))) {
tokens.push({ type: "string", value: v, start: i, end: i + v.length }); i += v.length; continue
}
// Numbers
if ((v = eat(RE.number))) {
tokens.push({ type: "number", value: v, start: i, end: i + v.length }); i += v.length; continue
}
// true/false/null/undefined
if ((v = eat(RE.boolNullUndef))) {
const t: TokenType = v === "true" || v === "false" ? "boolean" : (v === "null" ? "null" : "undefined")
tokens.push({ type: t, value: v, start: i, end: i + v.length }); i += v.length; continue
}
// Keywords
if ((v = eat(RE.keywords))) {
tokens.push({ type: "keyword", value: v, start: i, end: i + v.length }); i += v.length; continue
}
// Identifiers
if ((v = eat(RE.ident))) {
tokens.push({ type: "identifier", value: v, start: i, end: i + v.length }); i += v.length; continue
}
// Punctuation / operators
if ((v = eat(RE.punct))) {
tokens.push({ type: "punctuation", value: v, start: i, end: i + v.length }); i += v.length; continue
}
// Whitespace
if ((v = eat(RE.ws))) {
tokens.push({ type: "whitespace", value: v, start: i, end: i + v.length }); i += v.length; continue
}
// Fallback
if (src[i]) {
tokens.push({ type: "unknown", value: src[i]!, start: i, end: i + 1 })
}
i += 1
}
return { type: "Program", tokens }
}
function tokenToHTML(token: Token): string {
switch (token.type) {
case "string": return `<span style="color: var(--red)">${escapeHtml(token.value)}</span>`
case "number": return `<span style="color: var(--yellow)">${token.value}</span>`
case "keyword": return `<span style="color: var(--purple)">${token.value}</span>`
case "comment": return `<span style="color: var(--magenta)">${escapeHtml(token.value)}</span>`
case "null": case "undefined": case "boolean":
return `<span style="color: var(--green)">${token.value}</span>`
case "punctuation": {
// if (token.value === "(" || token.value === ")" || token.value === "{" || token.value === "}" || token.value === "[" || token.value === "]")
// return `<span style="color: var(--yellow)">${token.value}</span>`
// else
return escapeHtml(token.value)
}
case "identifier": {
if (token.value[0]?.match(/[A-Z]/) || types.includes(token.value))
return `<span style="color: var(--blue)">${token.value}</span>`
else
return `<span style="color: var(--cyan)">${token.value}</span>`
}
case "whitespace":
case "unknown":
return `${escapeHtml(token.value)}`
}
}
export function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}