feat: statically render the jet ansi text

This commit is contained in:
Jet Pham 2026-03-05 13:18:50 -08:00
parent e6a9b1a111
commit 5cbe032c23
No known key found for this signature in database
7 changed files with 74 additions and 51 deletions

6
package-lock.json generated
View file

@ -8,9 +8,7 @@
"name": "website", "name": "website",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"anser": "^2.3.5",
"cgol": "file:./cgol/pkg", "cgol": "file:./cgol/pkg",
"escape-carriage": "^1.3.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },
@ -20,6 +18,8 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"anser": "^2.3.5",
"escape-carriage": "^1.3.1",
"eslint": "^10", "eslint": "^10",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
@ -2257,6 +2257,7 @@
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz", "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz",
"integrity": "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==", "integrity": "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
@ -2496,6 +2497,7 @@
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/escape-carriage/-/escape-carriage-1.3.1.tgz", "resolved": "https://registry.npmjs.org/escape-carriage/-/escape-carriage-1.3.1.tgz",
"integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==", "integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {

View file

@ -16,13 +16,13 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"anser": "^2.3.5",
"cgol": "file:./cgol/pkg", "cgol": "file:./cgol/pkg",
"escape-carriage": "^1.3.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },
"devDependencies": { "devDependencies": {
"anser": "^2.3.5",
"escape-carriage": "^1.3.1",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",

View file

@ -2,7 +2,7 @@ import { BorderedBox } from "~/components/bordered-box";
import { FrostedBox } from "~/components/frosted-box"; import { FrostedBox } from "~/components/frosted-box";
import Header from "~/components/header"; import Header from "~/components/header";
import { CgolCanvas } from "~/components/cgol-canvas"; import { CgolCanvas } from "~/components/cgol-canvas";
import Jet from "~/assets/Jet.txt?raw"; import Jet from "~/assets/Jet.txt?ansi";
export default function App() { export default function App() {
return ( return (

View file

@ -1,6 +1,3 @@
import React from "react";
import Ansi from "./ansi";
interface HeaderProps { interface HeaderProps {
content: string; content: string;
className?: string; className?: string;
@ -8,9 +5,9 @@ interface HeaderProps {
export default function Header({ content, className }: HeaderProps) { export default function Header({ content, className }: HeaderProps) {
return ( return (
<div className={className}> <div
<Ansi>{content}</Ansi> className={className}
</div> dangerouslySetInnerHTML={{ __html: content }}
/>
); );
} }

5
src/global.d.ts vendored
View file

@ -5,6 +5,11 @@ declare module "*.txt?raw" {
export default content; export default content;
} }
declare module "*.txt?ansi" {
const content: string;
export default content;
}
declare module "*.utf8ans?raw" { declare module "*.utf8ans?raw" {
const content: string; const content: string;
export default content; export default content;

View file

@ -1,6 +1,7 @@
import Anser, { type AnserJsonEntry } from "anser"; import Anser, { type AnserJsonEntry } from "anser";
import { escapeCarriageReturn } from "escape-carriage"; import { escapeCarriageReturn } from "escape-carriage";
import React, { memo, useMemo } from "react"; import fs from "node:fs";
import type { Plugin } from "vite";
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
"ansi-black": "text-[var(--black)]", "ansi-black": "text-[var(--black)]",
@ -19,7 +20,7 @@ const colorMap: Record<string, string> = {
"ansi-bright-magenta": "text-[var(--light-magenta)]", "ansi-bright-magenta": "text-[var(--light-magenta)]",
"ansi-bright-cyan": "text-[var(--light-cyan)]", "ansi-bright-cyan": "text-[var(--light-cyan)]",
"ansi-bright-white": "text-[var(--white)]", "ansi-bright-white": "text-[var(--white)]",
} as const; };
const bgColorMap: Record<string, string> = { const bgColorMap: Record<string, string> = {
"ansi-black": "bg-transparent", "ansi-black": "bg-transparent",
@ -38,7 +39,7 @@ const bgColorMap: Record<string, string> = {
"ansi-bright-magenta": "bg-[var(--light-magenta)]", "ansi-bright-magenta": "bg-[var(--light-magenta)]",
"ansi-bright-cyan": "bg-[var(--light-cyan)]", "ansi-bright-cyan": "bg-[var(--light-cyan)]",
"ansi-bright-white": "bg-[var(--white)]", "ansi-bright-white": "bg-[var(--white)]",
} as const; };
const decorationMap: Record<string, string> = { const decorationMap: Record<string, string> = {
bold: "font-bold", bold: "font-bold",
@ -48,7 +49,7 @@ const decorationMap: Record<string, string> = {
strikethrough: "line-through", strikethrough: "line-through",
underline: "underline", underline: "underline",
blink: "animate-pulse", blink: "animate-pulse",
} as const; };
function fixBackspace(txt: string): string { function fixBackspace(txt: string): string {
let tmp = txt; let tmp = txt;
@ -61,7 +62,6 @@ function fixBackspace(txt: string): string {
function createClass(bundle: AnserJsonEntry): string | null { function createClass(bundle: AnserJsonEntry): string | null {
const classes: string[] = []; const classes: string[] = [];
if (bundle.bg && bgColorMap[bundle.bg]) { if (bundle.bg && bgColorMap[bundle.bg]) {
classes.push(bgColorMap[bundle.bg]!); classes.push(bgColorMap[bundle.bg]!);
} }
@ -74,41 +74,59 @@ function createClass(bundle: AnserJsonEntry): string | null {
return classes.length ? classes.join(" ") : null; return classes.length ? classes.join(" ") : null;
} }
interface Props { function escapeHtml(str: string): string {
children?: string; return str
className?: string; .replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }
const Ansi = memo(function Ansi({ className, children = "" }: Props) { function renderAnsiToHtml(raw: string): string {
const bundles = useMemo(() => { const input = escapeCarriageReturn(fixBackspace(raw));
const input = escapeCarriageReturn(fixBackspace(children)); const bundles = Anser.ansiToJson(input, {
return Anser.ansiToJson(input, {
json: true, json: true,
remove_empty: true, remove_empty: true,
use_classes: true, use_classes: true,
}); });
}, [children]);
const renderedContent = useMemo( const spans = bundles
() => .map((bundle) => {
bundles.map((bundle, key) => { const cls = createClass(bundle);
const bundleClassName = createClass(bundle); const content = escapeHtml(bundle.content);
return ( if (cls) {
<span key={key} className={bundleClassName ?? undefined}> return `<span class="${cls}">${content}</span>`;
{bundle.content} }
</span> return `<span>${content}</span>`;
); })
}), .join("");
[bundles],
);
return ( return `<div class="flex justify-center"><pre style="text-align:left"><code>${spans}</code></pre></div>`;
<div className="flex justify-center"> }
<pre className={className ?? ""} style={{ textAlign: "left" }}>
<code>{renderedContent}</code>
</pre>
</div>
);
});
export default Ansi; const ANSI_SUFFIX = "?ansi";
export default function ansiPlugin(): Plugin {
return {
name: "vite-plugin-ansi",
enforce: "pre",
async resolveId(source, importer, options) {
if (!source.endsWith(ANSI_SUFFIX)) return;
const bare = source.slice(0, -ANSI_SUFFIX.length);
const resolved = await this.resolve(bare, importer, {
...options,
skipSelf: true,
});
if (resolved) {
return resolved.id + ANSI_SUFFIX;
}
},
load(id) {
if (!id.endsWith(ANSI_SUFFIX)) return;
const filePath = id.slice(0, -ANSI_SUFFIX.length);
const raw = fs.readFileSync(filePath, "utf-8");
const html = renderAnsiToHtml(raw);
return `export default ${JSON.stringify(html)};`;
},
};
}

View file

@ -3,9 +3,10 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import wasm from "vite-plugin-wasm"; import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await"; import topLevelAwait from "vite-plugin-top-level-await";
import ansi from "./vite-plugin-ansi";
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss(), wasm(), topLevelAwait()], plugins: [ansi(), react(), tailwindcss(), wasm(), topLevelAwait()],
resolve: { resolve: {
alias: { alias: {
"~": "/src", "~": "/src",