cat
This commit is contained in:
parent
a8dda2d47d
commit
01d614a5d9
106
nose/bin/cat.ts
Normal file
106
nose/bin/cat.ts
Normal 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
144
nose/lib/highlight.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user