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,88 @@
1
+ /**
2
+ * Menu entries. Each entry is { id, icon, label, hint, run }, where `run` resolves to
3
+ * an outcome: { status, ok } | { view } | { quit: true }.
4
+ * Entries whose data is missing (e.g. "contact" without an email) are skipped.
5
+ *
6
+ * This module has no Node-only imports (the web builder uses it for previews);
7
+ * operating-system access is passed in as `sys` (see ../system.js).
8
+ */
9
+
10
+ import { localize, slugify } from '../text.js';
11
+ import { createVCard } from '../vcard.js';
12
+ import { iconFor } from '../render/icons.js';
13
+
14
+ const unavailable = async () => false;
15
+ const offline = { openExternal: unavailable, copyToClipboard: unavailable, writeTempFile: () => { throw new Error('unavailable'); } };
16
+
17
+ const host = (url) => new URL(url).host.replace(/^www\./, '');
18
+
19
+ export function buildMenu(config, lang, t, sys = offline) {
20
+ const icon = (key, link) => iconFor(config.icons, key, link);
21
+ const done = (ok, key, value) => ({ ok, status: t(key, { value }) });
22
+ const browse = (url) => async () =>
23
+ (await sys.openExternal(url)) ? done(true, 'status.browser') : done(false, 'status.browserFailed', url);
24
+ const copy = (value) => async () =>
25
+ (await sys.copyToClipboard(value)) ? done(true, 'status.copied') : done(false, 'status.copyFailed', value);
26
+
27
+ const factories = {
28
+ contact: () =>
29
+ config.email && {
30
+ icon: icon('email'),
31
+ label: t('action.contact'),
32
+ hint: t('hint.contact'),
33
+ run: async () =>
34
+ (await sys.openExternal(`mailto:${config.email}?subject=${encodeURIComponent(t('mail.subject'))}`))
35
+ ? done(true, 'status.contact')
36
+ : done(false, 'status.contactFailed', config.email),
37
+ },
38
+ save: () => ({
39
+ icon: icon('save'),
40
+ label: t('action.save'),
41
+ hint: t('hint.save'),
42
+ run: async () => {
43
+ let file;
44
+ try {
45
+ file = sys.writeTempFile(`${slugify(config.name) || 'contact'}.vcf`, createVCard(config, lang));
46
+ } catch {
47
+ return done(false, 'status.saveError');
48
+ }
49
+ return (await sys.openExternal(file)) ? done(true, 'status.save') : done(false, 'status.saveFailed', file);
50
+ },
51
+ }),
52
+ website: () =>
53
+ config.website && {
54
+ icon: icon('website'),
55
+ label: t('action.website'),
56
+ hint: t('hint.website', { host: host(config.website) }),
57
+ run: browse(config.website),
58
+ },
59
+ 'copy-email': () => config.email && { icon: icon('email'), label: t('action.copyEmail'), hint: t('hint.copyEmail'), run: copy(config.email) },
60
+ 'copy-phone': () => config.phone && { icon: icon('phone'), label: t('action.copyPhone'), hint: t('hint.copyPhone'), run: copy(config.phone) },
61
+ language: () =>
62
+ config.language.available.length > 1 && {
63
+ icon: icon('language'),
64
+ label: t('action.language'),
65
+ hint: t('hint.language'),
66
+ run: async () => ({ view: 'language' }),
67
+ },
68
+ exit: () => ({ icon: icon('exit'), label: t('action.exit'), hint: t('hint.exit'), run: async () => ({ quit: true }) }),
69
+ };
70
+
71
+ const linkEntry = (link) => ({
72
+ id: `link:${link.id}`,
73
+ icon: icon('link', link),
74
+ label: t('action.link', { label: link.label }),
75
+ hint: t('hint.link', { label: link.label, host: host(link.url) }),
76
+ run: browse(link.url),
77
+ });
78
+
79
+ return config.menu.items.flatMap((item) => {
80
+ if (typeof item === 'object') {
81
+ return [{ id: `custom:${item.url}`, icon: icon('link'), label: localize(item.label, lang), hint: localize(item.hint, lang) || t('hint.link', { host: host(item.url) }), run: browse(item.url) }];
82
+ }
83
+ if (item === 'links') return config.links.map(linkEntry);
84
+ if (item.startsWith('link:')) return config.links.filter((l) => l.id === item.slice(5)).map(linkEntry);
85
+ const entry = factories[item]?.();
86
+ return entry ? [{ id: item, ...entry }] : [];
87
+ });
88
+ }
package/src/tui/app.js ADDED
@@ -0,0 +1,164 @@
1
+ /** Interactive card: the card itself plus a keyboard-driven menu below it. */
2
+
3
+ import readline from 'node:readline';
4
+ import { createTranslator, languageName } from '../i18n/index.js';
5
+ import { layoutCard, layoutHeader } from '../render/card.js';
6
+ import { toAnsi, createPainter } from '../render/format.js';
7
+ import { displayWidth, stripAnsi } from '../text.js';
8
+ import { buildMenu } from './actions.js';
9
+ import * as system from '../system.js';
10
+ import { Screen } from './screen.js';
11
+
12
+ export function runInteractive(config, { lang: initialLang, depth, stdin, stdout }) {
13
+ const paint = createPainter(config.theme, depth);
14
+ const screen = new Screen(stdout);
15
+ const state = { lang: initialLang, view: 'main', index: 0, status: null };
16
+ let card = [];
17
+ let cardRows = 0;
18
+ let t;
19
+ let menu;
20
+
21
+ const load = () => {
22
+ t = createTranslator(state.lang, config.strings);
23
+ menu = buildMenu(config, state.lang, t, system);
24
+ const top = config.card.enabled ? layoutCard(config, state.lang) : layoutHeader(config, state.lang);
25
+ card = ['', ...toAnsi(top, config.theme, depth).split('\n')];
26
+ };
27
+
28
+ const ruleWidth = () => Math.max(40, ...card.map((l) => displayWidth(stripAnsi(l)) - 2));
29
+
30
+ /** Keeps a line of plain text within the terminal width so it never wraps. */
31
+ const fit = (text) => {
32
+ const max = (stdout.columns || 80) - 4;
33
+ if (displayWidth(text) <= max) return text;
34
+ let out = '';
35
+ for (const ch of text) {
36
+ if (displayWidth(out + ch) > max - 1) break;
37
+ out += ch;
38
+ }
39
+ return `${out}…`;
40
+ };
41
+
42
+ const entries = () =>
43
+ state.view === 'language'
44
+ ? [
45
+ ...config.language.available.map((code) => ({
46
+ label: `${languageName(code, config.strings)}${code === state.lang ? ` ${paint('accent', '✓')}` : ''}`,
47
+ hint: '',
48
+ code,
49
+ })),
50
+ { label: t('action.back'), hint: t('hint.back'), back: true },
51
+ ]
52
+ : menu;
53
+
54
+ const menuLines = () => {
55
+ const list = entries();
56
+ const current = list[state.index];
57
+ const iconWidth = Math.max(0, ...list.map((e) => displayWidth(e.icon ?? '')));
58
+ const keys = [['↑↓', t('keys.navigate')], ['enter', t('keys.select')], ...(state.view === 'language' ? [['esc', t('keys.back')]] : []), ['q', t('keys.quit')]]
59
+ .map(([key, word]) => `${paint('muted', key)} ${paint('faint', word)}`)
60
+ .join(paint('faint', ' · '));
61
+ const status = state.status
62
+ ? `${paint(state.status.ok ? 'accent' : 'muted', state.status.ok ? '✓' : '•')} ${paint('muted', fit(state.status.text))}`
63
+ : paint('faint', fit(current?.hint ?? ''));
64
+ return [
65
+ '',
66
+ ...list.map((e, i) => {
67
+ const glyph = iconWidth ? paint('accent', (e.icon ?? '') + ' '.repeat(iconWidth - displayWidth(e.icon ?? '') + 1)) : '';
68
+ return i === state.index ? ` ${paint('accent', '›')} ${glyph}${paint('strong', e.label)}` : ` ${glyph}${paint('muted', e.label)}`;
69
+ }),
70
+ '',
71
+ ` ${paint('faint', '─'.repeat(ruleWidth()))}`,
72
+ ` ${status}`,
73
+ ` ${keys}`,
74
+ ];
75
+ };
76
+
77
+ const render = () => screen.paint(menuLines());
78
+
79
+ /** Switches language and redraws the card in place (or from the top if it no longer fits). */
80
+ const switchLanguage = (code) => {
81
+ screen.clear(cardRows);
82
+ state.lang = code;
83
+ load();
84
+ showMain({ ok: true, text: t('status.language') });
85
+ cardRows = screen.print(card);
86
+ render();
87
+ };
88
+
89
+ const showMain = (status = null) => {
90
+ state.view = 'main';
91
+ state.index = Math.max(0, menu.findIndex((e) => e.id === 'language'));
92
+ state.status = status;
93
+ };
94
+
95
+ return new Promise((resolve) => {
96
+ let busy = false;
97
+ let closed = false;
98
+
99
+ const cleanup = () => {
100
+ stdin.off('keypress', onKey);
101
+ if (stdin.isTTY) stdin.setRawMode(false);
102
+ stdin.pause();
103
+ screen.showCursor();
104
+ };
105
+ const quit = () => {
106
+ if (closed) return;
107
+ closed = true;
108
+ screen.clear();
109
+ stdout.write('\n');
110
+ cleanup();
111
+ resolve();
112
+ };
113
+
114
+ const select = async () => {
115
+ const entry = entries()[state.index];
116
+ if (state.view === 'language') {
117
+ if (entry.code && entry.code !== state.lang) return switchLanguage(entry.code);
118
+ showMain();
119
+ return render();
120
+ }
121
+ busy = true;
122
+ const outcome = await entry.run();
123
+ busy = false;
124
+ if (closed) return;
125
+ if (outcome.quit) return quit();
126
+ if (outcome.view) {
127
+ state.view = outcome.view;
128
+ state.index = Math.max(0, config.language.available.indexOf(state.lang));
129
+ state.status = null;
130
+ return render();
131
+ }
132
+ state.status = { ok: outcome.ok, text: outcome.status };
133
+ render();
134
+ };
135
+
136
+ async function onKey(str, key = {}) {
137
+ if ((key.ctrl && key.name === 'c') || key.name === 'q') return quit();
138
+ if (busy) return;
139
+ const count = entries().length;
140
+ if (key.name === 'escape' || key.name === 'backspace' || key.name === 'left') {
141
+ if (state.view === 'main') return key.name === 'escape' ? quit() : undefined;
142
+ showMain();
143
+ return render();
144
+ }
145
+ if (key.name === 'up' || key.name === 'k') state.index = (state.index - 1 + count) % count;
146
+ else if (key.name === 'down' || key.name === 'j' || key.name === 'tab') state.index = (state.index + 1) % count;
147
+ else if (/^[1-9]$/.test(str ?? '') && Number(str) <= count) state.index = Number(str) - 1;
148
+ else if (key.name === 'return' || key.name === 'space' || key.name === 'right') return select();
149
+ else return;
150
+ state.status = null;
151
+ render();
152
+ }
153
+
154
+ load();
155
+ readline.emitKeypressEvents(stdin);
156
+ stdin.setRawMode(true);
157
+ stdin.resume();
158
+ screen.hideCursor();
159
+ process.once('exit', () => screen.showCursor());
160
+ cardRows = screen.print(card);
161
+ render();
162
+ stdin.on('keypress', onKey);
163
+ });
164
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Redraws a block of lines in place below whatever is already on screen,
3
+ * instead of clearing the terminal (which leaves empty space in some terminals).
4
+ * Heights are counted in screen rows, so lines that wrap are handled correctly.
5
+ */
6
+
7
+ import { displayWidth, stripAnsi } from '../text.js';
8
+
9
+ /** Number of screen rows `lines` occupy in a terminal `columns` wide. */
10
+ export function rowsFor(lines, columns) {
11
+ if (!columns) return lines.length;
12
+ return lines.reduce((rows, line) => rows + Math.max(1, Math.ceil(displayWidth(stripAnsi(line)) / columns)), 0);
13
+ }
14
+
15
+ export class Screen {
16
+ constructor(stream) {
17
+ this.stream = stream;
18
+ this.height = 0;
19
+ }
20
+
21
+ rows(lines) {
22
+ return rowsFor(lines, this.stream.columns);
23
+ }
24
+
25
+ /** Writes lines below the current block without replacing it (e.g. the card). */
26
+ print(lines) {
27
+ this.stream.write(`${lines.join('\n')}\n`);
28
+ return this.rows(lines);
29
+ }
30
+
31
+ /** Replaces the previously painted block with `lines`. */
32
+ paint(lines) {
33
+ const up = this.height ? `\x1b[${this.height}A` : '';
34
+ this.stream.write(`${up}\r\x1b[J${lines.join('\n')}\n`);
35
+ this.height = this.rows(lines);
36
+ }
37
+
38
+ /** Erases the painted block plus `extra` rows above it (or the whole screen if they scrolled away). */
39
+ clear(extra = 0) {
40
+ const total = this.height + extra;
41
+ if (!total) return;
42
+ const visible = !this.stream.rows || total < this.stream.rows;
43
+ this.stream.write(visible ? `\x1b[${total}A\r\x1b[J` : '\x1b[2J\x1b[H');
44
+ this.height = 0;
45
+ }
46
+
47
+ hideCursor() {
48
+ this.stream.write('\x1b[?25l');
49
+ }
50
+
51
+ showCursor() {
52
+ this.stream.write('\x1b[?25h');
53
+ }
54
+ }
package/src/vcard.js ADDED
@@ -0,0 +1,28 @@
1
+ /** vCard 3.0 (RFC 2426) export of the card's contact details. */
2
+
3
+ import { localize } from './text.js';
4
+
5
+ // Escape per RFC 2426 §5: backslash, comma, semicolon and newlines.
6
+ const esc = (value) => String(value).replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\r?\n/g, '\\n');
7
+
8
+ export function createVCard(config, lang) {
9
+ const words = config.name.split(/\s+/);
10
+ const family = words.length > 1 ? words.at(-1) : '';
11
+ const given = words.length > 1 ? words.slice(0, -1).join(' ') : words[0];
12
+ const lines = [
13
+ 'BEGIN:VCARD',
14
+ 'VERSION:3.0',
15
+ `N:${esc(family)};${esc(given)};;;`,
16
+ `FN:${esc(config.name)}`,
17
+ config.organization && `ORG:${esc(config.organization)}`,
18
+ config.title && `TITLE:${esc(localize(config.title, lang))}`,
19
+ config.email && `EMAIL;TYPE=INTERNET:${esc(config.email)}`,
20
+ config.phone && `TEL;TYPE=CELL:${esc(config.phone)}`,
21
+ config.website && `URL:${esc(config.website)}`,
22
+ ...config.links.map((l) => `X-SOCIALPROFILE;TYPE=${esc(l.id)}:${esc(l.url)}`),
23
+ config.description && `NOTE:${esc(localize(config.description, lang))}`,
24
+ 'END:VCARD',
25
+ '',
26
+ ];
27
+ return lines.filter((l) => l !== undefined && l !== false && l !== null).join('\r\n');
28
+ }