┌─ TS ───────────────────────────────────────────────────────────────────────┐
│ // Build-time ASCII rendering helpers. Pure functions, no DOM. │
│ │
│ export const G = { │
│ H: "─", V: "│", │
│ TL: "┌", TR: "┐", BL: "└", BR: "┘", │
│ LJ: "├", RJ: "┤", TJ: "┬", BJ: "┴", X: "┼", │
│ D: "═", HD: "╌", │
│ }; │
│ │
│ // Codepoint length, not UTF-16 units - frame math breaks on surrogate │
│ // pairs otherwise. │
│ export const len = (s: unknown): number => [...String(s ?? "")].length; │
│ │
│ // The site is og ascii: printable ASCII + tab for text, box drawing + │
│ // block elements for chrome, plus NBSP (layout) and the measured │
│ // arrows ↑ ↓. Typographic glyphs (em dash, ellipsis, curly quotes, │
│ // Latin-1 letters) are deliberately off-grid so the build rejects │
│ // them - write "-", "...", and straight quotes instead. │
│ const ON_GRID = /^[\t\x20-\x7E\u00A0\u2191\u2193\u2500-\u259F]$/u; │
│ │
│ /** Throws unless every line of `text` fits `maxW` cells and uses only │
│ * on-grid glyphs. Returns `text` so call sites can wrap in place. */ │
│ export function checkGrid(text: string, maxW: number, ctx: string): string { │
│ for (const line of text.split("\n")) { │
│ if (len(line) > maxW) { │
│ throw new Error(`[ascii] ${ctx}: line is ${len(line)} cells, max ${maxW}: "$ │
│ {[...line].slice(0, 40).join("")}..."`); │
│ } │
│ const bad = [...line].find((c) => !ON_GRID.test(c)); │
│ if (bad !== undefined) { │
│ const cp = bad.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0"); │
│ throw new Error(`[ascii] ${ctx}: off-grid character "${bad}" (U+${cp}). If i │
│ t measures one cell wide in the frame font, add it to ON_GRID in ascii.ts.`); │
│ } │
│ } │
│ return text; │
│ } │
│ │
│ // For text from outside the pipeline (git history, external feeds): │
│ // transliterate typographic glyphs to their og-ascii forms and blank │
│ // anything else off-grid, so checkGrid never fails on foreign input. │
│ const GRID_MAP: Record<string, string> = { │
│ "-": "-", "-": "-", "...": "...", "-": "-", │
│ "": "", """: '"', """: '"', "'": "'", "'": "'", │
│ }; │
│ │
│ export function toGrid(s: string): string { │
│ return s │
│ .split("\n") │
│ .map((line) => [...line].map((c) => GRID_MAP[c] ?? (ON_GRID.test(c) ? c : "?") │
│ ).join("")) │
│ .join("\n"); │
│ } │
│ │
│ export type PadSide = "start" | "end" | "center"; │
│ export function pad(s: unknown, n: number, side: PadSide = "end"): string { │
│ const str = String(s ?? ""); │
│ const L = len(str); │
│ if (L >= n) return [...str].slice(0, n).join(""); │
│ const fill = " ".repeat(n - L); │
│ if (side === "start") return fill + str; │
│ if (side === "center") { │
│ const left = Math.floor((n - L) / 2); │
│ const right = n - L - left; │
│ return " ".repeat(left) + str + " ".repeat(right); │
│ } │
│ return str + fill; │
│ } │
│ │
│ export const rule = (w: number) => G.H.repeat(w); │
│ export const ruleD = (w: number) => G.D.repeat(w); │
│ │
│ export interface BoxOpts { │
│ width: number; │
│ lines: string[]; │
│ title?: string; │
│ align?: PadSide; │
│ } │
│ export function box({ width, lines, title, align = "start" }: BoxOpts): string { │
│ const inner = width - 2; │
│ const out: string[] = []; │
│ out.push(G.TL + rule(inner) + G.TR); │
│ if (title != null) { │
│ out.push(G.V + pad(" " + title, inner) + G.V); │
│ out.push(G.LJ + rule(inner) + G.RJ); │
│ } │
│ for (const ln of lines) out.push(G.V + pad(ln, inner, align) + G.V); │
│ out.push(G.BL + rule(inner) + G.BR); │
│ return out.join("\n"); │
│ } │
│ │
│ export const kv = (key: string, val: string, keyW: number, totalW: number) => │
│ pad(key, keyW) + pad(val, totalW - keyW); │
│ │
│ export const center = (s: string, w: number) => pad(s, w, "center"); │
│ │
│ /** wrap(), but words wider than the width hard-split instead of │
│ * overflowing - for foreign text like commit subjects. */ │
│ export function wrapHard(s: string, w: number): string { │
│ return wrap(s, w) │
│ .split("\n") │
│ .flatMap((line) => { │
│ const cps = [...line]; │
│ if (cps.length <= w) return [line]; │
│ const out: string[] = []; │
│ for (let i = 0; i < cps.length; i += w) out.push(cps.slice(i, i + w).join("" │
│ )); │
│ return out; │
│ }) │
│ .join("\n"); │
│ } │
│ │
│ export function row2(l: string, r: string, w: number): string { │
│ const gap = Math.max(1, w - len(l) - len(r)); │
│ return l + " ".repeat(gap) + r; │
│ } │
│ │
│ export function row3(l: string, c: string, r: string, w: number): string { │
│ const out = " ".repeat(w).split(""); │
│ const lArr = [...l], rArr = [...r], cArr = [...c]; │
│ for (let i = 0; i < lArr.length && i < w; i++) out[i] = lArr[i]; │
│ for (let i = 0; i < rArr.length && i < w; i++) out[w - rArr.length + i] = rArr[i │
│ ]; │
│ const start = Math.max(lArr.length + 1, Math.floor((w - cArr.length) / 2)); │
│ for (let i = 0; i < cArr.length && start + i < w - rArr.length - 1; i++) out[sta │
│ rt + i] = cArr[i]; │
│ return out.join(""); │
│ } │
│ │
│ // Greedy word wrap. │
│ export function wrap(text: string, w: number): string { │
│ const words = text.split(/\s+/); │
│ const out: string[] = []; │
│ let line = ""; │
│ for (const word of words) { │
│ if (!line) { line = word; continue; } │
│ if (len(line) + 1 + len(word) <= w) line += " " + word; │
│ else { out.push(line); line = word; } │
│ } │
│ if (line) out.push(line); │
│ return out.join("\n"); │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ TS ─────────────────────────────────┐
│ // Build-time ASCII rendering helpers. Pure │
│ functions, no DOM. │
│ │
│ export const G = { │
│ H: "─", V: "│", │
│ TL: "┌", TR: "┐", BL: "└", BR: "┘", │
│ LJ: "├", RJ: "┤", TJ: "┬", BJ: "┴", X: "┼" │
│ , │
│ D: "═", HD: "╌", │
│ }; │
│ │
│ // Codepoint length, not UTF-16 units - fram │
│ e math breaks on surrogate │
│ // pairs otherwise. │
│ export const len = (s: unknown): number => [ │
│ ...String(s ?? "")].length; │
│ │
│ // The site is og ascii: printable ASCII + t │
│ ab for text, box drawing + │
│ // block elements for chrome, plus NBSP (lay │
│ out) and the measured │
│ // arrows ↑ ↓. Typographic glyphs (em dash, │
│ ellipsis, curly quotes, │
│ // Latin-1 letters) are deliberately off-gri │
│ d so the build rejects │
│ // them - write "-", "...", and straight quo │
│ tes instead. │
│ const ON_GRID = /^[\t\x20-\x7E\u00A0\u2191\u │
│ 2193\u2500-\u259F]$/u; │
│ │
│ /** Throws unless every line of `text` fits │
│ `maxW` cells and uses only │
│ * on-grid glyphs. Returns `text` so call s │
│ ites can wrap in place. */ │
│ export function checkGrid(text: string, maxW │
│ : number, ctx: string): string { │
│ for (const line of text.split("\n")) { │
│ if (len(line) > maxW) { │
│ throw new Error(`[ascii] ${ctx}: line │
│ is ${len(line)} cells, max ${maxW}: "${[...l │
│ ine].slice(0, 40).join("")}..."`); │
│ } │
│ const bad = [...line].find((c) => !ON_GR │
│ ID.test(c)); │
│ if (bad !== undefined) { │
│ const cp = bad.codePointAt(0)!.toStrin │
│ g(16).toUpperCase().padStart(4, "0"); │
│ throw new Error(`[ascii] ${ctx}: off-g │
│ rid character "${bad}" (U+${cp}). If it meas │
│ ures one cell wide in the frame font, add it │
│ to ON_GRID in ascii.ts.`); │
│ } │
│ } │
│ return text; │
│ } │
│ │
│ // For text from outside the pipeline (git h │
│ istory, external feeds): │
│ // transliterate typographic glyphs to their │
│ og-ascii forms and blank │
│ // anything else off-grid, so checkGrid neve │
│ r fails on foreign input. │
│ const GRID_MAP: Record<string, string> = { │
│ "-": "-", "-": "-", "...": "...", "-": "-" │
│ , │
│ "": "", """: '"', """: '"', "'": "'", "'": │
│ "'", │
│ }; │
│ │
│ export function toGrid(s: string): string { │
│ return s │
│ .split("\n") │
│ .map((line) => [...line].map((c) => GRID │
│ _MAP[c] ?? (ON_GRID.test(c) ? c : "?")).join │
│ ("")) │
│ .join("\n"); │
│ } │
│ │
│ export type PadSide = "start" | "end" | "cen │
│ ter"; │
│ export function pad(s: unknown, n: number, s │
│ ide: PadSide = "end"): string { │
│ const str = String(s ?? ""); │
│ const L = len(str); │
│ if (L >= n) return [...str].slice(0, n).jo │
│ in(""); │
│ const fill = " ".repeat(n - L); │
│ if (side === "start") return fill + str; │
│ if (side === "center") { │
│ const left = Math.floor((n - L) / 2); │
│ const right = n - L - left; │
│ return " ".repeat(left) + str + " ".repe │
│ at(right); │
│ } │
│ return str + fill; │
│ } │
│ │
│ export const rule = (w: number) => G.H.repea │
│ t(w); │
│ export const ruleD = (w: number) => G.D.repe │
│ at(w); │
│ │
│ export interface BoxOpts { │
│ width: number; │
│ lines: string[]; │
│ title?: string; │
│ align?: PadSide; │
│ } │
│ export function box({ width, lines, title, a │
│ lign = "start" }: BoxOpts): string { │
│ const inner = width - 2; │
│ const out: string[] = []; │
│ out.push(G.TL + rule(inner) + G.TR); │
│ if (title != null) { │
│ out.push(G.V + pad(" " + title, inner) + │
│ G.V); │
│ out.push(G.LJ + rule(inner) + G.RJ); │
│ } │
│ for (const ln of lines) out.push(G.V + pad │
│ (ln, inner, align) + G.V); │
│ out.push(G.BL + rule(inner) + G.BR); │
│ return out.join("\n"); │
│ } │
│ │
│ export const kv = (key: string, val: string, │
│ keyW: number, totalW: number) => │
│ pad(key, keyW) + pad(val, totalW - keyW); │
│ │
│ export const center = (s: string, w: number) │
│ => pad(s, w, "center"); │
│ │
│ /** wrap(), but words wider than the width h │
│ ard-split instead of │
│ * overflowing - for foreign text like comm │
│ it subjects. */ │
│ export function wrapHard(s: string, w: numbe │
│ r): string { │
│ return wrap(s, w) │
│ .split("\n") │
│ .flatMap((line) => { │
│ const cps = [...line]; │
│ if (cps.length <= w) return [line]; │
│ const out: string[] = []; │
│ for (let i = 0; i < cps.length; i += w │
│ ) out.push(cps.slice(i, i + w).join("")); │
│ return out; │
│ }) │
│ .join("\n"); │
│ } │
│ │
│ export function row2(l: string, r: string, w │
│ : number): string { │
│ const gap = Math.max(1, w - len(l) - len(r │
│ )); │
│ return l + " ".repeat(gap) + r; │
│ } │
│ │
│ export function row3(l: string, c: string, r │
│ : string, w: number): string { │
│ const out = " ".repeat(w).split(""); │
│ const lArr = [...l], rArr = [...r], cArr = │
│ [...c]; │
│ for (let i = 0; i < lArr.length && i < w; │
│ i++) out[i] = lArr[i]; │
│ for (let i = 0; i < rArr.length && i < w; │
│ i++) out[w - rArr.length + i] = rArr[i]; │
│ const start = Math.max(lArr.length + 1, Ma │
│ th.floor((w - cArr.length) / 2)); │
│ for (let i = 0; i < cArr.length && start + │
│ i < w - rArr.length - 1; i++) out[start + i │
│ ] = cArr[i]; │
│ return out.join(""); │
│ } │
│ │
│ // Greedy word wrap. │
│ export function wrap(text: string, w: number │
│ ): string { │
│ const words = text.split(/\s+/); │
│ const out: string[] = []; │
│ let line = ""; │
│ for (const word of words) { │
│ if (!line) { line = word; continue; } │
│ if (len(line) + 1 + len(word) <= w) line │
│ += " " + word; │
│ else { out.push(line); line = word; } │
│ } │
│ if (line) out.push(line); │
│ return out.join("\n"); │
│ } │
└──────────────────────────────────────────────┘