Loading cli/deno.json +2 −0 Original line number Diff line number Diff line Loading @@ -15,8 +15,10 @@ "@jimp/wasm-webp": "npm:@jimp/wasm-webp@^1.6.0", "@poppanator/http-constants": "npm:@poppanator/http-constants@^1.1.1", "@std/dotenv": "jsr:@std/dotenv@^0.225.2", "@std/assert": "jsr:@std/assert@^1.0.0", "@std/semver": "jsr:@std/semver@^1.0.5", "cli-highlight": "npm:cli-highlight@^2.1.11", "fetch-mock": "npm:fetch-mock@^12.5.2", "hono": "jsr:@hono/hono@^4.8.3", "icojs": "npm:icojs@^0.19.4", "jimp": "npm:jimp@^1.6.0", Loading cli/node.test.ts 0 → 100644 +181 −0 Original line number Diff line number Diff line import { assertEquals } from "@std/assert"; import fetchMock from "fetch-mock"; import { getAsciiArt, getFaviconUrl, Jimp, rgbTo256Color } from "./node.ts"; const HTML_WITH_SMALL_ICON = ` <!DOCTYPE html> <html> <head> <title>Test Site</title> <link rel="icon" href="/favicon.ico" sizes="32x32"> <link rel="apple-touch-icon" href="/apple-touch-icon.png"> </head> <body>Test</body> </html> `; Deno.test("getFaviconUrl - small favicon.ico and apple-touch-icon.png", async () => { fetchMock.spyGlobal(); fetchMock.get("https://example.com/", { body: HTML_WITH_SMALL_ICON, headers: { "Content-Type": "text/html" }, }); const result = await getFaviconUrl("https://example.com/"); assertEquals(result.href, "https://example.com/apple-touch-icon.png"); fetchMock.hardReset(); }); const HTML_WITH_ICON = ` <!DOCTYPE html> <html> <head> <title>Test Site</title> <link rel="icon" href="/favicon.ico" sizes="64x64"> <link rel="apple-touch-icon" href="/apple-touch-icon.png"> </head> <body>Test</body> </html> `; Deno.test("getFaviconUrl - favicon.ico and apple-touch-icon.png", async () => { fetchMock.spyGlobal(); fetchMock.get("https://example.com/", { body: HTML_WITH_ICON, headers: { "Content-Type": "text/html" }, }); const result = await getFaviconUrl("https://example.com/"); assertEquals(result.href, "https://example.com/favicon.ico"); fetchMock.hardReset(); }); const HTML_WITH_SVG_ONLY = ` <!DOCTYPE html> <html> <head> <title>Test Site</title> <link rel="icon" href="/icon.svg" type="image/svg+xml"> </head> <body>Test</body> </html> `; Deno.test("getFaviconUrl - svg icons only falls back to /favicon.ico", async () => { fetchMock.spyGlobal(); fetchMock.get("https://example.com/", { body: HTML_WITH_SVG_ONLY, headers: { "Content-Type": "text/html" }, }); const result = await getFaviconUrl("https://example.com/"); assertEquals(result.href, "https://example.com/favicon.ico"); fetchMock.hardReset(); }); const HTML_WITHOUT_ICON = ` <!DOCTYPE html> <html> <head> <title>Test Site</title> </head> <body>Test</body> </html> `; Deno.test("getFaviconUrl - falls back to /favicon.ico", async () => { fetchMock.spyGlobal(); fetchMock.get("https://example.com/", { body: HTML_WITHOUT_ICON, headers: { "Content-Type": "text/html" }, }); const result = await getFaviconUrl("https://example.com/"); assertEquals(result.href, "https://example.com/favicon.ico"); fetchMock.hardReset(); }); Deno.test("rgbTo256Color - check RGB cube", () => { const CUBE_VALUES = [0, 95, 135, 175, 215, 255]; const colors: Array<{ r: number; g: number; b: number }> = []; for (let r = 0; r < 6; r++) { for (let g = 0; g < 6; g++) { for (let b = 0; b < 6; b++) { colors.push({ r: CUBE_VALUES[r], g: CUBE_VALUES[g], b: CUBE_VALUES[b], }); } } } // Expected color indices for the above colors (16-231) // RGB cube: 6x6x6 = 216 colors, indices 16-231 const expected_color_idx = Array.from( { length: colors.length }, (_, i) => 16 + i, ); const results = colors.map((color) => rgbTo256Color(color.r, color.g, color.b) ); assertEquals(results, expected_color_idx); }); Deno.test("rgbTo256Color - check grayscale", () => { const grayscale = Array.from({ length: 24 }).map( (_, idx) => ({ r: 8 + idx * 10, g: 8 + idx * 10, b: 8 + idx * 10, }), ); const expected_gray_idx = Array.from( { length: grayscale.length }, (_, i) => 232 + i, ); const results = grayscale.map((GRAY) => rgbTo256Color(GRAY.r, GRAY.g, GRAY.b) ); assertEquals(results, expected_gray_idx); }); Deno.test("getAsciiArt - Darkest Letter", async () => { // Create black and white 1x1 images using Jimp constructor const blackImage = new Jimp({ width: 1, height: 1, color: 0x000000ff }); const blackImageBuffer = await blackImage.getBuffer("image/webp"); const blackResult = getAsciiArt( await Jimp.read(blackImageBuffer), 1, true, ); assertEquals(blackResult, "█"); }); Deno.test("getAsciiArt - Brightest Letter", async () => { // Create black and white 1x1 images using Jimp constructor const whiteImage = new Jimp({ width: 1, height: 1, color: 0xffffffff }); const whiteImageBuffer = await whiteImage.getBuffer("image/webp"); const whiteResult = getAsciiArt( await Jimp.read(whiteImageBuffer), 1, true, ); assertEquals(whiteResult, " "); }); cli/node.ts +41 −16 Original line number Diff line number Diff line Loading @@ -221,7 +221,7 @@ const LINK_REGEXP = /<link((?:\s+(?:[-a-z]+)=(?:"[^"]*"|'[^']*'|[^\s]+))*)\s*\/?>/ig; const LINK_ATTRS_REGEXP = /(?:\s+([-a-z]+)=("[^"]*"|'[^']*'|[^\s]+))/ig; async function getFaviconUrl( export async function getFaviconUrl( url: string | URL, userAgent?: string, ): Promise<URL> { Loading Loading @@ -253,7 +253,7 @@ async function getFaviconUrl( return new URL("/favicon.ico", response.url); } const Jimp = createJimp({ export const Jimp = createJimp({ formats: [...defaultFormats, webp], plugins: defaultPlugins, }); Loading @@ -277,28 +277,53 @@ const ASCII_CHARS = "█▓▒░@#B8&WM%*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. "; // cSpell: enable function rgbTo256Color(r: number, g: number, b: number): number { // Handle grayscale colors (colors 232-255) const CUBE_VALUES = [0, 95, 135, 175, 215, 255]; const findClosestIndex = (value: number): number => { let minDiff = Infinity; let closestIndex = 0; for (let idx = 0; idx < CUBE_VALUES.length; idx++) { const diff = Math.abs(value - CUBE_VALUES[idx]); if (diff < minDiff) { minDiff = diff; closestIndex = idx; } } return closestIndex; }; export function rgbTo256Color(r: number, g: number, b: number): number { // Check if it's a grayscale color first (when all RGB values are very close) const gray = Math.round((r + g + b) / 3); if ( Math.abs(r - gray) < 10 && Math.abs(g - gray) < 10 && Math.abs(b - gray) < 10 ) { if (gray < 8) return 16; // Black if (gray > 248) return 231; // White return Math.round(((gray - 8) / 240) * 23) + 232; const isGrayscale = Math.abs(r - gray) <= 5 && Math.abs(g - gray) <= 5 && Math.abs(b - gray) <= 5; // Handle grayscale colors (colors 232-255) - but exclude exact cube values if (isGrayscale) { const isExactCubeValue = CUBE_VALUES.includes(r) && r === g && g === b; if (!isExactCubeValue) { if (gray < 8) return 232; // Darkest grayscale if (gray > 238) return 255; // Brightest grayscale // Map to grayscale range 232-255 (24 levels) // XTerm grayscale: 8, 18, 28, ..., 238 maps to 232, 233, 234, ..., 255 const grayIndex = Math.round((gray - 8) / 10); return Math.max(232, Math.min(255, 232 + grayIndex)); } } // Handle RGB colors (colors 16-231) // Convert to 6x6x6 cube const r6 = Math.round((r / 255) * 5); const g6 = Math.round((g / 255) * 5); const b6 = Math.round((b / 255) * 5); // XTerm 256 color cube values: [0, 95, 135, 175, 215, 255] const r6 = findClosestIndex(r); const g6 = findClosestIndex(g); const b6 = findClosestIndex(b); return 16 + (36 * r6) + (6 * g6) + b6; } function getAsciiArt( export function getAsciiArt( image: Awaited<ReturnType<typeof Jimp.read>>, width = DEFAULT_IMAGE_WIDTH, trueColorSupport: boolean, Loading Loading
cli/deno.json +2 −0 Original line number Diff line number Diff line Loading @@ -15,8 +15,10 @@ "@jimp/wasm-webp": "npm:@jimp/wasm-webp@^1.6.0", "@poppanator/http-constants": "npm:@poppanator/http-constants@^1.1.1", "@std/dotenv": "jsr:@std/dotenv@^0.225.2", "@std/assert": "jsr:@std/assert@^1.0.0", "@std/semver": "jsr:@std/semver@^1.0.5", "cli-highlight": "npm:cli-highlight@^2.1.11", "fetch-mock": "npm:fetch-mock@^12.5.2", "hono": "jsr:@hono/hono@^4.8.3", "icojs": "npm:icojs@^0.19.4", "jimp": "npm:jimp@^1.6.0", Loading
cli/node.test.ts 0 → 100644 +181 −0 Original line number Diff line number Diff line import { assertEquals } from "@std/assert"; import fetchMock from "fetch-mock"; import { getAsciiArt, getFaviconUrl, Jimp, rgbTo256Color } from "./node.ts"; const HTML_WITH_SMALL_ICON = ` <!DOCTYPE html> <html> <head> <title>Test Site</title> <link rel="icon" href="/favicon.ico" sizes="32x32"> <link rel="apple-touch-icon" href="/apple-touch-icon.png"> </head> <body>Test</body> </html> `; Deno.test("getFaviconUrl - small favicon.ico and apple-touch-icon.png", async () => { fetchMock.spyGlobal(); fetchMock.get("https://example.com/", { body: HTML_WITH_SMALL_ICON, headers: { "Content-Type": "text/html" }, }); const result = await getFaviconUrl("https://example.com/"); assertEquals(result.href, "https://example.com/apple-touch-icon.png"); fetchMock.hardReset(); }); const HTML_WITH_ICON = ` <!DOCTYPE html> <html> <head> <title>Test Site</title> <link rel="icon" href="/favicon.ico" sizes="64x64"> <link rel="apple-touch-icon" href="/apple-touch-icon.png"> </head> <body>Test</body> </html> `; Deno.test("getFaviconUrl - favicon.ico and apple-touch-icon.png", async () => { fetchMock.spyGlobal(); fetchMock.get("https://example.com/", { body: HTML_WITH_ICON, headers: { "Content-Type": "text/html" }, }); const result = await getFaviconUrl("https://example.com/"); assertEquals(result.href, "https://example.com/favicon.ico"); fetchMock.hardReset(); }); const HTML_WITH_SVG_ONLY = ` <!DOCTYPE html> <html> <head> <title>Test Site</title> <link rel="icon" href="/icon.svg" type="image/svg+xml"> </head> <body>Test</body> </html> `; Deno.test("getFaviconUrl - svg icons only falls back to /favicon.ico", async () => { fetchMock.spyGlobal(); fetchMock.get("https://example.com/", { body: HTML_WITH_SVG_ONLY, headers: { "Content-Type": "text/html" }, }); const result = await getFaviconUrl("https://example.com/"); assertEquals(result.href, "https://example.com/favicon.ico"); fetchMock.hardReset(); }); const HTML_WITHOUT_ICON = ` <!DOCTYPE html> <html> <head> <title>Test Site</title> </head> <body>Test</body> </html> `; Deno.test("getFaviconUrl - falls back to /favicon.ico", async () => { fetchMock.spyGlobal(); fetchMock.get("https://example.com/", { body: HTML_WITHOUT_ICON, headers: { "Content-Type": "text/html" }, }); const result = await getFaviconUrl("https://example.com/"); assertEquals(result.href, "https://example.com/favicon.ico"); fetchMock.hardReset(); }); Deno.test("rgbTo256Color - check RGB cube", () => { const CUBE_VALUES = [0, 95, 135, 175, 215, 255]; const colors: Array<{ r: number; g: number; b: number }> = []; for (let r = 0; r < 6; r++) { for (let g = 0; g < 6; g++) { for (let b = 0; b < 6; b++) { colors.push({ r: CUBE_VALUES[r], g: CUBE_VALUES[g], b: CUBE_VALUES[b], }); } } } // Expected color indices for the above colors (16-231) // RGB cube: 6x6x6 = 216 colors, indices 16-231 const expected_color_idx = Array.from( { length: colors.length }, (_, i) => 16 + i, ); const results = colors.map((color) => rgbTo256Color(color.r, color.g, color.b) ); assertEquals(results, expected_color_idx); }); Deno.test("rgbTo256Color - check grayscale", () => { const grayscale = Array.from({ length: 24 }).map( (_, idx) => ({ r: 8 + idx * 10, g: 8 + idx * 10, b: 8 + idx * 10, }), ); const expected_gray_idx = Array.from( { length: grayscale.length }, (_, i) => 232 + i, ); const results = grayscale.map((GRAY) => rgbTo256Color(GRAY.r, GRAY.g, GRAY.b) ); assertEquals(results, expected_gray_idx); }); Deno.test("getAsciiArt - Darkest Letter", async () => { // Create black and white 1x1 images using Jimp constructor const blackImage = new Jimp({ width: 1, height: 1, color: 0x000000ff }); const blackImageBuffer = await blackImage.getBuffer("image/webp"); const blackResult = getAsciiArt( await Jimp.read(blackImageBuffer), 1, true, ); assertEquals(blackResult, "█"); }); Deno.test("getAsciiArt - Brightest Letter", async () => { // Create black and white 1x1 images using Jimp constructor const whiteImage = new Jimp({ width: 1, height: 1, color: 0xffffffff }); const whiteImageBuffer = await whiteImage.getBuffer("image/webp"); const whiteResult = getAsciiArt( await Jimp.read(whiteImageBuffer), 1, true, ); assertEquals(whiteResult, " "); });
cli/node.ts +41 −16 Original line number Diff line number Diff line Loading @@ -221,7 +221,7 @@ const LINK_REGEXP = /<link((?:\s+(?:[-a-z]+)=(?:"[^"]*"|'[^']*'|[^\s]+))*)\s*\/?>/ig; const LINK_ATTRS_REGEXP = /(?:\s+([-a-z]+)=("[^"]*"|'[^']*'|[^\s]+))/ig; async function getFaviconUrl( export async function getFaviconUrl( url: string | URL, userAgent?: string, ): Promise<URL> { Loading Loading @@ -253,7 +253,7 @@ async function getFaviconUrl( return new URL("/favicon.ico", response.url); } const Jimp = createJimp({ export const Jimp = createJimp({ formats: [...defaultFormats, webp], plugins: defaultPlugins, }); Loading @@ -277,28 +277,53 @@ const ASCII_CHARS = "█▓▒░@#B8&WM%*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. "; // cSpell: enable function rgbTo256Color(r: number, g: number, b: number): number { // Handle grayscale colors (colors 232-255) const CUBE_VALUES = [0, 95, 135, 175, 215, 255]; const findClosestIndex = (value: number): number => { let minDiff = Infinity; let closestIndex = 0; for (let idx = 0; idx < CUBE_VALUES.length; idx++) { const diff = Math.abs(value - CUBE_VALUES[idx]); if (diff < minDiff) { minDiff = diff; closestIndex = idx; } } return closestIndex; }; export function rgbTo256Color(r: number, g: number, b: number): number { // Check if it's a grayscale color first (when all RGB values are very close) const gray = Math.round((r + g + b) / 3); if ( Math.abs(r - gray) < 10 && Math.abs(g - gray) < 10 && Math.abs(b - gray) < 10 ) { if (gray < 8) return 16; // Black if (gray > 248) return 231; // White return Math.round(((gray - 8) / 240) * 23) + 232; const isGrayscale = Math.abs(r - gray) <= 5 && Math.abs(g - gray) <= 5 && Math.abs(b - gray) <= 5; // Handle grayscale colors (colors 232-255) - but exclude exact cube values if (isGrayscale) { const isExactCubeValue = CUBE_VALUES.includes(r) && r === g && g === b; if (!isExactCubeValue) { if (gray < 8) return 232; // Darkest grayscale if (gray > 238) return 255; // Brightest grayscale // Map to grayscale range 232-255 (24 levels) // XTerm grayscale: 8, 18, 28, ..., 238 maps to 232, 233, 234, ..., 255 const grayIndex = Math.round((gray - 8) / 10); return Math.max(232, Math.min(255, 232 + grayIndex)); } } // Handle RGB colors (colors 16-231) // Convert to 6x6x6 cube const r6 = Math.round((r / 255) * 5); const g6 = Math.round((g / 255) * 5); const b6 = Math.round((b / 255) * 5); // XTerm 256 color cube values: [0, 95, 135, 175, 215, 255] const r6 = findClosestIndex(r); const g6 = findClosestIndex(g); const b6 = findClosestIndex(b); return 16 + (36 * r6) + (6 * g6) + b6; } function getAsciiArt( export function getAsciiArt( image: Awaited<ReturnType<typeof Jimp.read>>, width = DEFAULT_IMAGE_WIDTH, trueColorSupport: boolean, Loading