termcard 0.0.0-stage → 0.2.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.
@@ -0,0 +1,39 @@
1
+ export default {
2
+ 'meta.name': 'Türkçe',
3
+ 'label.website': 'Web',
4
+ 'label.email': 'E-posta',
5
+ 'label.phone': 'Telefon',
6
+ 'action.contact': 'İletişime geç',
7
+ 'hint.contact': 'E-posta istemcinizde yeni bir ileti taslağı açar.',
8
+ 'action.save': 'Rehbere ekle',
9
+ 'hint.save': 'Kişi kartını (vCard) cihazınızın rehberine aktarır.',
10
+ 'action.website': 'Web sitesini ziyaret et',
11
+ 'hint.website': '{host} adresini tarayıcıda açar.',
12
+ 'action.link': '{label} profilini görüntüle',
13
+ 'hint.link': '{host} adresini tarayıcıda açar.',
14
+ 'action.copyEmail': 'E-posta adresini kopyala',
15
+ 'hint.copyEmail': 'Adresi panoya kopyalar.',
16
+ 'action.copyPhone': 'Telefon numarasını kopyala',
17
+ 'hint.copyPhone': 'Numarayı panoya kopyalar.',
18
+ 'action.language': 'Dil',
19
+ 'hint.language': 'Kartı başka bir dilde gösterir.',
20
+ 'action.back': 'Geri',
21
+ 'hint.back': 'Ana menüye döner.',
22
+ 'action.exit': 'Çıkış',
23
+ 'hint.exit': 'Kartı kapatır.',
24
+ 'status.contact': 'E-posta taslağı açıldı.',
25
+ 'status.contactFailed': 'E-posta istemcisi açılamadı — {value}',
26
+ 'status.save': 'Kişi kartı açıldı.',
27
+ 'status.saveFailed': 'Kişi kartı kaydedildi: {value}',
28
+ 'status.saveError': 'Kişi kartı oluşturulamadı.',
29
+ 'status.browser': 'Tarayıcıda açıldı.',
30
+ 'status.browserFailed': 'Tarayıcı açılamadı — {value}',
31
+ 'status.copied': 'Panoya kopyalandı.',
32
+ 'status.copyFailed': 'Panoya erişilemedi — {value}',
33
+ 'status.language': 'Dil değiştirildi.',
34
+ 'keys.navigate': 'gezin',
35
+ 'keys.select': 'seç',
36
+ 'keys.quit': 'çıkış',
37
+ 'keys.back': 'geri',
38
+ 'mail.subject': 'Merhaba',
39
+ };
package/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * termcard — public API.
3
+ *
4
+ * import { start } from 'termcard';
5
+ * start(new URL('../card.config.json', import.meta.url));
6
+ */
7
+
8
+ export { start, run } from './run.js';
9
+ export { loadConfig } from './config/load.js';
10
+ export { normalizeConfig, ConfigError, isEmail, urlProblem, TEXT_LIMIT, DESCRIPTION_LIMIT, ICON_SETS } from './config/schema.js';
11
+ export { templates, DEFAULT_TEMPLATE } from './config/templates.js';
12
+ export { themes, THEME_DESCRIPTIONS, DEFAULT_THEME } from './config/themes.js';
13
+ export { BUILT_IN_LANGUAGES, detectLanguage, languageName } from './i18n/index.js';
14
+ export { layoutCard, layoutHeader } from './render/card.js';
15
+ export { toAnsi, toPlain, toHtml } from './render/format.js';
16
+ export { colorDepth } from './render/color.js';
17
+ export { createVCard } from './vcard.js';
18
+ export { buildCard, UnsafeOutputError } from './build/index.js';
19
+ export { slugify } from './text.js';
@@ -0,0 +1,7 @@
1
+ export const borders = {
2
+ rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
3
+ square: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },
4
+ double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
5
+ heavy: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
6
+ none: null,
7
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Card layout. Produces format-independent lines of segments:
3
+ * { text, role?, href?, frame? }
4
+ * `role` maps to a theme color, `frame` marks border/padding that the HTML
5
+ * renderer replaces with CSS. Formatters live in ./format.js.
6
+ */
7
+
8
+ import { displayWidth, localize, stripProtocol, upper, wrap } from '../text.js';
9
+ import { createTranslator } from '../i18n/index.js';
10
+ import { borders } from './borders.js';
11
+ import { iconFor } from './icons.js';
12
+ import { templates } from '../config/templates.js';
13
+
14
+ const INDENT = ' ';
15
+ const seg = (text, role, href) => ({ text, role, href });
16
+ const frame = (text, role) => ({ text, role, frame: true });
17
+ const widthOf = (segments) => segments.reduce((n, s) => n + displayWidth(s.text), 0);
18
+
19
+ function nameLines(name, { nameStyle, splitName }, lang) {
20
+ const words = name.split(/\s+/);
21
+ const parts = splitName && words.length > 1 ? [words.slice(0, -1).join(' '), words.at(-1)] : [name];
22
+ return parts.map((part) => {
23
+ if (nameStyle === 'spaced') return [...upper(part, lang)].join(' ');
24
+ if (nameStyle === 'uppercase') return upper(part, lang);
25
+ return part;
26
+ });
27
+ }
28
+
29
+ /** The contact rows the card shows, in the configured order. */
30
+ export function contactRows(config, t, iconSet = config.icons) {
31
+ const rows = {
32
+ website: config.website && { label: t('label.website'), value: stripProtocol(config.website), href: config.website, icon: iconFor(iconSet, 'website') },
33
+ email: config.email && { label: t('label.email'), value: config.email, href: `mailto:${config.email}`, icon: iconFor(iconSet, 'email') },
34
+ phone: config.phone && { label: t('label.phone'), value: config.phone, href: `tel:${config.phone.replace(/[^\d+]/g, '')}`, icon: iconFor(iconSet, 'phone') },
35
+ };
36
+ for (const link of config.links) {
37
+ rows[link.id] = { label: link.label, value: stripProtocol(link.url), href: link.url, icon: iconFor(iconSet, 'link', link) };
38
+ }
39
+ return config.card.fields.filter((f) => f !== 'location').map((f) => rows[f]).filter(Boolean);
40
+ }
41
+
42
+ /** Width that the description wraps to: the card's inner width, or a comfortable default. */
43
+ const descriptionWidth = (card, box) => (typeof card.width === 'number' && box ? card.width - 2 - card.padding * 2 : 46);
44
+
45
+ /** Options for `layoutCard`: { iconSet } overrides config.icons (the web page uses this). */
46
+ /** One-line header shown above the menu when the card itself is turned off. */
47
+ export function layoutHeader(config, lang) {
48
+ const text = (value) => localize(value, lang);
49
+ const first = [seg(config.name, 'name')];
50
+ if (config.tagline) first.push(seg(' · ', 'faint'), seg(text(config.tagline), 'accent'));
51
+ const lines = [first];
52
+ if (config.title) lines.push([seg(text(config.title), 'muted')]);
53
+ if (config.description) for (const line of wrap(text(config.description), 60)) lines.push([seg(line, 'muted')]);
54
+ return lines.map((line) => [frame(INDENT), ...line]);
55
+ }
56
+
57
+ export function layoutCard(config, lang, { iconSet = config.icons } = {}) {
58
+ // With "card": false the card is still printed where no menu can be shown (pipes, --card).
59
+ const card = config.card.enabled ? config.card : { ...config.card, ...templates.minimal };
60
+ const t = createTranslator(lang, config.strings);
61
+ const text = (value) => localize(value, lang);
62
+ const relaxed = card.spacing === 'relaxed';
63
+
64
+ // Content sections; blank lines are inserted between them.
65
+ const sections = [];
66
+ const head = nameLines(config.name, card, lang).map((n) => [seg(n, 'name')]);
67
+ if (config.title) head.push([seg(text(config.title), 'muted')]);
68
+
69
+ const brand = [];
70
+ if (config.tagline) brand.push([seg(text(config.tagline), 'accent')]);
71
+ if (card.accentRule) brand.push([seg('───', 'accent')]);
72
+ if (config.description) {
73
+ for (const line of wrap(text(config.description), descriptionWidth(card, borders[card.border]))) brand.push([seg(line, 'muted')]);
74
+ }
75
+
76
+ if (relaxed) sections.push(head, brand);
77
+ else sections.push([...head, ...brand]);
78
+
79
+ const rows = contactRows(config, t, iconSet);
80
+ const labelWidth = Math.max(0, ...rows.map((r) => displayWidth(r.label))) + 2;
81
+ const iconWidth = Math.max(0, ...rows.map((r) => displayWidth(r.icon)), displayWidth(iconFor(iconSet, 'location')));
82
+ const icon = (glyph) => (iconWidth ? [seg(glyph + ' '.repeat(iconWidth - displayWidth(glyph) + 1), 'accent')] : []);
83
+ sections.push(
84
+ rows.map((r) => [
85
+ ...icon(r.icon),
86
+ ...(card.labels ? [seg(r.label + ' '.repeat(labelWidth - displayWidth(r.label)), 'muted')] : []),
87
+ seg(r.value, 'text', r.href),
88
+ ]),
89
+ );
90
+
91
+ const location =
92
+ card.fields.includes('location') && config.location ? [...icon(iconFor(iconSet, 'location')), seg(text(config.location), 'muted')] : null;
93
+ const body = sections.filter((s) => s.length).flatMap((s, i) => (i ? [[], ...s] : s));
94
+ if (location) body.push([], { right: card.locationAlign === 'right', segments: location });
95
+
96
+ // Normalize to { segments, right } and compute the inner width.
97
+ const content = body.map((l) => (Array.isArray(l) ? { segments: l, right: false } : l));
98
+ const pad = card.padding;
99
+ const box = borders[card.border];
100
+ const contentWidth = Math.max(...content.map((l) => widthOf(l.segments)));
101
+ const innerWidth = Math.max(contentWidth + pad * 2, card.width === 'auto' || !box ? 0 : card.width - 2);
102
+ const usable = innerWidth - pad * 2;
103
+
104
+ const place = ({ segments, right }) => {
105
+ const fill = seg(' '.repeat(Math.max(0, usable - widthOf(segments))));
106
+ return right ? [fill, ...segments] : [...segments, fill];
107
+ };
108
+
109
+ if (!box) {
110
+ return content.map((l) => [frame(INDENT), ...(l.right ? place(l) : l.segments)]);
111
+ }
112
+
113
+ const row = (segments) => [frame(INDENT), frame(box.v, 'faint'), frame(' '.repeat(pad)), ...segments, frame(' '.repeat(pad)), frame(box.v, 'faint')];
114
+ const blank = row([seg(' '.repeat(usable))]);
115
+ return [
116
+ [frame(INDENT), frame(box.tl + box.h.repeat(innerWidth) + box.tr, 'faint')],
117
+ ...(relaxed ? [blank] : []),
118
+ ...content.map((l) => row(place(l))),
119
+ ...(relaxed ? [blank] : []),
120
+ [frame(INDENT), frame(box.bl + box.h.repeat(innerWidth) + box.br, 'faint')],
121
+ ];
122
+ }
@@ -0,0 +1,40 @@
1
+ /** Terminal color support detection and hex → ANSI conversion. */
2
+
3
+ const CUBE = [0, 95, 135, 175, 215, 255];
4
+
5
+ const hexToRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
6
+ const distance = (a, b) => a.reduce((sum, v, i) => sum + (v - b[i]) ** 2, 0);
7
+
8
+ /** Closest xterm-256 palette index for a hex color (6×6×6 cube or grayscale ramp). */
9
+ export function hexTo256(hex) {
10
+ const rgb = hexToRgb(hex);
11
+ const nearest = (v) => CUBE.reduce((best, level, i) => (Math.abs(level - v) < Math.abs(CUBE[best] - v) ? i : best), 0);
12
+ const [r, g, b] = rgb.map(nearest);
13
+ const cubeIndex = 16 + 36 * r + 6 * g + b;
14
+ const cubeRgb = [CUBE[r], CUBE[g], CUBE[b]];
15
+
16
+ const avg = (rgb[0] + rgb[1] + rgb[2]) / 3;
17
+ const grayStep = Math.max(0, Math.min(23, Math.round((avg - 8) / 10)));
18
+ const grayRgb = Array(3).fill(8 + grayStep * 10);
19
+
20
+ return distance(rgb, grayRgb) < distance(rgb, cubeRgb) ? 232 + grayStep : cubeIndex;
21
+ }
22
+
23
+ /**
24
+ * Color depth for a stream: 0 (none), 256, or 16777216 (truecolor).
25
+ * Honors the NO_COLOR (https://no-color.org) and FORCE_COLOR conventions.
26
+ */
27
+ export function colorDepth(stream, env = {}) {
28
+ if ('NO_COLOR' in env && env.NO_COLOR !== '') return 0;
29
+ if (env.FORCE_COLOR === '0' || env.FORCE_COLOR === 'false') return 0;
30
+ const forced = env.FORCE_COLOR !== undefined;
31
+ if (!forced && (!stream?.isTTY || env.TERM === 'dumb')) return 0;
32
+ if (/^(truecolor|24bit)$/i.test(env.COLORTERM ?? '')) return 16777216;
33
+ return 256;
34
+ }
35
+
36
+ /** SGR parameters for a foreground hex color at the given depth. */
37
+ export function foreground(hex, depth) {
38
+ if (depth >= 16777216) return `38;2;${hexToRgb(hex).join(';')}`;
39
+ return `38;5;${hexTo256(hex)}`;
40
+ }
@@ -0,0 +1,46 @@
1
+ /** Turns laid-out card lines into terminal (ANSI), plain text or HTML output. */
2
+
3
+ import { foreground } from './color.js';
4
+
5
+ const ROLE_COLOR = { name: 'text', text: 'text', muted: 'muted', faint: 'faint', accent: 'accent' };
6
+
7
+ /** Returns a function that paints text in a theme role, or leaves it plain at depth 0. */
8
+ export function createPainter(theme, depth) {
9
+ return (role, text) => {
10
+ if (!depth || !role) return text;
11
+ const color = theme[ROLE_COLOR[role] ?? 'text'];
12
+ const codes = [
13
+ ...(role === 'name' || role === 'strong' ? ['1'] : []),
14
+ ...(color && color !== 'default' ? [foreground(color, depth)] : []),
15
+ ];
16
+ return codes.length ? `\x1b[${codes.join(';')}m${text}\x1b[0m` : text;
17
+ };
18
+ }
19
+
20
+ export function toAnsi(lines, theme, depth = 256) {
21
+ const paint = createPainter(theme, depth);
22
+ return lines.map((line) => line.map((s) => paint(s.role, s.text)).join('').trimEnd()).join('\n');
23
+ }
24
+
25
+ export const toPlain = (lines) => lines.map((line) => line.map((s) => s.text).join('').trimEnd()).join('\n');
26
+
27
+ export const escapeHtml = (text) =>
28
+ String(text).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
29
+
30
+ /** HTML for the web page. Frame segments are dropped — CSS draws the border. */
31
+ export function toHtml(lines) {
32
+ const content = lines.map((line) => line.filter((s) => !s.frame)).filter((line, i, all) => line.length || (i > 0 && i < all.length - 1));
33
+ return content
34
+ .map((line) =>
35
+ line
36
+ .map((s) => {
37
+ const inner = s.role ? `<span class="${s.role}">${escapeHtml(s.text)}</span>` : escapeHtml(s.text);
38
+ if (!s.href) return inner;
39
+ const external = /^https?:/.test(s.href) ? ' target="_blank" rel="noopener noreferrer"' : '';
40
+ return `<a href="${escapeHtml(s.href)}"${external}>${inner}</a>`;
41
+ })
42
+ .join('')
43
+ .trimEnd(),
44
+ )
45
+ .join('\n');
46
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Icon sets for card rows and menu entries.
3
+ *
4
+ * - symbols: plain Unicode symbols that every terminal font has (default choice when icons are on)
5
+ * - emoji: colorful, two columns wide; looks best on macOS and modern terminals
6
+ * - nerd: brand icons; requires a Nerd Font (https://www.nerdfonts.com) in the visitor's
7
+ * terminal, so it suits developer audiences. Web output falls back to symbols.
8
+ */
9
+
10
+ const sets = {
11
+ symbols: {
12
+ website: '⌂', email: '✉', phone: '☎', location: '◎', link: '↗',
13
+ save: '+', language: '⇄', exit: '×',
14
+ },
15
+ emoji: {
16
+ website: '🌐', email: '📧', phone: '📞', location: '📍', link: '🔗',
17
+ save: '📇', language: '💬', exit: '👋',
18
+ },
19
+ nerd: {
20
+ website: '', email: '', phone: '', location: '', link: '',
21
+ save: '', language: '', exit: '',
22
+ github: '', gitlab: '', linkedin: '', x: '', instagram: '',
23
+ facebook: '', youtube: '', behance: '', dribbble: '', medium: '',
24
+ stackoverflow: '', codepen: '', npm: '',
25
+ },
26
+ };
27
+
28
+ /**
29
+ * Returns the icon for a key ("email", "website", …) or a link, or '' when icons are off.
30
+ * A link's own `icon` always wins.
31
+ */
32
+ export function iconFor(set, key, link) {
33
+ if (link?.icon) return link.icon;
34
+ if (!sets[set]) return '';
35
+ if (link) return sets[set][link.service] ?? sets[set].link;
36
+ return sets[set][key] ?? '';
37
+ }
38
+
39
+ /** Icon set to use in HTML: Nerd Font glyphs are not available on the web. */
40
+ export const webIconSet = (set) => (set === 'nerd' ? 'symbols' : set);
package/src/run.js ADDED
@@ -0,0 +1,80 @@
1
+ /** Entry point used by every generated card package: `npx <name>`. */
2
+
3
+ import { parseArgs } from 'node:util';
4
+ import { loadConfig } from './config/load.js';
5
+ import { ConfigError } from './config/schema.js';
6
+ import { detectLanguage, createTranslator } from './i18n/index.js';
7
+ import { layoutCard } from './render/card.js';
8
+ import { toAnsi } from './render/format.js';
9
+ import { colorDepth } from './render/color.js';
10
+ import { createVCard } from './vcard.js';
11
+ import { runInteractive } from './tui/app.js';
12
+ import { buildMenu } from './tui/actions.js';
13
+
14
+ const HELP = `Usage: npx <card> [options]
15
+
16
+ Options:
17
+ --lang <code> Show the card in a specific language
18
+ --card Print the card without the interactive menu
19
+ --vcard Print the contact card in vCard format
20
+ --json Print the card details as JSON
21
+ --no-color Disable colors
22
+ -h, --help Show this help`;
23
+
24
+ /** Public, display-only view of the config (what the card already shows). */
25
+ const publicProfile = (config) => {
26
+ const { name, title, tagline, description, organization, email, phone, website, location, links } = config;
27
+ return JSON.parse(JSON.stringify({ name, title, tagline, description, organization, email, phone, website, location, links }));
28
+ };
29
+
30
+ /**
31
+ * Runs a card from a normalized config.
32
+ * @returns {Promise<number>} process exit code
33
+ */
34
+ export async function run(config, { argv = process.argv.slice(2), env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr } = {}) {
35
+ let flags;
36
+ try {
37
+ ({ values: flags } = parseArgs({
38
+ args: argv,
39
+ options: {
40
+ lang: { type: 'string' },
41
+ card: { type: 'boolean' },
42
+ vcard: { type: 'boolean' },
43
+ json: { type: 'boolean' },
44
+ 'no-color': { type: 'boolean' },
45
+ help: { type: 'boolean', short: 'h' },
46
+ },
47
+ strict: true,
48
+ }));
49
+ } catch (error) {
50
+ stderr.write(`${error.message}\n\n${HELP}\n`);
51
+ return 2;
52
+ }
53
+
54
+ if (flags.help) return stdout.write(`${HELP}\n`), 0;
55
+ const lang = detectLanguage(config.language, { flag: flags.lang, env });
56
+ if (flags.json) return stdout.write(`${JSON.stringify(publicProfile(config), null, 2)}\n`), 0;
57
+ if (flags.vcard) return stdout.write(createVCard(config, lang)), 0;
58
+
59
+ const depth = flags['no-color'] ? 0 : colorDepth(stdout, env);
60
+ const hasMenu = config.menu.enabled && buildMenu(config, lang, createTranslator(lang, config.strings)).length > 0;
61
+ const interactive = hasMenu && !flags.card && stdin.isTTY && stdout.isTTY && typeof stdin.setRawMode === 'function';
62
+ if (!interactive) {
63
+ stdout.write(`\n${toAnsi(layoutCard(config, lang), config.theme, depth)}\n\n`);
64
+ return 0;
65
+ }
66
+ await runInteractive(config, { lang, depth, stdin, stdout });
67
+ return 0;
68
+ }
69
+
70
+ /** Loads a JSON config file and runs it; prints readable errors instead of stack traces. */
71
+ export async function start(configFile, options = {}) {
72
+ const stderr = options.stderr ?? process.stderr;
73
+ try {
74
+ process.exitCode = await run(loadConfig(configFile), options);
75
+ } catch (error) {
76
+ if (!(error instanceof ConfigError)) throw error;
77
+ stderr.write(`${error.message}\n`);
78
+ process.exitCode = 1;
79
+ }
80
+ }
package/src/system.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The only module that touches the operating system.
3
+ * Commands are spawned without a shell and with fixed argument lists, so no
4
+ * value from a config can be interpreted as a shell command.
5
+ */
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import { mkdtempSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { isAbsolute, join } from 'node:path';
11
+
12
+ const SAFE_URL = /^(https?:\/\/|mailto:|tel:)/i;
13
+ // Openers normally exit within milliseconds; one still running after this is treated as success.
14
+ const OPENER_GRACE_MS = 1500;
15
+
16
+ function openerFor(platform) {
17
+ if (platform === 'darwin') return ['open', []];
18
+ if (platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler']];
19
+ return ['xdg-open', []];
20
+ }
21
+
22
+ /** Opens a URL (http, https, mailto, tel) or an absolute file path with the default app. */
23
+ export function openExternal(target, platform = process.platform) {
24
+ if (!SAFE_URL.test(target) && !isAbsolute(target)) return Promise.resolve(false);
25
+ const [command, args] = openerFor(platform);
26
+ return new Promise((resolve) => {
27
+ const child = spawn(command, [...args, target], { stdio: 'ignore', detached: true, shell: false });
28
+ const timer = setTimeout(() => {
29
+ child.unref();
30
+ resolve(true);
31
+ }, OPENER_GRACE_MS);
32
+ timer.unref();
33
+ child.once('error', () => {
34
+ clearTimeout(timer);
35
+ resolve(false);
36
+ });
37
+ child.once('exit', (code) => {
38
+ clearTimeout(timer);
39
+ resolve(code === 0);
40
+ });
41
+ });
42
+ }
43
+
44
+ function clipboardTools(platform) {
45
+ if (platform === 'darwin') return [['pbcopy', []]];
46
+ if (platform === 'win32') return [['clip', []]];
47
+ return [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
48
+ }
49
+
50
+ /** Copies text using the platform clipboard tool. Resolves false if none is available. */
51
+ export function copyToClipboard(text, platform = process.platform) {
52
+ const tools = clipboardTools(platform);
53
+ return new Promise((resolve) => {
54
+ const attempt = (i) => {
55
+ if (i >= tools.length) return resolve(false);
56
+ const [command, args] = tools[i];
57
+ const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'], shell: false });
58
+ child.once('error', () => attempt(i + 1));
59
+ child.once('close', (code) => (code === 0 ? resolve(true) : attempt(i + 1)));
60
+ child.stdin.on('error', () => {});
61
+ child.stdin.end(text);
62
+ };
63
+ attempt(0);
64
+ });
65
+ }
66
+
67
+ /** Writes a file into a fresh private temp directory (mode 0600) and returns its path. */
68
+ export function writeTempFile(name, content) {
69
+ const dir = mkdtempSync(join(tmpdir(), 'termcard-'));
70
+ const file = join(dir, name);
71
+ writeFileSync(file, content, { mode: 0o600 });
72
+ return file;
73
+ }
package/src/text.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Text helpers shared by every renderer.
3
+ * All user-provided strings pass through `sanitize` before they reach a terminal or HTML.
4
+ */
5
+
6
+ // C0/C1 control characters (incl. ESC) and bidirectional override characters.
7
+ // Removing them prevents terminal escape injection and text-direction spoofing.
8
+ const UNSAFE = /[\u0000-\u001f\u007f-\u009f‪-‮⁦-⁩]/g;
9
+
10
+ export const sanitize = (value) => String(value).replace(UNSAFE, '').trim();
11
+
12
+ const isZeroWidth = (cp) =>
13
+ (cp >= 0x0300 && cp <= 0x036f) || // combining diacritics
14
+ (cp >= 0x200b && cp <= 0x200f) || // zero-width space/joiners
15
+ (cp >= 0xfe00 && cp <= 0xfe0f); // variation selectors
16
+
17
+ // Symbols below U+1F300 that terminals draw two columns wide (emoji presentation).
18
+ const WIDE_SYMBOLS = [
19
+ [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe],
20
+ [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1],
21
+ [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4],
22
+ [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd],
23
+ [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e],
24
+ [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf],
25
+ [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
26
+ ];
27
+
28
+ const isWide = (cp) =>
29
+ WIDE_SYMBOLS.some(([from, to]) => cp >= from && cp <= to) ||
30
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
31
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK … Yi
32
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
33
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
34
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
35
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
36
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
37
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji & pictographs
38
+ (cp >= 0x20000 && cp <= 0x3fffd); // CJK extensions
39
+
40
+ /** Number of terminal columns a string occupies (no escape sequences expected). */
41
+ export function displayWidth(text) {
42
+ let width = 0;
43
+ for (const char of text) {
44
+ const cp = char.codePointAt(0);
45
+ if (isZeroWidth(cp)) continue;
46
+ width += isWide(cp) ? 2 : 1;
47
+ }
48
+ return width;
49
+ }
50
+
51
+ /** Replaces `{key}` placeholders. Unknown keys are left untouched. */
52
+ export const format = (template, values = {}) =>
53
+ template.replace(/\{(\w+)\}/g, (match, key) => (key in values ? String(values[key]) : match));
54
+
55
+ /**
56
+ * A localized value is either a plain string or an object keyed by language code.
57
+ * Falls back to the given fallback language, then to the first available entry.
58
+ */
59
+ export function localize(value, lang, fallback = 'en') {
60
+ if (value == null) return '';
61
+ if (typeof value === 'string') return value;
62
+ return value[lang] ?? value[fallback] ?? Object.values(value)[0] ?? '';
63
+ }
64
+
65
+ /** Locale-aware uppercase that keeps Turkish dotted/dotless i correct. */
66
+ export const upper = (text, lang) => text.toLocaleUpperCase(lang === 'tr' || lang === 'az' ? 'tr-TR' : undefined);
67
+
68
+ export const stripProtocol = (url) => url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
69
+
70
+ const TRANSLITERATE = { ı: 'i', ß: 'ss', ø: 'o', æ: 'ae', œ: 'oe', ł: 'l', đ: 'd', þ: 'th' };
71
+
72
+ /** "Ahmet Erkan Paşahan" → "ahmet-erkan-pasahan" (lowercase ASCII, dashes). */
73
+ export function slugify(text) {
74
+ return text
75
+ .toLowerCase()
76
+ .replace(/[ıßøæœłđþ]/g, (ch) => TRANSLITERATE[ch])
77
+ .normalize('NFKD')
78
+ .replace(/[\u0300-\u036f]/g, '')
79
+ .replace(/[^a-z0-9]+/g, '-')
80
+ .replace(/^-+|-+$/g, '')
81
+ .slice(0, 50);
82
+ }
83
+
84
+ /** Removes ANSI SGR sequences (used to measure already-painted lines). */
85
+ export const stripAnsi = (text) => text.replace(/\x1b\[[0-9;]*m/g, '');
86
+
87
+ /** Word-wraps text to lines of at most `width` columns; overlong words are split. */
88
+ export function wrap(text, width) {
89
+ const lines = [];
90
+ let line = '';
91
+ for (const word of text.split(/\s+/).filter(Boolean)) {
92
+ let rest = word;
93
+ while (displayWidth(rest) > width) {
94
+ let head = '';
95
+ for (const ch of rest) {
96
+ if (displayWidth(head + ch) > width) break;
97
+ head += ch;
98
+ }
99
+ if (line) lines.push(line);
100
+ lines.push(head);
101
+ line = '';
102
+ rest = rest.slice(head.length);
103
+ }
104
+ const candidate = line ? `${line} ${rest}` : rest;
105
+ if (displayWidth(candidate) <= width) line = candidate;
106
+ else {
107
+ lines.push(line);
108
+ line = rest;
109
+ }
110
+ }
111
+ if (line) lines.push(line);
112
+ return lines;
113
+ }