komado 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +208 -0
  2. package/dist/app.js +59 -0
  3. package/dist/cli.js +196 -0
  4. package/dist/components/List.js +41 -0
  5. package/dist/components/screens/ContinueScreen.js +66 -0
  6. package/dist/components/screens/HomeScreen.js +59 -0
  7. package/dist/components/screens/LibraryScreen.js +69 -0
  8. package/dist/components/screens/LoginScreen.js +85 -0
  9. package/dist/components/screens/MangaScreen.js +107 -0
  10. package/dist/components/screens/ReaderScreen.js +195 -0
  11. package/dist/components/screens/SearchScreen.js +111 -0
  12. package/dist/components/screens/SettingsScreen.js +144 -0
  13. package/dist/components/ui.js +33 -0
  14. package/dist/config.js +51 -0
  15. package/dist/domain/shape.js +44 -0
  16. package/dist/hooks/useStdoutDimensions.js +17 -0
  17. package/dist/lib/AppError.js +37 -0
  18. package/dist/lib/cache.js +54 -0
  19. package/dist/lib/catchAsync.js +12 -0
  20. package/dist/lib/envelope.js +15 -0
  21. package/dist/lib/fetchWithBackoff.js +65 -0
  22. package/dist/lib/logger.js +41 -0
  23. package/dist/lib/natsort.js +7 -0
  24. package/dist/lib/text.js +20 -0
  25. package/dist/render/chafa.js +56 -0
  26. package/dist/render/detect.js +86 -0
  27. package/dist/render/halfblock.js +42 -0
  28. package/dist/render/image.js +23 -0
  29. package/dist/render/sixel.js +88 -0
  30. package/dist/sixel-reader.js +309 -0
  31. package/dist/sources/index.js +17 -0
  32. package/dist/sources/local/archive.js +68 -0
  33. package/dist/sources/local/index.js +147 -0
  34. package/dist/sources/mangadex/auth.js +102 -0
  35. package/dist/sources/mangadex/client.js +76 -0
  36. package/dist/sources/mangadex/index.js +156 -0
  37. package/dist/sources/mangadex/normalize.js +54 -0
  38. package/dist/state/store.js +91 -0
  39. package/dist/ui-context.js +11 -0
  40. package/package.json +50 -0
  41. package/src/app.js +73 -0
  42. package/src/cli.js +218 -0
  43. package/src/components/List.js +60 -0
  44. package/src/components/screens/ContinueScreen.js +73 -0
  45. package/src/components/screens/HomeScreen.js +54 -0
  46. package/src/components/screens/LibraryScreen.js +79 -0
  47. package/src/components/screens/LoginScreen.js +92 -0
  48. package/src/components/screens/MangaScreen.js +125 -0
  49. package/src/components/screens/ReaderScreen.js +230 -0
  50. package/src/components/screens/SearchScreen.js +123 -0
  51. package/src/components/screens/SettingsScreen.js +146 -0
  52. package/src/components/ui.js +42 -0
  53. package/src/config.js +49 -0
  54. package/src/domain/shape.js +47 -0
  55. package/src/hooks/useStdoutDimensions.js +19 -0
  56. package/src/lib/AppError.js +26 -0
  57. package/src/lib/cache.js +57 -0
  58. package/src/lib/catchAsync.js +12 -0
  59. package/src/lib/envelope.js +14 -0
  60. package/src/lib/fetchWithBackoff.js +74 -0
  61. package/src/lib/logger.js +41 -0
  62. package/src/lib/natsort.js +7 -0
  63. package/src/lib/text.js +18 -0
  64. package/src/render/chafa.js +64 -0
  65. package/src/render/detect.js +112 -0
  66. package/src/render/halfblock.js +46 -0
  67. package/src/render/image.js +24 -0
  68. package/src/render/sixel.js +141 -0
  69. package/src/sixel-reader.js +359 -0
  70. package/src/sources/index.js +17 -0
  71. package/src/sources/local/archive.js +74 -0
  72. package/src/sources/local/index.js +155 -0
  73. package/src/sources/mangadex/auth.js +125 -0
  74. package/src/sources/mangadex/client.js +83 -0
  75. package/src/sources/mangadex/index.js +166 -0
  76. package/src/sources/mangadex/normalize.js +70 -0
  77. package/src/state/store.js +90 -0
  78. package/src/ui-context.js +12 -0
package/src/config.js ADDED
@@ -0,0 +1,49 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+
5
+ // Self-contained runtime home. Override with KOMADO_HOME (handy for tests).
6
+ const usingDefaultHome = !process.env.KOMADO_HOME;
7
+ const HOME = usingDefaultHome
8
+ ? path.join(os.homedir(), '.komado')
9
+ : path.resolve(process.env.KOMADO_HOME);
10
+ // The pre-rename home (the project used to be "manga-tui") — migrated once on
11
+ // first run so existing config / reading progress / MangaDex login carry over.
12
+ const LEGACY_HOME = path.join(os.homedir(), '.manga-tui');
13
+
14
+ export const paths = {
15
+ home: HOME,
16
+ configFile: path.join(HOME, 'config.json'),
17
+ progressFile: path.join(HOME, 'progress.json'),
18
+ credentialsFile: path.join(HOME, 'credentials.json'),
19
+ cacheDir: path.join(HOME, 'cache'),
20
+ logFile: path.join(HOME, 'komado.log'),
21
+ };
22
+
23
+ export function ensureDirs() {
24
+ // One-time migration from the old ~/.manga-tui home so saved state isn't
25
+ // orphaned. Best-effort, default-home only (same filesystem → rename suffices).
26
+ if (usingDefaultHome && !fs.existsSync(HOME) && fs.existsSync(LEGACY_HOME)) {
27
+ try { fs.renameSync(LEGACY_HOME, HOME); } catch { /* leave the legacy dir as-is */ }
28
+ }
29
+ fs.mkdirSync(paths.home, { recursive: true });
30
+ fs.mkdirSync(paths.cacheDir, { recursive: true });
31
+ }
32
+
33
+ export const DEFAULT_CONFIG = {
34
+ localLibraryPaths: [], // directories scanned by the local source
35
+ language: 'en', // preferred MangaDex translatedLanguage
36
+ contentRating: ['safe', 'suggestive'],
37
+ dataSaver: true, // smaller MangaDex page images — ideal for a terminal
38
+ renderer: 'auto', // auto | halfblock | chafa
39
+ theme: 'default',
40
+ syncProgress: true, // push read-markers to MangaDex while logged in
41
+ };
42
+
43
+ export const MANGADEX = {
44
+ api: 'https://api.mangadex.org',
45
+ auth: 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token',
46
+ uploads: 'https://uploads.mangadex.org',
47
+ userAgent: 'komado/0.1 (+https://github.com/RyuPrad/komado)',
48
+ pageLimit: 20,
49
+ };
@@ -0,0 +1,47 @@
1
+ // The unified data-shape contract. Rows from MangaDex and from the local
2
+ // filesystem are normalized into ONE Manga/Chapter shape, so every downstream
3
+ // consumer (search, reader, progress, UI) treats them identically. The only
4
+ // source-specific seam is `source.loadPageBuffer()`, which resolves raw bytes.
5
+
6
+ export const globalKey = (source, id) => `${source}:${id}`;
7
+
8
+ export function makeManga(p) {
9
+ return {
10
+ source: p.source,
11
+ id: String(p.id),
12
+ key: globalKey(p.source, p.id),
13
+ title: p.title || 'Untitled',
14
+ altTitles: p.altTitles || [],
15
+ description: p.description || '',
16
+ authors: p.authors || [],
17
+ status: p.status || 'unknown',
18
+ tags: p.tags || [],
19
+ coverUrl: p.coverUrl || null,
20
+ language: p.language || 'en',
21
+ chaptersCount: p.chaptersCount ?? null,
22
+ raw: p.raw,
23
+ };
24
+ }
25
+
26
+ export function makeChapter(p) {
27
+ return {
28
+ source: p.source,
29
+ id: String(p.id),
30
+ key: globalKey(p.source, p.id),
31
+ mangaKey: p.mangaKey,
32
+ number: p.number ?? null, // string like "12.5", or null for oneshots
33
+ volume: p.volume ?? null,
34
+ title: p.title || '',
35
+ language: p.language || 'en',
36
+ pages: p.pages ?? null,
37
+ publishedAt: p.publishedAt || null,
38
+ raw: p.raw,
39
+ };
40
+ }
41
+
42
+ export function chapterLabel(ch) {
43
+ const head = ch.number != null && ch.number !== ''
44
+ ? `Ch. ${ch.number}${ch.volume != null && ch.volume !== '' ? ` (Vol. ${ch.volume})` : ''}`
45
+ : 'Oneshot';
46
+ return ch.title ? `${head} — ${ch.title}` : head;
47
+ }
@@ -0,0 +1,19 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ // Terminal size + live updates on resize. The reader recomputes its viewport
4
+ // and re-renders the page at the new width from this.
5
+ export function useStdoutDimensions() {
6
+ const read = () => ({
7
+ cols: process.stdout.columns || 80,
8
+ rows: process.stdout.rows || 24,
9
+ });
10
+ const [size, setSize] = useState(read);
11
+
12
+ useEffect(() => {
13
+ const onResize = () => setSize(read());
14
+ process.stdout.on('resize', onResize);
15
+ return () => process.stdout.off('resize', onResize);
16
+ }, []);
17
+
18
+ return size;
19
+ }
@@ -0,0 +1,26 @@
1
+ // Typed, operational errors — same idea as your server AppError, minus HTTP plumbing.
2
+ // statusCode is kept for parity/log triage; the TUI maps these to friendly messages.
3
+ export class AppError extends Error {
4
+ constructor(message, statusCode = 500, opts = {}) {
5
+ super(message);
6
+ this.name = this.constructor.name;
7
+ this.statusCode = statusCode;
8
+ this.isOperational = true;
9
+ if (opts.cause) this.cause = opts.cause;
10
+ if (opts.meta) Object.assign(this, opts.meta);
11
+ }
12
+ }
13
+
14
+ // Critical-vs-decorative branching at the call site, instead of string-matching.
15
+ export class NotFoundError extends AppError {
16
+ constructor(message = 'Not found', opts) { super(message, 404, opts); }
17
+ }
18
+ export class SourceError extends AppError {
19
+ constructor(message = 'Source unavailable', opts) { super(message, 502, opts); }
20
+ }
21
+ export class UnsupportedError extends AppError {
22
+ constructor(message = 'Unsupported', opts) { super(message, 422, opts); }
23
+ }
24
+ export class AuthError extends AppError {
25
+ constructor(message = 'Authentication required', opts) { super(message, 401, opts); }
26
+ }
@@ -0,0 +1,57 @@
1
+ // In-memory cache with TTL, negative caching, and stampede protection —
2
+ // a port of your createCache. `wrap` shares a single in-flight promise per key
3
+ // so concurrent callers (e.g. two screens requesting the same chapter) collapse
4
+ // into one upstream request.
5
+ export function createCache({ ttlMs = 60_000, negativeTtlMs = 5_000, max = 500 } = {}) {
6
+ const store = new Map(); // key -> { value, expires }
7
+ const inflight = new Map(); // key -> Promise
8
+
9
+ function get(key) {
10
+ const entry = store.get(key);
11
+ if (!entry) return undefined;
12
+ if (entry.expires <= Date.now()) {
13
+ store.delete(key);
14
+ return undefined;
15
+ }
16
+ return entry.value;
17
+ }
18
+
19
+ function set(key, value, ttl) {
20
+ const isEmpty = value === null || value === undefined;
21
+ const life = ttl ?? (isEmpty ? negativeTtlMs : ttlMs);
22
+ store.set(key, { value, expires: Date.now() + life });
23
+ // Cheap bound: evict the oldest insertion when over capacity.
24
+ if (store.size > max) {
25
+ const oldest = store.keys().next().value;
26
+ store.delete(oldest);
27
+ }
28
+ }
29
+
30
+ async function wrap(key, fn, ttl) {
31
+ const cached = get(key);
32
+ if (cached !== undefined) return cached; // note: a cached `null` is a hit (negative cache)
33
+ if (inflight.has(key)) return inflight.get(key);
34
+
35
+ const promise = (async () => {
36
+ try {
37
+ const value = await fn();
38
+ set(key, value, ttl);
39
+ return value;
40
+ } finally {
41
+ inflight.delete(key);
42
+ }
43
+ })();
44
+
45
+ inflight.set(key, promise);
46
+ return promise;
47
+ }
48
+
49
+ return {
50
+ get,
51
+ set,
52
+ wrap,
53
+ delete: (key) => store.delete(key),
54
+ clear: () => { store.clear(); inflight.clear(); },
55
+ get size() { return store.size; },
56
+ };
57
+ }
@@ -0,0 +1,12 @@
1
+ // TUI flavour of your server catchAsync. Instead of forwarding to next(err),
2
+ // it resolves a { data, error } result so hooks can drop straight into state
3
+ // without a try/catch at every call site (and never crash the render loop).
4
+ export function catchAsync(fn) {
5
+ return async (...args) => {
6
+ try {
7
+ return { data: await fn(...args), error: null };
8
+ } catch (error) {
9
+ return { data: null, error };
10
+ }
11
+ };
12
+ }
@@ -0,0 +1,14 @@
1
+ // Consistent { data, pagination, meta } shape across every source, exactly like
2
+ // your API envelope. Sources return this so hooks/UI are source-agnostic.
3
+ export function envelope(data, { pagination = null, meta = {} } = {}) {
4
+ return { data, pagination, meta };
5
+ }
6
+
7
+ export function paginate({ offset = 0, limit = 0, total = 0 } = {}) {
8
+ return {
9
+ offset,
10
+ limit,
11
+ total,
12
+ hasMore: total > 0 ? offset + limit < total : false,
13
+ };
14
+ }
@@ -0,0 +1,74 @@
1
+ import { SourceError } from './AppError.js';
2
+
3
+ function sleep(ms, signal) {
4
+ return new Promise((resolve, reject) => {
5
+ if (signal?.aborted) return reject(signal.reason ?? new Error('aborted'));
6
+ const timer = setTimeout(resolve, ms);
7
+ signal?.addEventListener('abort', () => {
8
+ clearTimeout(timer);
9
+ reject(signal.reason ?? new Error('aborted'));
10
+ }, { once: true });
11
+ });
12
+ }
13
+
14
+ // Global fetch + retries on 429/5xx with exponential backoff, honouring
15
+ // Retry-After, plus a hard per-attempt timeout. Caller-supplied AbortSignal
16
+ // short-circuits retries (used by the hooks' cancelled-flag guard).
17
+ export async function fetchWithBackoff(url, options = {}) {
18
+ const {
19
+ retries = 4,
20
+ baseDelayMs = 500,
21
+ maxDelayMs = 8_000,
22
+ timeoutMs = 20_000,
23
+ signal: extSignal,
24
+ ...fetchOpts
25
+ } = options;
26
+
27
+ let attempt = 0;
28
+ for (;;) {
29
+ const ctrl = new AbortController();
30
+ const onExtAbort = () => ctrl.abort(extSignal.reason);
31
+ if (extSignal) {
32
+ if (extSignal.aborted) ctrl.abort(extSignal.reason);
33
+ else extSignal.addEventListener('abort', onExtAbort, { once: true });
34
+ }
35
+ const timer = setTimeout(() => ctrl.abort(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
36
+
37
+ try {
38
+ const res = await fetch(url, { ...fetchOpts, signal: ctrl.signal });
39
+
40
+ if ((res.status === 429 || res.status >= 500) && attempt < retries) {
41
+ const retryAfter = Number(res.headers.get('retry-after'));
42
+ const delay = Number.isFinite(retryAfter) && retryAfter > 0
43
+ ? retryAfter * 1000
44
+ : Math.min(maxDelayMs, baseDelayMs * 2 ** attempt) + Math.random() * 200;
45
+ attempt += 1;
46
+ await sleep(delay, extSignal);
47
+ continue;
48
+ }
49
+ return res;
50
+ } catch (err) {
51
+ // Caller cancelled — propagate without retrying.
52
+ if (extSignal?.aborted) throw err;
53
+ if (attempt < retries) {
54
+ const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt) + Math.random() * 200;
55
+ attempt += 1;
56
+ await sleep(delay, extSignal);
57
+ continue;
58
+ }
59
+ throw new SourceError(`Request failed: ${url}`, { cause: err });
60
+ } finally {
61
+ clearTimeout(timer);
62
+ extSignal?.removeEventListener('abort', onExtAbort);
63
+ }
64
+ }
65
+ }
66
+
67
+ // Convenience JSON wrapper that throws a typed error on non-2xx.
68
+ export async function fetchJson(url, options = {}) {
69
+ const res = await fetchWithBackoff(url, options);
70
+ if (!res.ok) {
71
+ throw new SourceError(`HTTP ${res.status} for ${url}`, { meta: { statusCode: res.status } });
72
+ }
73
+ return res.json();
74
+ }
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs';
2
+ import { paths } from '../config.js';
3
+
4
+ // A TUI owns stdout — console.log would corrupt the Ink render. So debug output
5
+ // goes to a file, only when KOMADO_DEBUG is set. Tail it with:
6
+ // tail -f ~/.komado/komado.log
7
+ const enabled = !!process.env.KOMADO_DEBUG;
8
+ let stream = null;
9
+
10
+ function out() {
11
+ if (!enabled) return null;
12
+ if (!stream) {
13
+ try {
14
+ fs.mkdirSync(paths.home, { recursive: true });
15
+ stream = fs.createWriteStream(paths.logFile, { flags: 'a' });
16
+ } catch {
17
+ stream = null;
18
+ }
19
+ }
20
+ return stream;
21
+ }
22
+
23
+ function fmt(arg) {
24
+ if (typeof arg === 'string') return arg;
25
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
26
+ try { return JSON.stringify(arg); } catch { return String(arg); }
27
+ }
28
+
29
+ function write(level, args) {
30
+ const s = out();
31
+ if (!s) return;
32
+ s.write(`[${new Date().toISOString()}] ${level} ${args.map(fmt).join(' ')}\n`);
33
+ }
34
+
35
+ export const logger = {
36
+ enabled,
37
+ debug: (...a) => write('DEBUG', a),
38
+ info: (...a) => write('INFO', a),
39
+ warn: (...a) => write('WARN', a),
40
+ error: (...a) => write('ERROR', a),
41
+ };
@@ -0,0 +1,7 @@
1
+ // Natural ("human") ordering so page2 < page10 and "Chapter 1.5" sorts sanely.
2
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
3
+
4
+ export const naturalCompare = (a, b) => collator.compare(String(a), String(b));
5
+
6
+ export const naturalSort = (arr, key = (x) => x) =>
7
+ [...arr].sort((a, b) => naturalCompare(key(a), key(b)));
@@ -0,0 +1,18 @@
1
+ export function truncate(str, max) {
2
+ const s = String(str ?? '');
3
+ if (max <= 1 || s.length <= max) return s;
4
+ return s.slice(0, Math.max(1, max - 1)) + '…';
5
+ }
6
+
7
+ // Compact relative time ("3h ago") in LOCAL time, anchored to now.
8
+ export function relativeTime(ts) {
9
+ if (!ts) return '';
10
+ const diff = Date.now() - ts;
11
+ const mins = Math.round(diff / 60_000);
12
+ if (mins < 1) return 'just now';
13
+ if (mins < 60) return `${mins}m ago`;
14
+ const hrs = Math.round(mins / 60);
15
+ if (hrs < 24) return `${hrs}h ago`;
16
+ const days = Math.round(hrs / 24);
17
+ return `${days}d ago`;
18
+ }
@@ -0,0 +1,64 @@
1
+ import { execFile, spawnSync } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { writeFile, unlink, mkdir } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import sharp from 'sharp';
6
+ import { paths } from '../config.js';
7
+ import { detectCapabilities } from './detect.js';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ let dirReady = false;
12
+ async function ensureTempDir() {
13
+ if (dirReady) return;
14
+ await mkdir(paths.cacheDir, { recursive: true });
15
+ dirReady = true;
16
+ }
17
+
18
+ async function withTempImage(buffer, fn) {
19
+ await ensureTempDir();
20
+ const file = path.join(
21
+ paths.cacheDir,
22
+ `chafa-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.png`,
23
+ );
24
+ await writeFile(file, buffer);
25
+ try {
26
+ return await fn(file);
27
+ } finally {
28
+ unlink(file).catch(() => {});
29
+ }
30
+ }
31
+
32
+ // Cell-based symbol output (truecolor ANSI). Compatible with Ink's <Text>
33
+ // layout, so the reader can scroll it like the half-block output. We size the
34
+ // box to the image's natural aspect to avoid chafa padding the result.
35
+ export async function renderChafaSymbols(buffer, { cols = 80 } = {}) {
36
+ const meta = await sharp(buffer).metadata();
37
+ const aspect = (meta.height || 1) / (meta.width || 1);
38
+ const rows = Math.max(1, Math.round((aspect * cols) / 2)); // cells are ~2x tall
39
+
40
+ const stdout = await withTempImage(buffer, (file) =>
41
+ execFileAsync(
42
+ 'chafa',
43
+ ['--format', 'symbols', '--colors', 'full', '--size', `${cols}x${rows}`, file],
44
+ { maxBuffer: 96 * 1024 * 1024 },
45
+ ).then((r) => r.stdout),
46
+ );
47
+
48
+ const lines = stdout.replace(/\n+$/, '').split('\n');
49
+ return { lines, cols, rows: lines.length };
50
+ }
51
+
52
+ // Fullscreen one-shot: print straight to the inherited TTY so chafa can probe
53
+ // the real terminal and auto-select kitty > sixel > symbols. Used by the
54
+ // reader's "high-fidelity" toggle. Returns false if chafa is unavailable.
55
+ export function spawnChafaToTerminal(file, { cols, rows } = {}) {
56
+ if (!detectCapabilities().chafa) return false;
57
+ const args = ['--colors', 'full'];
58
+ if (cols && rows) args.push('--size', `${cols}x${rows}`);
59
+ args.push(file);
60
+ const res = spawnSync('chafa', args, { stdio: ['ignore', 'inherit', 'inherit'] });
61
+ return res.status === 0;
62
+ }
63
+
64
+ export { withTempImage };
@@ -0,0 +1,112 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ let cached = null;
4
+
5
+ // Detect terminal/image capabilities once. Conservative: we never assume a
6
+ // pixel protocol unless there's a strong signal, because the universal
7
+ // half-block path always works.
8
+ export function detectCapabilities() {
9
+ if (cached) return cached;
10
+ const env = process.env;
11
+ const term = env.TERM || '';
12
+ const termProgram = env.TERM_PROGRAM || '';
13
+
14
+ const kitty =
15
+ !!env.KITTY_WINDOW_ID ||
16
+ term.includes('kitty') ||
17
+ termProgram === 'ghostty' ||
18
+ termProgram === 'WezTerm';
19
+
20
+ // Sixel is hard to probe without a terminal round-trip; trust an explicit hint
21
+ // or a couple of known sixel-first terminals.
22
+ const sixel =
23
+ /sixel/i.test(env.KOMADO_CAPS || '') ||
24
+ term === 'foot' || term.includes('foot') || term.includes('mlterm');
25
+
26
+ // We always emit 24-bit colour; non-truecolor terminals degrade gracefully.
27
+ const truecolor = env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit';
28
+
29
+ let chafa = false;
30
+ let chafaVersion = null;
31
+ try {
32
+ const out = execFileSync('chafa', ['--version'], {
33
+ stdio: ['ignore', 'pipe', 'ignore'],
34
+ }).toString();
35
+ chafa = true;
36
+ chafaVersion = (out.match(/version\s+([\d.]+)/i) || [])[1] || 'unknown';
37
+ } catch {
38
+ /* chafa not on PATH — half-block fallback */
39
+ }
40
+
41
+ cached = { term, termProgram, kitty, sixel, truecolor, chafa, chafaVersion };
42
+ return cached;
43
+ }
44
+
45
+ // Inline (scrollable) reader backend. Both options produce an array of terminal
46
+ // lines, so the reader can slice a vertical window for panning.
47
+ export function pickInlineBackend(config, caps = detectCapabilities()) {
48
+ const pref = config?.renderer || 'auto';
49
+ if (pref === 'halfblock') return 'halfblock';
50
+ if (pref === 'chafa') return caps.chafa ? 'chafa-symbols' : 'halfblock';
51
+ // auto: chafa's symbol output is sharper than raw half-blocks when available.
52
+ return caps.chafa ? 'chafa-symbols' : 'halfblock';
53
+ }
54
+
55
+ // Cycle order for the in-reader "switch renderer" key.
56
+ export const RENDERER_CYCLE = ['auto', 'halfblock', 'chafa'];
57
+
58
+ // Actively ask the terminal what it supports by emitting a kitty-graphics
59
+ // support query (APC G) + primary Device Attributes (DA1), then reading the
60
+ // replies. DA1 returns ";4" when sixel is supported; a kitty ";OK" reply means
61
+ // the kitty graphics protocol is available. Needs a real TTY on both ends.
62
+ export async function probeTerminal({ timeoutMs = 350 } = {}) {
63
+ const { stdin, stdout } = process;
64
+ if (!stdout.isTTY || !stdin.isTTY) {
65
+ return { queried: false, sixel: false, kitty: false, cellW: null, cellH: null };
66
+ }
67
+
68
+ return new Promise((resolve) => {
69
+ let buf = '';
70
+ const prevRaw = stdin.isRaw;
71
+ const onData = (d) => { buf += d.toString('latin1'); };
72
+
73
+ try { stdin.setRawMode(true); } catch { /* ignore */ }
74
+ stdin.resume();
75
+ stdin.on('data', onData);
76
+ // kitty support · cell size (16t) · text-area px (14t) · text-area chars
77
+ // (18t) · primary DA. Replies are collected together over the timeout.
78
+ stdout.write('\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[16t\x1b[14t\x1b[18t\x1b[c');
79
+
80
+ setTimeout(() => {
81
+ stdin.removeListener('data', onData);
82
+ try { stdin.setRawMode(prevRaw); } catch { /* ignore */ }
83
+ stdin.pause();
84
+ /* eslint-disable no-control-regex */ // matching raw terminal escape replies
85
+ const kitty = /\x1b_G[^\x1b]*;OK/.test(buf);
86
+ const da = buf.match(/\x1b\[\?([0-9;]+)c/);
87
+ const cell = buf.match(/\x1b\[6;(\d+);(\d+)t/); // CSI 6 ; cellH ; cellW t
88
+ const areaPx = buf.match(/\x1b\[4;(\d+);(\d+)t/); // CSI 4 ; areaH ; areaW t (px)
89
+ const areaCh = buf.match(/\x1b\[8;(\d+);(\d+)t/); // CSI 8 ; rows ; cols t
90
+ /* eslint-enable no-control-regex */
91
+
92
+ const sixel = da ? da[1].split(';').includes('4') : false;
93
+ let cellW = null;
94
+ let cellH = null;
95
+ if (cell) {
96
+ cellH = Number(cell[1]);
97
+ cellW = Number(cell[2]);
98
+ } else if (areaPx && areaCh) {
99
+ const cols = Number(areaCh[2]);
100
+ const rows = Number(areaCh[1]);
101
+ if (cols > 0 && rows > 0) {
102
+ cellW = Number(areaPx[2]) / cols;
103
+ cellH = Number(areaPx[1]) / rows;
104
+ }
105
+ }
106
+ if (!(cellW > 2 && cellW < 64)) cellW = null; // ignore absurd values
107
+ if (!(cellH > 2 && cellH < 96)) cellH = null;
108
+
109
+ resolve({ queried: true, sixel, kitty, cellW, cellH });
110
+ }, timeoutMs);
111
+ });
112
+ }
@@ -0,0 +1,46 @@
1
+ import sharp from 'sharp';
2
+
3
+ const ESC = '\x1b';
4
+ const RESET = `${ESC}[0m`;
5
+
6
+ // Render an image buffer to terminal lines using the upper-half-block glyph (▀):
7
+ // the glyph's foreground paints the TOP half of the cell, the background the
8
+ // BOTTOM half — so each character encodes two vertical pixels at 24-bit colour.
9
+ // One column == one pixel wide, one row == two pixels tall.
10
+ export async function renderHalfBlock(buffer, { cols = 80 } = {}) {
11
+ const targetWidth = Math.max(1, Math.min(Math.floor(cols), 400));
12
+
13
+ const { data, info } = await sharp(buffer)
14
+ .resize({ width: targetWidth, fit: 'inside', withoutEnlargement: false })
15
+ .flatten({ background: '#ffffff' }) // composite any transparency onto white
16
+ .raw()
17
+ .toBuffer({ resolveWithObject: true });
18
+
19
+ const { width, height, channels } = info;
20
+
21
+ const px = (x, y) => {
22
+ const i = (y * width + x) * channels;
23
+ if (channels === 1) { const v = data[i]; return [v, v, v]; }
24
+ return [data[i], data[i + 1], data[i + 2]];
25
+ };
26
+
27
+ const lines = [];
28
+ for (let y = 0; y < height; y += 2) {
29
+ let line = '';
30
+ let lastFg = null;
31
+ let lastBg = null;
32
+ for (let x = 0; x < width; x++) {
33
+ const [tr, tg, tb] = px(x, y);
34
+ const [br, bg, bb] = y + 1 < height ? px(x, y + 1) : [tr, tg, tb];
35
+ const fg = `${tr};${tg};${tb}`;
36
+ const bgc = `${br};${bg};${bb}`;
37
+ // Emit colour codes only when they change — keeps lines compact.
38
+ if (fg !== lastFg) { line += `${ESC}[38;2;${fg}m`; lastFg = fg; }
39
+ if (bgc !== lastBg) { line += `${ESC}[48;2;${bgc}m`; lastBg = bgc; }
40
+ line += '▀';
41
+ }
42
+ lines.push(line + RESET);
43
+ }
44
+
45
+ return { lines, cols: width, rows: lines.length };
46
+ }
@@ -0,0 +1,24 @@
1
+ import sharp from 'sharp';
2
+ import { renderHalfBlock } from './halfblock.js';
3
+ import { renderChafaSymbols } from './chafa.js';
4
+ import { logger } from '../lib/logger.js';
5
+
6
+ export async function imageSize(buffer) {
7
+ const { width = 1, height = 1 } = await sharp(buffer).metadata();
8
+ return { width, height };
9
+ }
10
+
11
+ // Dispatch a buffer to the chosen inline backend, producing { lines, cols, rows }.
12
+ // Always falls back to half-block so a chafa hiccup never blanks the reader —
13
+ // the same "graceful degradation" idea as your SSR→static fallback.
14
+ export async function renderInline(buffer, { cols = 80, backend = 'halfblock' } = {}) {
15
+ if (backend === 'chafa-symbols') {
16
+ try {
17
+ return await renderChafaSymbols(buffer, { cols });
18
+ } catch (err) {
19
+ logger.warn('chafa render failed, falling back to half-block', err);
20
+ return renderHalfBlock(buffer, { cols });
21
+ }
22
+ }
23
+ return renderHalfBlock(buffer, { cols });
24
+ }