ubuligan 1.0.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.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # ubuligan (TUI)
2
+
3
+ ```
4
+ ██ ██ ██████ ██ ██ ██ ██ ██████ █████ ███ ██
5
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██
6
+ ██ ██ ██████ ██ ██ ██ ██ ██ ███ ███████ ██ ██ ██
7
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
8
+ ██████ ██████ ██████ ███████ ██ ██████ ██ ██ ██ ████
9
+ [ markdown recon // terminal access // v2 ]
10
+ ```
11
+
12
+ A full-screen terminal UI for browsing Markdown content from a public GitHub
13
+ repo. Built with [ink](https://github.com/vadimdemedes/ink) (React for the
14
+ terminal). Sibling of the `promaster` CLI in `../cli` — same content source,
15
+ different front-end.
16
+
17
+ - Browse content folders and files without leaving the terminal.
18
+ - **Preview Markdown right in the terminal** (rendered with `marked-terminal`).
19
+ - Open any file in the browser as styled HTML (temporary, auto-cleaned on exit).
20
+ - Podcast folders open their link directly.
21
+
22
+ ## Install
23
+
24
+ ### Option A — npm (global)
25
+
26
+ ```bash
27
+ npm install -g ubuligan
28
+ ubuligan list # interactive TUI
29
+ ubuligan help # usage
30
+ ```
31
+
32
+ ### Option B — npx (no install)
33
+
34
+ ```bash
35
+ npx ubuligan list
36
+ ```
37
+
38
+ ### Option C — from source
39
+
40
+ ```bash
41
+ git clone https://github.com/jsznpm/proman
42
+ cd proman/cli2
43
+ npm install
44
+ node bin/ubuligan.js list
45
+ ```
46
+
47
+ ### Option D — standalone binary
48
+
49
+ Grab a prebuilt executable from `dist/` (or build it — see below), then run it
50
+ directly. No Node required.
51
+
52
+ ```bash
53
+ ./ubuligan-linux list # Linux
54
+ ./ubuligan-mac-arm64 list # macOS (Apple Silicon)
55
+ ubuligan-win.exe list # Windows
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```bash
61
+ ubuligan list # the interactive TUI
62
+ ubuligan help # usage
63
+ ```
64
+
65
+ Point it at a different content repo:
66
+
67
+ ```bash
68
+ PROMASTER_REPO=owner/repo ubuligan list
69
+ GITHUB_TOKEN=... # optional, raises the GitHub API rate limit above 60/hr
70
+ ```
71
+
72
+ Or set it per-project in `package.json`:
73
+
74
+ ```json
75
+ { "ubuligan": { "repo": "owner/repo" } }
76
+ ```
77
+
78
+ ## Keys
79
+
80
+ | Screen | Keys |
81
+ |----------|------|
82
+ | Folders | `↑`/`↓` move · `n`/`p` page · `Enter` open · `q` quit |
83
+ | Files | `↑`/`↓` move · `Space` select · `Enter` preview · `o` open in browser · `n`/`p` page · `b`/`Esc` back |
84
+ | Preview | `↑`/`↓` scroll · `Space` page down · `g`/`G` top/bottom · `o` open in browser · `b`/`Esc` back |
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ npm test # node --test, runs all test/*.test.js
90
+ node --test test/github.test.js # a single file
91
+ ```
92
+
93
+ The I/O layer (`github.js`, `config.js`, `render.js`, `download.js`, `open.js`)
94
+ is copied verbatim from `../cli`; only the interactive layer (`src/ui/*`) is
95
+ new. Network code lives solely in `github.js` so it stays testable by
96
+ monkey-patching `globalThis.fetch`.
97
+
98
+ ## Build standalone executables
99
+
100
+ Requires [bun](https://bun.sh):
101
+
102
+ ```bash
103
+ npm run build:all # or build:win / build:mac / build:mac-intel / build:linux
104
+ ```
105
+
106
+ Output lands in `dist/` (`ubuligan-win.exe`, `ubuligan-mac-arm64`,
107
+ `ubuligan-mac-x64`, `ubuligan-linux`).
108
+
109
+ Verified with bun 1.2.21: ink's yoga WebAssembly bundles into the executable
110
+ fine. `react-devtools-core` is listed as a direct dependency on purpose — ink
111
+ imports it, and without it `bun --compile` fails with `Could not resolve
112
+ "react-devtools-core"`. Keep it installed.
113
+
114
+ > If a future bun version cannot embed `yoga.wasm`
115
+ > ([bun#13552](https://github.com/oven-sh/bun/issues/13552)), ship `yoga.wasm`
116
+ > next to the executable or use the plain `npm`/`npx` install — that path
117
+ > always works.
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import { runList } from "../src/commands/list.js";
3
+ import { HttpError } from "../src/github.js";
4
+ import { printBanner } from "../src/banner.js";
5
+
6
+ const USAGE = `ubuligan - browse Markdown from a GitHub repo in a full-screen TUI
7
+
8
+ Usage:
9
+ ubuligan list Pick a category, browse files, preview in the terminal,
10
+ or open in the browser.
11
+
12
+ Config:
13
+ PROMASTER_REPO=owner/repo Source repo (or "ubuligan".repo in package.json).
14
+ GITHUB_TOKEN=... Optional, raises the GitHub API rate limit.`;
15
+
16
+ async function main() {
17
+ const cmd = process.argv[2];
18
+ switch (cmd) {
19
+ case "list":
20
+ await runList();
21
+ break;
22
+ case undefined:
23
+ case "-h":
24
+ case "--help":
25
+ case "help":
26
+ printBanner();
27
+ console.log(USAGE);
28
+ break;
29
+ default:
30
+ console.error(`Unknown command: ${cmd}\n`);
31
+ printBanner();
32
+ console.log(USAGE);
33
+ process.exitCode = 1;
34
+ }
35
+ }
36
+
37
+ main().catch((err) => {
38
+ if (err && err.name === "ExitPromptError") {
39
+ process.exitCode = 130;
40
+ return;
41
+ }
42
+ if (err instanceof HttpError) {
43
+ console.error(err.message);
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+ console.error(err?.message || String(err));
48
+ process.exitCode = 1;
49
+ });
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "ubuligan",
3
+ "version": "1.0.0",
4
+ "description": "Full-screen TUI (ink) to browse Markdown from a public GitHub repo, preview it in the terminal, and open it in the browser.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ubuligan": "./bin/ubuligan.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "test": "node --test",
16
+ "build:win": "bun build ./bin/ubuligan.js --compile --target=bun-windows-x64 --outfile dist/ubuligan-win.exe",
17
+ "build:mac": "bun build ./bin/ubuligan.js --compile --target=bun-darwin-arm64 --outfile dist/ubuligan-mac-arm64",
18
+ "build:mac-intel": "bun build ./bin/ubuligan.js --compile --target=bun-darwin-x64 --outfile dist/ubuligan-mac-x64",
19
+ "build:linux": "bun build ./bin/ubuligan.js --compile --target=bun-linux-x64 --outfile dist/ubuligan-linux",
20
+ "build:all": "bun run build:win && bun run build:mac && bun run build:mac-intel && bun run build:linux"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "keywords": [
26
+ "cli",
27
+ "tui",
28
+ "ink",
29
+ "markdown",
30
+ "github"
31
+ ],
32
+ "license": "MIT",
33
+ "ubuligan": {
34
+ "repo": "jsznpm/proman"
35
+ },
36
+ "dependencies": {
37
+ "htm": "^3.1.1",
38
+ "ink": "^5.2.1",
39
+ "ink-spinner": "^5.0.0",
40
+ "marked": "^14.0.0",
41
+ "marked-terminal": "^7.3.0",
42
+ "react": "^18.3.1",
43
+ "react-devtools-core": "^4.28.5"
44
+ },
45
+ "devDependencies": {
46
+ "ink-testing-library": "^4.0.0"
47
+ }
48
+ }
package/src/actions.js ADDED
@@ -0,0 +1,60 @@
1
+ import { rmSync } from "node:fs";
2
+ import { fetchRaw } from "./github.js";
3
+ import { saveTemp } from "./download.js";
4
+ import { openInBrowser } from "./open.js";
5
+ import { extractUrl } from "./util.js";
6
+ import { isPodcastFolder } from "./config.js";
7
+
8
+ // Temp dirs created for browser previews. Cleaned up best-effort on exit so
9
+ // nothing is left on disk after the TUI quits.
10
+ const tempDirs = [];
11
+
12
+ function cleanup() {
13
+ while (tempDirs.length) {
14
+ try {
15
+ rmSync(tempDirs.pop(), { recursive: true, force: true });
16
+ } catch {
17
+ /* best effort */
18
+ }
19
+ }
20
+ }
21
+
22
+ let registered = false;
23
+ function ensureCleanupRegistered() {
24
+ if (registered) return;
25
+ registered = true;
26
+ process.on("exit", cleanup);
27
+ process.on("SIGINT", () => {
28
+ cleanup();
29
+ process.exit(130);
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Fetch a file's raw Markdown content.
35
+ */
36
+ export function fetchContent(file, ctx) {
37
+ return fetchRaw(file.download_url, ctx);
38
+ }
39
+
40
+ /**
41
+ * Open a file in the browser. For podcast folders the file holds a link, which
42
+ * is opened directly; otherwise the Markdown is rendered to a temporary HTML
43
+ * file and opened. Returns { ok, msg } for status display.
44
+ */
45
+ export async function openInBrowserFromFile(file, folder, ctx) {
46
+ ensureCleanupRegistered();
47
+ const content = await fetchRaw(file.download_url, ctx);
48
+
49
+ if (isPodcastFolder(folder.name)) {
50
+ const url = extractUrl(content);
51
+ if (!url) return { ok: false, msg: `No link found in ${file.name}` };
52
+ openInBrowser(url);
53
+ return { ok: true, msg: `Opened ${file.name} → ${url}` };
54
+ }
55
+
56
+ const { path, dir } = await saveTemp(file, content);
57
+ tempDirs.push(dir);
58
+ openInBrowser(path);
59
+ return { ok: true, msg: `Opened in browser: ${file.name}` };
60
+ }
package/src/banner.js ADDED
@@ -0,0 +1,29 @@
1
+ const G = "\x1b[32m"; // green
2
+ const GB = "\x1b[1;92m"; // bright green bold
3
+ const DIM = "\x1b[2;32m"; // dim green
4
+ const R = "\x1b[0m"; // reset
5
+
6
+ const ART = String.raw`
7
+ ██ ██ ██████ ██ ██ ██ ██ ██████ █████ ███ ██
8
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██
9
+ ██ ██ ██████ ██ ██ ██ ██ ██ ███ ███████ ██ ██ ██
10
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
11
+ ██████ ██████ ██████ ███████ ██ ██████ ██ ██ ██ ████
12
+ `;
13
+
14
+ const TOP = "╔══════════════════════════════════════════════════════════════╗";
15
+ const BOT = "╚══════════════════════════════════════════════════════════════╝";
16
+
17
+ const BANNER = [
18
+ "",
19
+ `${G}${TOP}${R}`,
20
+ `${GB}${ART.replace(/^\n+|\n+$/g, "")}${R}`,
21
+ `${DIM} [ markdown recon // terminal access // v2 ]${R}`,
22
+ `${G}${BOT}${R}`,
23
+ `${DIM} > By Javid Salimov | Software Engineer | https://github.com/javidselimov${R}`,
24
+ "",
25
+ ].join("\n");
26
+
27
+ export function printBanner() {
28
+ console.log(BANNER);
29
+ }
@@ -0,0 +1,16 @@
1
+ import { render } from "ink";
2
+ import { resolveRepo } from "../config.js";
3
+ import { App } from "../ui/app.js";
4
+ import { html } from "../ui/html.js";
5
+ import { HttpError } from "../github.js";
6
+
7
+ /**
8
+ * Mount the full-screen TUI and wait until the user quits.
9
+ */
10
+ export async function runList() {
11
+ const ctx = resolveRepo();
12
+ const { waitUntilExit } = render(html`<${App} ctx=${ctx} />`);
13
+ await waitUntilExit();
14
+ }
15
+
16
+ export { HttpError };
package/src/config.js ADDED
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ // Default content repo (this git repo). Override with PROMASTER_REPO if the repo name differs.
5
+ const DEFAULT_REPO = "jsznpm/proman";
6
+
7
+ // Top-level folders never shown as content categories (the CLIs' own source).
8
+ // Dot-folders (.git, .github, ...) are skipped separately by listFolders.
9
+ export const EXCLUDED_FOLDERS = ["cli", "cli2"];
10
+
11
+ // Folder whose files hold a podcast link instead of Markdown to render.
12
+ // Selecting such a file opens its link straight in the browser.
13
+ export const PODCAST_FOLDER = "podcast";
14
+
15
+ export function isPodcastFolder(name) {
16
+ return name.toLowerCase() === PODCAST_FOLDER;
17
+ }
18
+
19
+ function parseRepo(value) {
20
+ if (typeof value !== "string") return null;
21
+ const match = value.trim().match(/^([^/\s]+)\/([^/\s]+)$/);
22
+ if (!match) return null;
23
+ return { owner: match[1], repo: match[2] };
24
+ }
25
+
26
+ function fromPackageJson() {
27
+ try {
28
+ const raw = readFileSync(resolve(process.cwd(), "package.json"), "utf8");
29
+ const pkg = JSON.parse(raw);
30
+ return pkg?.ubuligan?.repo ?? null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Resolve the source repo in priority order:
38
+ * 1. env PROMASTER_REPO
39
+ * 2. "ubuligan".repo in the current dir's package.json
40
+ * 3. DEFAULT_REPO constant
41
+ * Returns { owner, repo, token }. Throws if none is a valid "owner/repo".
42
+ */
43
+ export function resolveRepo() {
44
+ const candidates = [process.env.PROMASTER_REPO, fromPackageJson(), DEFAULT_REPO];
45
+ for (const candidate of candidates) {
46
+ const parsed = parseRepo(candidate);
47
+ if (parsed) {
48
+ return { ...parsed, token: process.env.GITHUB_TOKEN || null };
49
+ }
50
+ }
51
+ throw new Error(
52
+ "No source repo configured. Set PROMASTER_REPO=owner/repo, add { \"ubuligan\": { \"repo\": \"owner/repo\" } } to package.json, or edit DEFAULT_REPO."
53
+ );
54
+ }
@@ -0,0 +1,59 @@
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
+ import { basename, extname, resolve } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { mdToHtml } from "./render.js";
5
+
6
+ export const OUTPUT_ROOT = "ubuligan-data";
7
+
8
+ /**
9
+ * Sanitize a GitHub file name to a safe basename (no traversal, no slashes).
10
+ */
11
+ export function safeName(name) {
12
+ const base = basename(String(name).replace(/\\/g, "/"));
13
+ if (!base || base === "." || base === "..") {
14
+ throw new Error(`Unsafe file name: ${name}`);
15
+ }
16
+ return base;
17
+ }
18
+
19
+ /**
20
+ * Safe basename with its extension replaced by ".html".
21
+ */
22
+ export function htmlName(name) {
23
+ const base = safeName(name);
24
+ const ext = extname(base);
25
+ const stem = ext ? base.slice(0, -ext.length) : base;
26
+ return `${stem}.html`;
27
+ }
28
+
29
+ /**
30
+ * Render markdown to a styled HTML document and save it under
31
+ * ./ubuligan-data/<category>/<name>.html. Returns the absolute path.
32
+ */
33
+ export async function save(category, file, content) {
34
+ const dir = resolve(process.cwd(), OUTPUT_ROOT, safeName(category));
35
+ await mkdir(dir, { recursive: true });
36
+ const base = safeName(file.name);
37
+ const ext = extname(base);
38
+ const title = ext ? base.slice(0, -ext.length) : base;
39
+ const html = mdToHtml(content, { title });
40
+ const dest = resolve(dir, htmlName(file.name));
41
+ await writeFile(dest, html, "utf8");
42
+ return dest;
43
+ }
44
+
45
+ /**
46
+ * Render markdown to HTML in a fresh OS temp directory (never under
47
+ * ubuligan-data). Returns { path, dir } so the caller can delete `dir`
48
+ * once the user is done viewing — nothing is persisted.
49
+ */
50
+ export async function saveTemp(file, content) {
51
+ const dir = await mkdtemp(resolve(tmpdir(), "ubuligan-"));
52
+ const base = safeName(file.name);
53
+ const ext = extname(base);
54
+ const title = ext ? base.slice(0, -ext.length) : base;
55
+ const html = mdToHtml(content, { title });
56
+ const path = resolve(dir, htmlName(file.name));
57
+ await writeFile(path, html, "utf8");
58
+ return { path, dir };
59
+ }
package/src/github.js ADDED
@@ -0,0 +1,111 @@
1
+ const API = "https://api.github.com";
2
+
3
+ function headers({ token }) {
4
+ const h = {
5
+ Accept: "application/vnd.github+json",
6
+ "User-Agent": "ubuligan-cli",
7
+ };
8
+ if (token) h.Authorization = `Bearer ${token}`;
9
+ return h;
10
+ }
11
+
12
+ class HttpError extends Error {
13
+ constructor(message, { status, rateLimited } = {}) {
14
+ super(message);
15
+ this.name = "HttpError";
16
+ this.status = status;
17
+ this.rateLimited = Boolean(rateLimited);
18
+ }
19
+ }
20
+
21
+ async function request(url, ctx) {
22
+ const res = await fetch(url, { headers: headers(ctx) });
23
+ if (res.ok) return res;
24
+ const rateLimited =
25
+ res.status === 403 && res.headers.get("x-ratelimit-remaining") === "0";
26
+ if (rateLimited) {
27
+ throw new HttpError(
28
+ "GitHub API rate limit reached (60/hr unauthenticated). Wait and retry, or set GITHUB_TOKEN.",
29
+ { status: 403, rateLimited: true }
30
+ );
31
+ }
32
+ let snippet = "";
33
+ try {
34
+ snippet = (await res.text()).slice(0, 200);
35
+ } catch {
36
+ /* ignore */
37
+ }
38
+ throw new HttpError(`GitHub API ${res.status} ${res.statusText}: ${snippet}`, {
39
+ status: res.status,
40
+ });
41
+ }
42
+
43
+ /**
44
+ * List top-level directories of the repo, sorted alphabetically.
45
+ * Skips dot-folders and any names in `exclude`.
46
+ * Returns [{ name, path }].
47
+ */
48
+ export async function listFolders(ctx, { exclude = [] } = {}) {
49
+ const url = `${API}/repos/${ctx.owner}/${ctx.repo}/contents/`;
50
+ let res;
51
+ try {
52
+ res = await request(url, ctx);
53
+ } catch (err) {
54
+ if (err instanceof HttpError && err.status === 404) return [];
55
+ throw err;
56
+ }
57
+ const items = await res.json();
58
+ if (!Array.isArray(items)) return [];
59
+ const skip = new Set(exclude);
60
+ return items
61
+ .filter((it) => it.type === "dir")
62
+ .filter((it) => !it.name.startsWith(".") && !skip.has(it.name))
63
+ .map((it) => ({ name: it.name, path: it.path }))
64
+ .sort((a, b) => a.name.localeCompare(b.name));
65
+ }
66
+
67
+ /**
68
+ * List .md files in a top-level category folder of the repo.
69
+ * Returns [{ name, path, download_url }].
70
+ */
71
+ export async function listMarkdown(category, ctx) {
72
+ const url = `${API}/repos/${ctx.owner}/${ctx.repo}/contents/${encodeURIComponent(category)}`;
73
+ let res;
74
+ try {
75
+ res = await request(url, ctx);
76
+ } catch (err) {
77
+ if (err instanceof HttpError && err.status === 404) return [];
78
+ throw err;
79
+ }
80
+ const items = await res.json();
81
+ if (!Array.isArray(items)) return [];
82
+ return items
83
+ .filter((it) => it.type === "file" && it.name.endsWith(".md"))
84
+ .map((it) => ({ name: it.name, path: it.path, download_url: it.download_url }));
85
+ }
86
+
87
+ /**
88
+ * Last commit date (ms epoch) touching a path. Returns 0 on failure.
89
+ */
90
+ export async function lastCommitDate(path, ctx) {
91
+ const url = `${API}/repos/${ctx.owner}/${ctx.repo}/commits?path=${encodeURIComponent(path)}&per_page=1`;
92
+ try {
93
+ const res = await request(url, ctx);
94
+ const commits = await res.json();
95
+ const iso = commits?.[0]?.commit?.committer?.date;
96
+ const ms = iso ? Date.parse(iso) : NaN;
97
+ return Number.isNaN(ms) ? 0 : ms;
98
+ } catch {
99
+ return 0;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Fetch raw file content (utf-8 text).
105
+ */
106
+ export async function fetchRaw(url, ctx) {
107
+ const res = await request(url, ctx);
108
+ return res.text();
109
+ }
110
+
111
+ export { HttpError };
@@ -0,0 +1,17 @@
1
+ import { Marked } from "marked";
2
+ import { markedTerminal } from "marked-terminal";
3
+
4
+ // Isolated Marked instance so the terminal renderer never mutates the global
5
+ // `marked` singleton used by render.js for browser HTML.
6
+ let instance;
7
+
8
+ /**
9
+ * Render Markdown source to an ANSI-colored string for terminal display.
10
+ */
11
+ export function renderMarkdownToAnsi(src) {
12
+ if (!instance) {
13
+ instance = new Marked();
14
+ instance.use(markedTerminal());
15
+ }
16
+ return String(instance.parse(String(src)));
17
+ }
package/src/open.js ADDED
@@ -0,0 +1,31 @@
1
+ import { spawn } from "node:child_process";
2
+ import { platform } from "node:process";
3
+
4
+ /**
5
+ * Open a file path OR a URL in the OS default application (browser for
6
+ * .html and http(s) links).
7
+ * Non-blocking and best-effort: failures are reported but never throw.
8
+ */
9
+ export function openInBrowser(filePath) {
10
+ let cmd, args;
11
+ if (platform === "win32") {
12
+ // empty "" is the window title arg required by `start`
13
+ cmd = "cmd";
14
+ args = ["/c", "start", "", filePath];
15
+ } else if (platform === "darwin") {
16
+ cmd = "open";
17
+ args = [filePath];
18
+ } else {
19
+ cmd = "xdg-open";
20
+ args = [filePath];
21
+ }
22
+ try {
23
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
24
+ child.on("error", (err) => {
25
+ console.error(`Could not open ${filePath}: ${err.message}`);
26
+ });
27
+ child.unref();
28
+ } catch (err) {
29
+ console.error(`Could not open ${filePath}: ${err.message}`);
30
+ }
31
+ }
@@ -0,0 +1,34 @@
1
+ // Pure page-math used by the TUI list screens. Page index is clamped to
2
+ // [0, totalPages-1].
3
+
4
+ /**
5
+ * Stateful pager over a fixed array.
6
+ */
7
+ export function createPaginator(items, pageSize = 10) {
8
+ let page = 0;
9
+ const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
10
+ return {
11
+ get page() {
12
+ return page;
13
+ },
14
+ get totalPages() {
15
+ return totalPages;
16
+ },
17
+ get hasNext() {
18
+ return page < totalPages - 1;
19
+ },
20
+ get hasPrev() {
21
+ return page > 0;
22
+ },
23
+ next() {
24
+ if (page < totalPages - 1) page++;
25
+ },
26
+ prev() {
27
+ if (page > 0) page--;
28
+ },
29
+ pageItems() {
30
+ const start = page * pageSize;
31
+ return items.slice(start, start + pageSize);
32
+ },
33
+ };
34
+ }
package/src/render.js ADDED
@@ -0,0 +1,77 @@
1
+ import { marked } from "marked";
2
+
3
+ const ESCAPE = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
4
+
5
+ function escapeHtml(str) {
6
+ return String(str).replace(/[&<>"]/g, (ch) => ESCAPE[ch]);
7
+ }
8
+
9
+ const STYLE = `
10
+ :root { color-scheme: light dark; }
11
+ body {
12
+ max-width: 760px;
13
+ margin: 2.5rem auto;
14
+ padding: 0 1.25rem;
15
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
16
+ line-height: 1.65;
17
+ color: #1a1a1a;
18
+ background: #ffffff;
19
+ }
20
+ h1, h2, h3, h4 { line-height: 1.25; margin-top: 1.8em; }
21
+ h1 { border-bottom: 1px solid #e1e4e8; padding-bottom: .3em; }
22
+ a { color: #0969da; }
23
+ code {
24
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
25
+ font-size: .9em;
26
+ background: rgba(127,127,127,.15);
27
+ padding: .15em .35em;
28
+ border-radius: 4px;
29
+ }
30
+ pre {
31
+ background: #f6f8fa;
32
+ padding: 1rem;
33
+ border-radius: 8px;
34
+ overflow-x: auto;
35
+ }
36
+ pre code { background: none; padding: 0; }
37
+ blockquote {
38
+ margin: 0;
39
+ padding: 0 1em;
40
+ color: #57606a;
41
+ border-left: .25em solid #d0d7de;
42
+ }
43
+ table { border-collapse: collapse; width: 100%; }
44
+ th, td { border: 1px solid #d0d7de; padding: .5em .75em; }
45
+ img { max-width: 100%; }
46
+ @media (prefers-color-scheme: dark) {
47
+ body { color: #e6edf3; background: #0d1117; }
48
+ h1 { border-bottom-color: #30363d; }
49
+ a { color: #4493f8; }
50
+ pre { background: #161b22; }
51
+ blockquote { color: #8b949e; border-left-color: #30363d; }
52
+ th, td { border-color: #30363d; }
53
+ }
54
+ `;
55
+
56
+ /**
57
+ * Convert markdown text into a standalone, styled HTML document.
58
+ * @param {string} markdown raw markdown source
59
+ * @param {{ title?: string }} [opts]
60
+ * @returns {string} full HTML document
61
+ */
62
+ export function mdToHtml(markdown, { title = "Document" } = {}) {
63
+ const body = marked.parse(String(markdown));
64
+ return `<!DOCTYPE html>
65
+ <html lang="en">
66
+ <head>
67
+ <meta charset="utf-8">
68
+ <meta name="viewport" content="width=device-width, initial-scale=1">
69
+ <title>${escapeHtml(title)}</title>
70
+ <style>${STYLE}</style>
71
+ </head>
72
+ <body>
73
+ ${body}
74
+ </body>
75
+ </html>
76
+ `;
77
+ }
package/src/ui/app.js ADDED
@@ -0,0 +1,86 @@
1
+ import { useState } from "react";
2
+ import { useApp } from "ink";
3
+ import { html } from "./html.js";
4
+ import { FolderList } from "./folder-list.js";
5
+ import { FileList } from "./file-list.js";
6
+ import { Preview } from "./preview.js";
7
+ import { isPodcastFolder } from "../config.js";
8
+ import { openInBrowserFromFile } from "../actions.js";
9
+
10
+ /**
11
+ * Top-level TUI. Screen state machine: folders → files → preview.
12
+ */
13
+ export function App({ ctx }) {
14
+ const { exit } = useApp();
15
+ const [screen, setScreen] = useState("folders");
16
+ const [folder, setFolder] = useState(null);
17
+ const [previewFile, setPreviewFile] = useState(null);
18
+ const [status, setStatus] = useState("");
19
+
20
+ async function handleOpen(picks) {
21
+ const msgs = [];
22
+ for (const f of picks) {
23
+ try {
24
+ const r = await openInBrowserFromFile(f, folder, ctx);
25
+ msgs.push(r.msg);
26
+ } catch (e) {
27
+ msgs.push(e?.message || String(e));
28
+ }
29
+ }
30
+ setStatus(msgs.join(" | "));
31
+ }
32
+
33
+ async function handlePreview(file) {
34
+ // Podcast files hold a link, not Markdown — open it directly.
35
+ if (isPodcastFolder(folder.name)) {
36
+ await handleOpen([file]);
37
+ return;
38
+ }
39
+ setStatus("");
40
+ setPreviewFile(file);
41
+ setScreen("preview");
42
+ }
43
+
44
+ if (screen === "folders") {
45
+ return html`
46
+ <${FolderList}
47
+ ctx=${ctx}
48
+ onSelect=${(f) => {
49
+ setStatus("");
50
+ setFolder(f);
51
+ setScreen("files");
52
+ }}
53
+ onQuit=${exit}
54
+ />
55
+ `;
56
+ }
57
+
58
+ if (screen === "files") {
59
+ return html`
60
+ <${FileList}
61
+ ctx=${ctx}
62
+ folder=${folder}
63
+ status=${status}
64
+ onPreview=${handlePreview}
65
+ onOpen=${handleOpen}
66
+ onBack=${() => {
67
+ setStatus("");
68
+ setScreen("folders");
69
+ }}
70
+ />
71
+ `;
72
+ }
73
+
74
+ if (screen === "preview") {
75
+ return html`
76
+ <${Preview}
77
+ ctx=${ctx}
78
+ file=${previewFile}
79
+ onOpen=${() => handleOpen([previewFile])}
80
+ onBack=${() => setScreen("files")}
81
+ />
82
+ `;
83
+ }
84
+
85
+ return null;
86
+ }
@@ -0,0 +1,139 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { html } from "./html.js";
5
+ import { Frame } from "./frame.js";
6
+ import { useAsync } from "./use-data.js";
7
+ import { listMarkdown, lastCommitDate } from "../github.js";
8
+ import { mapLimit, fmtDate } from "../util.js";
9
+ import { COLORS } from "./theme.js";
10
+
11
+ const PAGE_SIZE = 10;
12
+ const CONCURRENCY = 5;
13
+
14
+ /**
15
+ * Paginated multi-select over a folder's .md files. Page + cursor-within-page
16
+ * model so each page can be re-sorted by commit date (newest first), matching
17
+ * the original CLI. Commit dates are fetched lazily, per visible page.
18
+ *
19
+ * Keys: ↑/↓ move · n/p page · Space toggle · Enter preview · o open in
20
+ * browser · b/Esc back.
21
+ */
22
+ export function FileList({ ctx, folder, status, onPreview, onOpen, onBack }) {
23
+ const { loading, data: files, error } = useAsync(
24
+ () => listMarkdown(folder.name, ctx),
25
+ [folder.name]
26
+ );
27
+ const [page, setPage] = useState(0);
28
+ const [cursor, setCursor] = useState(0);
29
+ const [selected, setSelected] = useState(() => new Set());
30
+ const [dates, setDates] = useState(() => new Map());
31
+
32
+ const totalPages = files ? Math.max(1, Math.ceil(files.length / PAGE_SIZE)) : 1;
33
+ const pageItems = files ? files.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE) : [];
34
+
35
+ // Fetch commit dates for the current page only.
36
+ useEffect(() => {
37
+ if (!files) return;
38
+ const missing = pageItems.filter((f) => !dates.has(f.path));
39
+ if (!missing.length) return;
40
+ let alive = true;
41
+ mapLimit(missing, CONCURRENCY, (f) => lastCommitDate(f.path, ctx)).then((ds) => {
42
+ if (!alive) return;
43
+ setDates((prev) => {
44
+ const m = new Map(prev);
45
+ missing.forEach((f, i) => m.set(f.path, ds[i]));
46
+ return m;
47
+ });
48
+ });
49
+ return () => {
50
+ alive = false;
51
+ };
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, [files, page]);
54
+
55
+ const sorted = [...pageItems].sort(
56
+ (a, b) => (dates.get(b.path) ?? 0) - (dates.get(a.path) ?? 0)
57
+ );
58
+ const highlighted = sorted[Math.min(cursor, sorted.length - 1)];
59
+
60
+ useInput((input, key) => {
61
+ if (loading) return;
62
+ if (error || !files || !files.length) {
63
+ if (input === "b" || key.escape) onBack();
64
+ return;
65
+ }
66
+ const len = sorted.length;
67
+ if (key.downArrow || input === "j") {
68
+ if (cursor < len - 1) setCursor(cursor + 1);
69
+ else if (page < totalPages - 1) {
70
+ setPage(page + 1);
71
+ setCursor(0);
72
+ }
73
+ } else if (key.upArrow || input === "k") {
74
+ if (cursor > 0) setCursor(cursor - 1);
75
+ else if (page > 0) {
76
+ setPage(page - 1);
77
+ setCursor(PAGE_SIZE - 1);
78
+ }
79
+ } else if (input === "n" || key.rightArrow) {
80
+ if (page < totalPages - 1) {
81
+ setPage(page + 1);
82
+ setCursor(0);
83
+ }
84
+ } else if (input === "p" || key.leftArrow) {
85
+ if (page > 0) {
86
+ setPage(page - 1);
87
+ setCursor(0);
88
+ }
89
+ } else if (input === " ") {
90
+ if (highlighted) {
91
+ setSelected((prev) => {
92
+ const s = new Set(prev);
93
+ if (s.has(highlighted.path)) s.delete(highlighted.path);
94
+ else s.add(highlighted.path);
95
+ return s;
96
+ });
97
+ }
98
+ } else if (key.return) {
99
+ if (highlighted) onPreview(highlighted);
100
+ } else if (input === "o") {
101
+ const picks = selected.size
102
+ ? files.filter((f) => selected.has(f.path))
103
+ : highlighted
104
+ ? [highlighted]
105
+ : [];
106
+ if (picks.length) onOpen(picks);
107
+ } else if (input === "b" || key.escape) {
108
+ onBack();
109
+ }
110
+ });
111
+
112
+ if (loading)
113
+ return html`<${Frame} title=${folder.name}><${Text}><${Spinner} type="dots" /> Loading files…<//><//>`;
114
+ if (error)
115
+ return html`<${Frame} title=${folder.name} hint="b/Esc back"><${Text} color=${COLORS.error}>${error.message}<//><//>`;
116
+ if (!files.length)
117
+ return html`<${Frame} title=${folder.name} hint="b/Esc back"><${Text}>No .md files in "${folder.name}".<//><//>`;
118
+
119
+ return html`
120
+ <${Frame}
121
+ title=${`${folder.name} — page ${page + 1}/${totalPages} · ${selected.size} selected`}
122
+ status=${status}
123
+ hint="↑/↓ move · Space select · Enter preview · o browser · n/p page · b back"
124
+ >
125
+ ${sorted.map((f, i) => {
126
+ const active = i === Math.min(cursor, sorted.length - 1);
127
+ const checked = selected.has(f.path);
128
+ return html`
129
+ <${Box} key=${f.path}>
130
+ <${Text} color=${active ? COLORS.marker : COLORS.dim}>${active ? "› " : " "}<//>
131
+ <${Text} color=${checked ? COLORS.selected : COLORS.dim}>${checked ? "[x] " : "[ ] "}<//>
132
+ <${Text} color=${active ? COLORS.selected : undefined} bold=${active}>${f.name}<//>
133
+ <${Text} color=${COLORS.dim}>${" (" + fmtDate(dates.get(f.path)) + ")"}<//>
134
+ <//>
135
+ `;
136
+ })}
137
+ <//>
138
+ `;
139
+ }
@@ -0,0 +1,68 @@
1
+ import { useState } from "react";
2
+ import { Text, useInput } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { html } from "./html.js";
5
+ import { Frame } from "./frame.js";
6
+ import { useAsync } from "./use-data.js";
7
+ import { listFolders } from "../github.js";
8
+ import { EXCLUDED_FOLDERS } from "../config.js";
9
+ import { COLORS } from "./theme.js";
10
+
11
+ const PAGE_SIZE = 10;
12
+
13
+ /**
14
+ * Paginated single-select over content folders. Cursor is an absolute index
15
+ * into the full list; the visible page is derived from it.
16
+ */
17
+ export function FolderList({ ctx, onSelect, onQuit }) {
18
+ const { loading, data: folders, error } = useAsync(
19
+ () => listFolders(ctx, { exclude: EXCLUDED_FOLDERS }),
20
+ []
21
+ );
22
+ const [cursor, setCursor] = useState(0);
23
+
24
+ useInput((input, key) => {
25
+ if (loading) return;
26
+ if (error || !folders || !folders.length) {
27
+ if (input === "q") onQuit();
28
+ return;
29
+ }
30
+ const n = folders.length;
31
+ if (key.downArrow || input === "j") setCursor((c) => Math.min(n - 1, c + 1));
32
+ else if (key.upArrow || input === "k") setCursor((c) => Math.max(0, c - 1));
33
+ else if (input === "n" || key.rightArrow)
34
+ setCursor((c) => Math.min(n - 1, (Math.floor(c / PAGE_SIZE) + 1) * PAGE_SIZE));
35
+ else if (input === "p" || key.leftArrow)
36
+ setCursor((c) => Math.max(0, (Math.floor(c / PAGE_SIZE) - 1) * PAGE_SIZE));
37
+ else if (key.return) onSelect(folders[cursor]);
38
+ else if (input === "q") onQuit();
39
+ });
40
+
41
+ if (loading)
42
+ return html`<${Frame}><${Text}><${Spinner} type="dots" /> Loading folders…<//><//>`;
43
+ if (error)
44
+ return html`<${Frame} hint="q quit"><${Text} color=${COLORS.error}>${error.message}<//><//>`;
45
+ if (!folders.length)
46
+ return html`<${Frame} hint="q quit"><${Text}>No folders found.<//><//>`;
47
+
48
+ const page = Math.floor(cursor / PAGE_SIZE);
49
+ const totalPages = Math.max(1, Math.ceil(folders.length / PAGE_SIZE));
50
+ const start = page * PAGE_SIZE;
51
+ const visible = folders.slice(start, start + PAGE_SIZE);
52
+
53
+ return html`
54
+ <${Frame}
55
+ title=${`Folders — page ${page + 1}/${totalPages}`}
56
+ hint="↑/↓ move · n/p page · Enter open · q quit"
57
+ >
58
+ ${visible.map((f, i) => {
59
+ const active = start + i === cursor;
60
+ return html`
61
+ <${Text} key=${f.path} color=${active ? COLORS.selected : undefined} bold=${active}>
62
+ ${active ? "› " : " "}${f.name}
63
+ <//>
64
+ `;
65
+ })}
66
+ <//>
67
+ `;
68
+ }
@@ -0,0 +1,29 @@
1
+ import { Box, Text } from "ink";
2
+ import { html } from "./html.js";
3
+ import { COLORS, LOGO } from "./theme.js";
4
+
5
+ /**
6
+ * Shared screen chrome: logo header, optional subtitle, a bordered content
7
+ * box, and an optional key-hint / status footer.
8
+ */
9
+ export function Frame({ title, hint, status, children }) {
10
+ return html`
11
+ <${Box} flexDirection="column" paddingX=${1} paddingY=${0}>
12
+ <${Box}>
13
+ <${Text} color=${COLORS.accent} bold=${true}>${LOGO}<//>
14
+ ${title ? html`<${Text} color=${COLORS.dim}>${" · " + title}<//>` : null}
15
+ <//>
16
+ <${Box}
17
+ flexDirection="column"
18
+ borderStyle="round"
19
+ borderColor=${COLORS.accent}
20
+ paddingX=${1}
21
+ marginTop=${1}
22
+ >
23
+ ${children}
24
+ <//>
25
+ ${status ? html`<${Box} marginTop=${1}><${Text} color=${COLORS.warn}>${status}<//><//>` : null}
26
+ ${hint ? html`<${Box} marginTop=${status ? 0 : 1}><${Text} color=${COLORS.dim}>${hint}<//><//>` : null}
27
+ <//>
28
+ `;
29
+ }
package/src/ui/html.js ADDED
@@ -0,0 +1,6 @@
1
+ import htm from "htm";
2
+ import { createElement } from "react";
3
+
4
+ // htm bound to React.createElement — lets us write JSX-like markup with no
5
+ // build step (plain Node ESM).
6
+ export const html = htm.bind(createElement);
@@ -0,0 +1,61 @@
1
+ import { useState, useMemo } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { html } from "./html.js";
5
+ import { Frame } from "./frame.js";
6
+ import { useAsync } from "./use-data.js";
7
+ import { fetchContent } from "../actions.js";
8
+ import { renderMarkdownToAnsi } from "../markdown.js";
9
+ import { COLORS } from "./theme.js";
10
+
11
+ /**
12
+ * Scrollable in-terminal Markdown preview. ↑/↓ scroll one line, Space/PgDn a
13
+ * page, o opens in the browser, b/Esc go back.
14
+ */
15
+ export function Preview({ ctx, file, onOpen, onBack }) {
16
+ const { loading, data: content, error } = useAsync(
17
+ () => fetchContent(file, ctx),
18
+ [file.path]
19
+ );
20
+ const [offset, setOffset] = useState(0);
21
+
22
+ const lines = useMemo(
23
+ () => (content != null ? renderMarkdownToAnsi(content).replace(/\n$/, "").split("\n") : []),
24
+ [content]
25
+ );
26
+
27
+ const rows = process.stdout.rows || 24;
28
+ const viewport = Math.max(5, rows - 8);
29
+ const maxOffset = Math.max(0, lines.length - viewport);
30
+
31
+ useInput((input, key) => {
32
+ if (input === "o") onOpen();
33
+ else if (input === "b" || key.escape) onBack();
34
+ else if (loading || error) return;
35
+ else if (key.downArrow || input === "j") setOffset((o) => Math.min(maxOffset, o + 1));
36
+ else if (key.upArrow || input === "k") setOffset((o) => Math.max(0, o - 1));
37
+ else if (input === " " || key.pageDown) setOffset((o) => Math.min(maxOffset, o + viewport));
38
+ else if (key.pageUp) setOffset((o) => Math.max(0, o - viewport));
39
+ else if (input === "g") setOffset(0);
40
+ else if (input === "G") setOffset(maxOffset);
41
+ });
42
+
43
+ if (loading)
44
+ return html`<${Frame} title=${file.name}><${Text}><${Spinner} type="dots" /> Loading…<//><//>`;
45
+ if (error)
46
+ return html`<${Frame} title=${file.name} hint="b/Esc back"><${Text} color=${COLORS.error}>${error.message}<//><//>`;
47
+
48
+ const view = lines.slice(offset, offset + viewport);
49
+ const atEnd = offset >= maxOffset;
50
+
51
+ return html`
52
+ <${Frame}
53
+ title=${`${file.name} — ${offset + 1}-${Math.min(offset + viewport, lines.length)}/${lines.length}`}
54
+ hint=${`↑/↓ scroll · Space page · g/G top/bottom · o browser · b back${atEnd ? "" : " · ▼ more"}`}
55
+ >
56
+ <${Box} flexDirection="column">
57
+ ${view.map((line, i) => html`<${Text} key=${offset + i} wrap="truncate-end">${line || " "}<//>`)}
58
+ <//>
59
+ <//>
60
+ `;
61
+ }
@@ -0,0 +1,12 @@
1
+ // Shared colors for the TUI. Color names are passed straight to ink's <Text>
2
+ // / <Box> color props.
3
+ export const COLORS = {
4
+ accent: "cyan",
5
+ selected: "green",
6
+ marker: "magenta",
7
+ dim: "gray",
8
+ warn: "yellow",
9
+ error: "red",
10
+ };
11
+
12
+ export const LOGO = "UBULIGAN";
@@ -0,0 +1,28 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ /**
4
+ * Run an async function and track { loading, data, error }. Re-runs when
5
+ * `deps` change. Ignores results after unmount / dep change.
6
+ */
7
+ export function useAsync(fn, deps) {
8
+ const [state, setState] = useState({ loading: true, data: null, error: null });
9
+ useEffect(() => {
10
+ let alive = true;
11
+ setState({ loading: true, data: null, error: null });
12
+ Promise.resolve()
13
+ .then(fn)
14
+ .then(
15
+ (data) => {
16
+ if (alive) setState({ loading: false, data, error: null });
17
+ },
18
+ (error) => {
19
+ if (alive) setState({ loading: false, data: null, error });
20
+ }
21
+ );
22
+ return () => {
23
+ alive = false;
24
+ };
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps
26
+ }, deps);
27
+ return state;
28
+ }
package/src/util.js ADDED
@@ -0,0 +1,32 @@
1
+ // Pure helpers shared by the UI and tests.
2
+
3
+ /**
4
+ * Run an async mapper over items with a bounded number of in-flight tasks.
5
+ * Preserves input order in the result array.
6
+ */
7
+ export async function mapLimit(items, limit, mapper) {
8
+ const results = new Array(items.length);
9
+ let next = 0;
10
+ async function worker() {
11
+ while (next < items.length) {
12
+ const i = next++;
13
+ results[i] = await mapper(items[i], i);
14
+ }
15
+ }
16
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
17
+ return results;
18
+ }
19
+
20
+ /** Format a commit timestamp (ms epoch) as YYYY-MM-DD. */
21
+ export function fmtDate(ms) {
22
+ return ms ? new Date(ms).toISOString().slice(0, 10) : "????-??-??";
23
+ }
24
+
25
+ /**
26
+ * First http(s) URL in the content. Handles a bare URL or a Markdown link
27
+ * like [title](https://...). Returns null if none found.
28
+ */
29
+ export function extractUrl(content) {
30
+ const match = String(content).match(/https?:\/\/[^\s)<>"']+/);
31
+ return match ? match[0] : null;
32
+ }