termcard 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +88 -2
- package/bin/termcard.js +4 -0
- package/package.json +39 -4
- package/src/browser.js +17 -0
- package/src/build/index.js +53 -0
- package/src/build/integrations.js +72 -0
- package/src/build/web.js +101 -0
- package/src/cli.js +79 -0
- package/src/config/load.js +27 -0
- package/src/config/schema.js +298 -0
- package/src/config/services.js +38 -0
- package/src/config/templates.js +56 -0
- package/src/config/themes.js +35 -0
- package/src/i18n/index.js +72 -0
- package/src/i18n/locales/de.js +39 -0
- package/src/i18n/locales/en.js +39 -0
- package/src/i18n/locales/es.js +39 -0
- package/src/i18n/locales/fr.js +39 -0
- package/src/i18n/locales/tr.js +39 -0
- package/src/index.js +19 -0
- package/src/render/borders.js +7 -0
- package/src/render/card.js +122 -0
- package/src/render/color.js +40 -0
- package/src/render/format.js +46 -0
- package/src/render/icons.js +40 -0
- package/src/run.js +80 -0
- package/src/system.js +73 -0
- package/src/text.js +113 -0
- package/src/tui/actions.js +88 -0
- package/src/tui/app.js +164 -0
- package/src/tui/screen.js +54 -0
- package/src/vcard.js +28 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config validation and normalization.
|
|
3
|
+
*
|
|
4
|
+
* `normalizeConfig(raw)` returns a fully resolved config (template + overrides
|
|
5
|
+
* applied, defaults filled in, strings sanitized) or throws a `ConfigError`
|
|
6
|
+
* listing every problem found. Renderers only ever see normalized configs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { sanitize, slugify, displayWidth } from '../text.js';
|
|
10
|
+
import { themes, DEFAULT_THEME, THEME_ROLES } from './themes.js';
|
|
11
|
+
import { templates, DEFAULT_TEMPLATE, CARD_OPTIONS, DEFAULT_MENU, MENU_ACTIONS } from './templates.js';
|
|
12
|
+
import { identifyService } from './services.js';
|
|
13
|
+
import { BUILT_IN_LANGUAGES } from '../i18n/index.js';
|
|
14
|
+
|
|
15
|
+
export class ConfigError extends Error {
|
|
16
|
+
constructor(problems) {
|
|
17
|
+
super(`Invalid termcard config:\n${problems.map((p) => ` • ${p}`).join('\n')}`);
|
|
18
|
+
this.name = 'ConfigError';
|
|
19
|
+
this.problems = problems;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TOP_LEVEL_KEYS = [
|
|
24
|
+
'$schema', 'name', 'title', 'tagline', 'description', 'organization', 'email', 'phone', 'website',
|
|
25
|
+
'location', 'links', 'language', 'strings', 'template', 'theme', 'icons', 'card', 'menu',
|
|
26
|
+
];
|
|
27
|
+
const LANG_CODE = /^[a-z]{2,3}(-[a-z0-9]{2,8})?$/i;
|
|
28
|
+
// Deliberately stricter than RFC 5322: no characters that change the meaning of a mailto: URL (? & # % /).
|
|
29
|
+
const EMAIL = /^[a-z0-9.!$'*+=^_`{|}~-]+@[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/i;
|
|
30
|
+
const PHONE = /^\+?[0-9][0-9 ().-]{3,24}$/;
|
|
31
|
+
const HEX = /^#[0-9a-f]{6}$/i;
|
|
32
|
+
const MAX_TEXT = 120;
|
|
33
|
+
export const DESCRIPTION_LIMIT = 280;
|
|
34
|
+
export const ICON_SETS = ['none', 'symbols', 'emoji', 'nerd'];
|
|
35
|
+
|
|
36
|
+
/** Why a string is not an acceptable link, or undefined if it is. */
|
|
37
|
+
export function urlProblem(value) {
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = new URL(value);
|
|
41
|
+
} catch {
|
|
42
|
+
return 'must be a full URL, e.g. https://example.com';
|
|
43
|
+
}
|
|
44
|
+
if (!['https:', 'http:'].includes(parsed.protocol)) return 'must start with https:// or http://';
|
|
45
|
+
if (parsed.username || parsed.password) return 'must not contain credentials';
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const isEmail = (value) => value.length <= 254 && EMAIL.test(value);
|
|
50
|
+
export const TEXT_LIMIT = MAX_TEXT;
|
|
51
|
+
|
|
52
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
53
|
+
|
|
54
|
+
/** Collects problems instead of throwing at the first one, so users can fix everything at once. */
|
|
55
|
+
function createChecker() {
|
|
56
|
+
const problems = [];
|
|
57
|
+
const fail = (path, message) => {
|
|
58
|
+
problems.push(`${path}: ${message}`);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const text = (path, value, { required = false, max = MAX_TEXT } = {}) => {
|
|
62
|
+
if (value != null && typeof value !== 'string') return fail(path, 'must be a string');
|
|
63
|
+
const clean = value == null ? '' : sanitize(value);
|
|
64
|
+
if (!clean) return required ? fail(path, 'is required') : undefined;
|
|
65
|
+
if (clean.length > max) return fail(path, `must be at most ${max} characters`);
|
|
66
|
+
return clean;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const localized = (path, value, options) => {
|
|
70
|
+
if (value == null || typeof value === 'string') return text(path, value, options);
|
|
71
|
+
if (!isObject(value)) return fail(path, 'must be a string or an object of language → text');
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const [lang, entry] of Object.entries(value)) {
|
|
74
|
+
if (!LANG_CODE.test(lang)) fail(`${path}.${lang}`, 'is not a valid language code');
|
|
75
|
+
else {
|
|
76
|
+
const clean = text(`${path}.${lang}`, entry, options);
|
|
77
|
+
if (clean) out[lang.toLowerCase()] = clean;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return Object.keys(out).length ? out : undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const url = (path, value, { required = false } = {}) => {
|
|
84
|
+
const raw = text(path, value, { required, max: 2048 });
|
|
85
|
+
if (!raw) return undefined;
|
|
86
|
+
const problem = urlProblem(raw);
|
|
87
|
+
return problem ? fail(path, problem) : new URL(raw).href;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const oneOf = (path, value, allowed, fallback) => {
|
|
91
|
+
if (value === undefined) return fallback;
|
|
92
|
+
if (!allowed.includes(value)) return fail(path, `must be one of: ${allowed.join(', ')}`), fallback;
|
|
93
|
+
return value;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const unknownKeys = (path, value, allowed) => {
|
|
97
|
+
for (const key of Object.keys(value)) {
|
|
98
|
+
if (!allowed.includes(key)) fail(path ? `${path}.${key}` : key, 'is not a recognized option');
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return { problems, fail, text, localized, url, oneOf, unknownKeys };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeLinks(c, raw) {
|
|
106
|
+
if (raw === undefined) return [];
|
|
107
|
+
if (!Array.isArray(raw)) return c.fail('links', 'must be an array'), [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
// Ids set by the author win; generated ids step around them.
|
|
110
|
+
const explicitIds = new Set(raw.filter((e) => isObject(e) && typeof e.id === 'string').map((e) => e.id));
|
|
111
|
+
return raw.flatMap((entry, i) => {
|
|
112
|
+
const path = `links[${i}]`;
|
|
113
|
+
if (typeof entry === 'string') entry = { url: entry };
|
|
114
|
+
if (!isObject(entry)) return c.fail(path, 'must be a URL or { url, label?, id? }'), [];
|
|
115
|
+
c.unknownKeys(path, entry, ['url', 'label', 'id', 'icon']);
|
|
116
|
+
const href = c.url(`${path}.url`, entry.url, { required: true });
|
|
117
|
+
if (!href) return [];
|
|
118
|
+
const service = identifyService(href);
|
|
119
|
+
const explicit = c.text(`${path}.id`, entry.id, { max: 40 });
|
|
120
|
+
let id;
|
|
121
|
+
if (explicit) {
|
|
122
|
+
if (!/^[a-z0-9-]+$/i.test(explicit)) return c.fail(`${path}.id`, 'may only contain letters, digits and dashes'), [];
|
|
123
|
+
if (seen.has(explicit)) return c.fail(`${path}.id`, `"${explicit}" is already used by another link`), [];
|
|
124
|
+
id = explicit;
|
|
125
|
+
} else {
|
|
126
|
+
const auto = slugify(service.id) || 'link';
|
|
127
|
+
id = auto;
|
|
128
|
+
for (let n = 2; seen.has(id) || explicitIds.has(id); n++) id = `${auto}-${n}`;
|
|
129
|
+
}
|
|
130
|
+
seen.add(id);
|
|
131
|
+
const icon = c.text(`${path}.icon`, entry.icon, { max: 8 });
|
|
132
|
+
if (icon && displayWidth(icon) > 2) c.fail(`${path}.icon`, 'must be a single symbol (at most 2 columns wide)');
|
|
133
|
+
return [{ id, label: c.text(`${path}.label`, entry.label, { max: 40 }) ?? service.label, url: href, service: service.id, ...(icon && { icon }) }];
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeLanguage(c, raw, strings) {
|
|
138
|
+
// "language": "de" is shorthand for a card that is only in German.
|
|
139
|
+
const value = raw === undefined ? {} : typeof raw === 'string' ? { default: raw, available: [raw] } : raw;
|
|
140
|
+
if (!isObject(value)) return c.fail('language', 'must be a language code or { default, available }'), { default: 'auto', available: ['en'] };
|
|
141
|
+
c.unknownKeys('language', value, ['default', 'available']);
|
|
142
|
+
|
|
143
|
+
const known = [...new Set([...BUILT_IN_LANGUAGES, ...Object.keys(strings)])];
|
|
144
|
+
let available = value.available ?? ['en'];
|
|
145
|
+
if (!Array.isArray(available) || !available.length) {
|
|
146
|
+
c.fail('language.available', 'must be a non-empty array of language codes');
|
|
147
|
+
available = ['en'];
|
|
148
|
+
}
|
|
149
|
+
available = available.map((l) => String(l).toLowerCase());
|
|
150
|
+
for (const lang of available) {
|
|
151
|
+
if (!known.includes(lang)) {
|
|
152
|
+
c.fail('language.available', `"${lang}" has no built-in translation; add it under "strings.${lang}"`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const def = String(value.default ?? 'auto').toLowerCase();
|
|
156
|
+
if (def !== 'auto' && !available.includes(def)) c.fail('language.default', 'must be "auto" or one of language.available');
|
|
157
|
+
return { default: def, available: [...new Set(available)] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizeStrings(c, raw) {
|
|
161
|
+
if (raw === undefined) return {};
|
|
162
|
+
if (!isObject(raw)) return c.fail('strings', 'must be an object of language → { key: text }'), {};
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const [lang, table] of Object.entries(raw)) {
|
|
165
|
+
if (!LANG_CODE.test(lang)) { c.fail(`strings.${lang}`, 'is not a valid language code'); continue; }
|
|
166
|
+
if (!isObject(table)) { c.fail(`strings.${lang}`, 'must be an object of key → text'); continue; }
|
|
167
|
+
out[lang.toLowerCase()] = Object.fromEntries(
|
|
168
|
+
Object.entries(table).flatMap(([key, v]) => {
|
|
169
|
+
const clean = c.text(`strings.${lang}.${key}`, v, { max: 200 });
|
|
170
|
+
return clean ? [[key, clean]] : [];
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeTheme(c, raw) {
|
|
178
|
+
if (raw === undefined) return { ...themes[DEFAULT_THEME] };
|
|
179
|
+
const value = typeof raw === 'string' ? { preset: raw } : raw;
|
|
180
|
+
if (!isObject(value)) return c.fail('theme', 'must be a preset name or an object'), { ...themes[DEFAULT_THEME] };
|
|
181
|
+
c.unknownKeys('theme', value, ['preset', ...THEME_ROLES]);
|
|
182
|
+
const preset = c.oneOf('theme.preset', value.preset, Object.keys(themes), DEFAULT_THEME);
|
|
183
|
+
const theme = { ...themes[preset] };
|
|
184
|
+
for (const role of THEME_ROLES) {
|
|
185
|
+
if (value[role] === undefined) continue;
|
|
186
|
+
if (typeof value[role] === 'string' && HEX.test(value[role])) theme[role] = value[role].toLowerCase();
|
|
187
|
+
else if (value[role] === 'default') theme[role] = 'default';
|
|
188
|
+
else c.fail(`theme.${role}`, 'must be a hex color like "#d7af5f" or "default" (the terminal\'s own color)');
|
|
189
|
+
}
|
|
190
|
+
return theme;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeCard(c, raw, template, fieldIds) {
|
|
194
|
+
// "card": false shows only the menu (with a one-line header).
|
|
195
|
+
if (raw === false) return { ...templates[template], enabled: false, fields: fieldIds };
|
|
196
|
+
const value = raw === undefined || raw === true ? {} : raw;
|
|
197
|
+
if (!isObject(value)) return c.fail('card', 'must be false or an object'), { ...templates[template], enabled: true, fields: fieldIds };
|
|
198
|
+
const options = Object.keys(templates.classic);
|
|
199
|
+
c.unknownKeys('card', value, [...options, 'fields']);
|
|
200
|
+
|
|
201
|
+
const card = { ...templates[template], enabled: true };
|
|
202
|
+
for (const [key, allowed] of Object.entries(CARD_OPTIONS)) card[key] = c.oneOf(`card.${key}`, value[key], allowed, card[key]);
|
|
203
|
+
for (const key of ['splitName', 'accentRule', 'labels']) {
|
|
204
|
+
if (value[key] === undefined) continue;
|
|
205
|
+
if (typeof value[key] === 'boolean') card[key] = value[key];
|
|
206
|
+
else c.fail(`card.${key}`, 'must be true or false');
|
|
207
|
+
}
|
|
208
|
+
if (value.padding !== undefined) {
|
|
209
|
+
if (Number.isInteger(value.padding) && value.padding >= 0 && value.padding <= 8) card.padding = value.padding;
|
|
210
|
+
else c.fail('card.padding', 'must be an integer between 0 and 8');
|
|
211
|
+
}
|
|
212
|
+
if (value.width !== undefined) {
|
|
213
|
+
if (value.width === 'auto' || (Number.isInteger(value.width) && value.width >= 20 && value.width <= 120)) card.width = value.width;
|
|
214
|
+
else c.fail('card.width', 'must be "auto" or an integer between 20 and 120');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
card.fields = fieldIds;
|
|
218
|
+
if (value.fields !== undefined) {
|
|
219
|
+
if (!Array.isArray(value.fields)) c.fail('card.fields', 'must be an array');
|
|
220
|
+
else {
|
|
221
|
+
card.fields = value.fields.filter((f) => {
|
|
222
|
+
if (fieldIds.includes(f)) return true;
|
|
223
|
+
c.fail('card.fields', `unknown field "${f}" (available: ${fieldIds.join(', ') || 'none'})`);
|
|
224
|
+
return false;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return card;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function normalizeMenu(c, raw, linkIds) {
|
|
232
|
+
if (raw === false) return { enabled: false, items: [] };
|
|
233
|
+
const value = raw === undefined || raw === true ? {} : raw;
|
|
234
|
+
if (!isObject(value)) return c.fail('menu', 'must be false or { items }'), { enabled: true, items: DEFAULT_MENU };
|
|
235
|
+
c.unknownKeys('menu', value, ['items']);
|
|
236
|
+
if (value.items === undefined) return { enabled: true, items: DEFAULT_MENU };
|
|
237
|
+
if (!Array.isArray(value.items)) return c.fail('menu.items', 'must be an array'), { enabled: true, items: DEFAULT_MENU };
|
|
238
|
+
|
|
239
|
+
const items = value.items.flatMap((item, i) => {
|
|
240
|
+
const path = `menu.items[${i}]`;
|
|
241
|
+
if (typeof item === 'string') {
|
|
242
|
+
if (MENU_ACTIONS.includes(item)) return [item];
|
|
243
|
+
const link = item.startsWith('link:') && item.slice(5);
|
|
244
|
+
if (link && linkIds.includes(link)) return [item];
|
|
245
|
+
c.fail(path, `unknown item "${item}" (use ${MENU_ACTIONS.join(', ')} or link:<id>)`);
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
if (!isObject(item)) return c.fail(path, 'must be an action name or { label, url, hint? }'), [];
|
|
249
|
+
c.unknownKeys(path, item, ['label', 'url', 'hint']);
|
|
250
|
+
const label = c.localized(`${path}.label`, item.label);
|
|
251
|
+
const href = c.url(`${path}.url`, item.url, { required: true });
|
|
252
|
+
if (!label) c.fail(`${path}.label`, 'is required');
|
|
253
|
+
return label && href ? [{ label, url: href, hint: c.localized(`${path}.hint`, item.hint) }] : [];
|
|
254
|
+
});
|
|
255
|
+
if (!items.length) c.fail('menu.items', 'must contain at least one item (or set "menu": false)');
|
|
256
|
+
return { enabled: true, items };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function normalizeConfig(raw) {
|
|
260
|
+
if (!isObject(raw)) throw new ConfigError(['config must be a JSON object']);
|
|
261
|
+
const c = createChecker();
|
|
262
|
+
c.unknownKeys('', raw, TOP_LEVEL_KEYS);
|
|
263
|
+
|
|
264
|
+
const config = {
|
|
265
|
+
name: c.text('name', raw.name, { required: true, max: 80 }),
|
|
266
|
+
title: c.localized('title', raw.title),
|
|
267
|
+
tagline: c.localized('tagline', raw.tagline),
|
|
268
|
+
description: c.localized('description', raw.description, { max: DESCRIPTION_LIMIT }),
|
|
269
|
+
organization: c.text('organization', raw.organization),
|
|
270
|
+
email: c.text('email', raw.email, { max: 254 }),
|
|
271
|
+
phone: c.text('phone', raw.phone, { max: 30 }),
|
|
272
|
+
website: c.url('website', raw.website),
|
|
273
|
+
location: c.localized('location', raw.location),
|
|
274
|
+
links: normalizeLinks(c, raw.links),
|
|
275
|
+
};
|
|
276
|
+
if (config.email && !isEmail(config.email)) c.fail('email', 'is not a valid email address');
|
|
277
|
+
if (config.phone && !PHONE.test(config.phone)) c.fail('phone', 'may only contain digits, spaces, + ( ) - .');
|
|
278
|
+
|
|
279
|
+
config.strings = normalizeStrings(c, raw.strings);
|
|
280
|
+
config.language = normalizeLanguage(c, raw.language, config.strings);
|
|
281
|
+
config.template = c.oneOf('template', raw.template, Object.keys(templates), DEFAULT_TEMPLATE);
|
|
282
|
+
config.theme = normalizeTheme(c, raw.theme);
|
|
283
|
+
config.icons = c.oneOf('icons', raw.icons, ICON_SETS, 'none');
|
|
284
|
+
|
|
285
|
+
const fieldIds = [
|
|
286
|
+
...(config.website ? ['website'] : []),
|
|
287
|
+
...(config.email ? ['email'] : []),
|
|
288
|
+
...(config.phone ? ['phone'] : []),
|
|
289
|
+
...config.links.map((l) => l.id),
|
|
290
|
+
...(config.location ? ['location'] : []),
|
|
291
|
+
];
|
|
292
|
+
config.card = normalizeCard(c, raw.card, config.template, fieldIds);
|
|
293
|
+
config.menu = normalizeMenu(c, raw.menu, config.links.map((l) => l.id));
|
|
294
|
+
if (!config.card.enabled && !config.menu.enabled) c.fail('card', 'cannot be false while the menu is off too');
|
|
295
|
+
|
|
296
|
+
if (c.problems.length) throw new ConfigError(c.problems);
|
|
297
|
+
return Object.freeze(config);
|
|
298
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Well-known services, used to give links a readable label and a stable id automatically. */
|
|
2
|
+
|
|
3
|
+
const services = [
|
|
4
|
+
['linkedin', 'LinkedIn', ['linkedin.com']],
|
|
5
|
+
['github', 'GitHub', ['github.com']],
|
|
6
|
+
['gitlab', 'GitLab', ['gitlab.com']],
|
|
7
|
+
['x', 'X', ['x.com', 'twitter.com']],
|
|
8
|
+
['bluesky', 'Bluesky', ['bsky.app']],
|
|
9
|
+
['threads', 'Threads', ['threads.net', 'threads.com']],
|
|
10
|
+
['instagram', 'Instagram', ['instagram.com']],
|
|
11
|
+
['facebook', 'Facebook', ['facebook.com']],
|
|
12
|
+
['youtube', 'YouTube', ['youtube.com', 'youtu.be']],
|
|
13
|
+
['tiktok', 'TikTok', ['tiktok.com']],
|
|
14
|
+
['behance', 'Behance', ['behance.net']],
|
|
15
|
+
['dribbble', 'Dribbble', ['dribbble.com']],
|
|
16
|
+
['medium', 'Medium', ['medium.com']],
|
|
17
|
+
['devto', 'DEV', ['dev.to']],
|
|
18
|
+
['stackoverflow', 'Stack Overflow', ['stackoverflow.com']],
|
|
19
|
+
['npm', 'npm', ['npmjs.com']],
|
|
20
|
+
['codepen', 'CodePen', ['codepen.io']],
|
|
21
|
+
['figma', 'Figma', ['figma.com']],
|
|
22
|
+
['producthunt', 'Product Hunt', ['producthunt.com']],
|
|
23
|
+
['calendly', 'Calendly', ['calendly.com']],
|
|
24
|
+
['cal', 'Cal.com', ['cal.com']],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function identifyService(url) {
|
|
28
|
+
let host;
|
|
29
|
+
try {
|
|
30
|
+
host = new URL(url).hostname.replace(/^www\./, '');
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
for (const [id, label, hosts] of services) {
|
|
35
|
+
if (hosts.some((h) => host === h || host.endsWith(`.${h}`))) return { id, label };
|
|
36
|
+
}
|
|
37
|
+
return { id: host.split('.').slice(-2, -1)[0] || host, label: host };
|
|
38
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Templates are presets for the `card` section. Every option can still be
|
|
3
|
+
* overridden individually in the config.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const templates = {
|
|
7
|
+
// Business-card look: letter-spaced name, generous whitespace.
|
|
8
|
+
classic: {
|
|
9
|
+
border: 'rounded',
|
|
10
|
+
nameStyle: 'spaced',
|
|
11
|
+
splitName: true,
|
|
12
|
+
spacing: 'relaxed',
|
|
13
|
+
accentRule: true,
|
|
14
|
+
labels: true,
|
|
15
|
+
locationAlign: 'right',
|
|
16
|
+
padding: 4,
|
|
17
|
+
width: 56,
|
|
18
|
+
},
|
|
19
|
+
// Denser layout for longer names or smaller terminals.
|
|
20
|
+
compact: {
|
|
21
|
+
border: 'rounded',
|
|
22
|
+
nameStyle: 'plain',
|
|
23
|
+
splitName: false,
|
|
24
|
+
spacing: 'compact',
|
|
25
|
+
accentRule: false,
|
|
26
|
+
labels: true,
|
|
27
|
+
locationAlign: 'left',
|
|
28
|
+
padding: 2,
|
|
29
|
+
width: 'auto',
|
|
30
|
+
},
|
|
31
|
+
// No frame, no labels — just the essentials.
|
|
32
|
+
minimal: {
|
|
33
|
+
border: 'none',
|
|
34
|
+
nameStyle: 'plain',
|
|
35
|
+
splitName: false,
|
|
36
|
+
spacing: 'compact',
|
|
37
|
+
accentRule: false,
|
|
38
|
+
labels: false,
|
|
39
|
+
locationAlign: 'left',
|
|
40
|
+
padding: 0,
|
|
41
|
+
width: 'auto',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_TEMPLATE = 'classic';
|
|
46
|
+
|
|
47
|
+
export const CARD_OPTIONS = {
|
|
48
|
+
border: ['rounded', 'square', 'double', 'heavy', 'none'],
|
|
49
|
+
nameStyle: ['spaced', 'uppercase', 'plain'],
|
|
50
|
+
spacing: ['relaxed', 'compact'],
|
|
51
|
+
locationAlign: ['left', 'right'],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Default menu: every action that has the data it needs is shown, in this order. */
|
|
55
|
+
export const DEFAULT_MENU = ['contact', 'save', 'website', 'links', 'copy-email', 'language', 'exit'];
|
|
56
|
+
export const MENU_ACTIONS = ['contact', 'save', 'website', 'links', 'copy-email', 'copy-phone', 'language', 'exit'];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color themes, named after the four seasons and the turns between them.
|
|
3
|
+
*
|
|
4
|
+
* Only the accent changes between themes. Text uses the terminal's own
|
|
5
|
+
* foreground color ("default"), and the grays are mid-tones, so every theme
|
|
6
|
+
* reads well on both dark and light terminal backgrounds. Accents are exact
|
|
7
|
+
* xterm-256 colors, so they look the same in 256-color and true-color terminals.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const neutral = { text: 'default', muted: '#8a8a8a', faint: '#6c6c6c' };
|
|
11
|
+
|
|
12
|
+
export const themes = {
|
|
13
|
+
thaw: { ...neutral, accent: '#5fafaf' }, // late winter → spring: meltwater teal
|
|
14
|
+
bloom: { ...neutral, accent: '#87af5f' }, // spring: fresh leaves
|
|
15
|
+
blossom: { ...neutral, accent: '#d787af' }, // spring → summer: petals
|
|
16
|
+
solstice: { ...neutral, accent: '#d7af5f' }, // summer: midday gold (default)
|
|
17
|
+
ember: { ...neutral, accent: '#d7875f' }, // summer → autumn: last warm light
|
|
18
|
+
harvest: { ...neutral, accent: '#af875f' }, // autumn: wheat and wood
|
|
19
|
+
frost: { ...neutral, accent: '#87afd7' }, // autumn → winter: first ice
|
|
20
|
+
midwinter: { ...neutral, accent: '#8787af' }, // winter: long blue dusk
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const THEME_DESCRIPTIONS = {
|
|
24
|
+
thaw: 'late winter → spring, meltwater teal',
|
|
25
|
+
bloom: 'spring, fresh green',
|
|
26
|
+
blossom: 'spring → summer, petal pink',
|
|
27
|
+
solstice: 'summer, midday gold',
|
|
28
|
+
ember: 'summer → autumn, warm copper',
|
|
29
|
+
harvest: 'autumn, wheat brown',
|
|
30
|
+
frost: 'autumn → winter, ice blue',
|
|
31
|
+
midwinter: 'winter, dusk lavender',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_THEME = 'solstice';
|
|
35
|
+
export const THEME_ROLES = ['text', 'muted', 'faint', 'accent'];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internationalization: built-in locales, string lookup with per-card
|
|
3
|
+
* overrides, and language detection from the user's environment.
|
|
4
|
+
*
|
|
5
|
+
* Terminals have no translation feature of their own, so the language is
|
|
6
|
+
* chosen from (in order): --lang flag, TERMCARD_LANG, the POSIX locale
|
|
7
|
+
* variables, the OS locale reported by Intl, then the card's default.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { format } from '../text.js';
|
|
11
|
+
import en from './locales/en.js';
|
|
12
|
+
import tr from './locales/tr.js';
|
|
13
|
+
import de from './locales/de.js';
|
|
14
|
+
import es from './locales/es.js';
|
|
15
|
+
import fr from './locales/fr.js';
|
|
16
|
+
|
|
17
|
+
const LOCALES = { en, tr, de, es, fr };
|
|
18
|
+
export const BUILT_IN_LANGUAGES = Object.keys(LOCALES);
|
|
19
|
+
|
|
20
|
+
/** Returns a translator bound to one language, honoring the card's own string overrides. */
|
|
21
|
+
export function createTranslator(lang, overrides = {}) {
|
|
22
|
+
const tables = [overrides[lang], LOCALES[lang], overrides.en, en].filter(Boolean);
|
|
23
|
+
return (key, values) => {
|
|
24
|
+
for (const table of tables) if (table[key] !== undefined) return format(table[key], values);
|
|
25
|
+
return key;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Native display name of a language, e.g. "Türkçe". */
|
|
30
|
+
export const languageName = (lang, overrides = {}) =>
|
|
31
|
+
overrides[lang]?.['meta.name'] ?? LOCALES[lang]?.['meta.name'] ?? lang.toUpperCase();
|
|
32
|
+
|
|
33
|
+
/** "tr_TR.UTF-8" → "tr", "pt-BR" → "pt". Returns undefined for C/POSIX or empty values. */
|
|
34
|
+
function baseLanguage(locale) {
|
|
35
|
+
if (!locale || /^(C|POSIX)([._].*)?$/i.test(locale)) return undefined;
|
|
36
|
+
return locale.split(/[_.@-]/)[0].toLowerCase() || undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Picks the language to show.
|
|
41
|
+
* @param {{ default: string, available: string[] }} language normalized config.language
|
|
42
|
+
* @param {{ flag?: string, env?: NodeJS.ProcessEnv }} context
|
|
43
|
+
*/
|
|
44
|
+
export function detectLanguage(language, { flag, env = {} } = {}) {
|
|
45
|
+
const { available } = language;
|
|
46
|
+
const pick = (code) => (code && available.includes(code) ? code : undefined);
|
|
47
|
+
|
|
48
|
+
const explicit = pick(flag?.toLowerCase()) ?? pick(env.TERMCARD_LANG?.toLowerCase());
|
|
49
|
+
if (explicit) return explicit;
|
|
50
|
+
if (language.default !== 'auto') return language.default;
|
|
51
|
+
|
|
52
|
+
const candidates = [env.LC_ALL, env.LC_MESSAGES, env.LANG, ...(env.LANGUAGE?.split(':') ?? [])];
|
|
53
|
+
try {
|
|
54
|
+
candidates.push(Intl.DateTimeFormat().resolvedOptions().locale);
|
|
55
|
+
} catch {
|
|
56
|
+
// Intl unavailable (very old runtimes): ignore.
|
|
57
|
+
}
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
const found = pick(baseLanguage(candidate));
|
|
60
|
+
if (found) return found;
|
|
61
|
+
}
|
|
62
|
+
return available.includes('en') ? 'en' : available[0];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Language from an HTTP Accept-Language header (used by the web/curl integrations). */
|
|
66
|
+
export function languageFromHeader(header = '', available = ['en']) {
|
|
67
|
+
for (const part of header.split(',')) {
|
|
68
|
+
const code = baseLanguage(part.split(';')[0].trim());
|
|
69
|
+
if (code && available.includes(code)) return code;
|
|
70
|
+
}
|
|
71
|
+
return available.includes('en') ? 'en' : available[0];
|
|
72
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
'meta.name': 'Deutsch',
|
|
3
|
+
'label.website': 'Web',
|
|
4
|
+
'label.email': 'E-Mail',
|
|
5
|
+
'label.phone': 'Telefon',
|
|
6
|
+
'action.contact': 'Kontakt aufnehmen',
|
|
7
|
+
'hint.contact': 'Öffnet eine neue Nachricht in Ihrem E-Mail-Programm.',
|
|
8
|
+
'action.save': 'Kontakt speichern',
|
|
9
|
+
'hint.save': 'Fügt die Visitenkarte (vCard) Ihrem Adressbuch hinzu.',
|
|
10
|
+
'action.website': 'Website besuchen',
|
|
11
|
+
'hint.website': 'Öffnet {host} in Ihrem Browser.',
|
|
12
|
+
'action.link': '{label}-Profil ansehen',
|
|
13
|
+
'hint.link': 'Öffnet {host} in Ihrem Browser.',
|
|
14
|
+
'action.copyEmail': 'E-Mail-Adresse kopieren',
|
|
15
|
+
'hint.copyEmail': 'Kopiert die Adresse in die Zwischenablage.',
|
|
16
|
+
'action.copyPhone': 'Telefonnummer kopieren',
|
|
17
|
+
'hint.copyPhone': 'Kopiert die Nummer in die Zwischenablage.',
|
|
18
|
+
'action.language': 'Sprache',
|
|
19
|
+
'hint.language': 'Zeigt die Karte in einer anderen Sprache.',
|
|
20
|
+
'action.back': 'Zurück',
|
|
21
|
+
'hint.back': 'Zurück zum Hauptmenü.',
|
|
22
|
+
'action.exit': 'Beenden',
|
|
23
|
+
'hint.exit': 'Schließt die Karte.',
|
|
24
|
+
'status.contact': 'E-Mail-Entwurf geöffnet.',
|
|
25
|
+
'status.contactFailed': 'E-Mail-Programm konnte nicht geöffnet werden — {value}',
|
|
26
|
+
'status.save': 'Visitenkarte geöffnet.',
|
|
27
|
+
'status.saveFailed': 'Visitenkarte gespeichert unter {value}',
|
|
28
|
+
'status.saveError': 'Visitenkarte konnte nicht erstellt werden.',
|
|
29
|
+
'status.browser': 'Im Browser geöffnet.',
|
|
30
|
+
'status.browserFailed': 'Browser konnte nicht geöffnet werden — {value}',
|
|
31
|
+
'status.copied': 'In die Zwischenablage kopiert.',
|
|
32
|
+
'status.copyFailed': 'Zwischenablage nicht verfügbar — {value}',
|
|
33
|
+
'status.language': 'Sprache geändert.',
|
|
34
|
+
'keys.navigate': 'navigieren',
|
|
35
|
+
'keys.select': 'auswählen',
|
|
36
|
+
'keys.quit': 'beenden',
|
|
37
|
+
'keys.back': 'zurück',
|
|
38
|
+
'mail.subject': 'Hallo',
|
|
39
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
'meta.name': 'English',
|
|
3
|
+
'label.website': 'Web',
|
|
4
|
+
'label.email': 'Email',
|
|
5
|
+
'label.phone': 'Phone',
|
|
6
|
+
'action.contact': 'Get in touch',
|
|
7
|
+
'hint.contact': 'Opens a new message in your email client.',
|
|
8
|
+
'action.save': 'Save contact',
|
|
9
|
+
'hint.save': 'Adds the contact card (vCard) to your address book.',
|
|
10
|
+
'action.website': 'Visit website',
|
|
11
|
+
'hint.website': 'Opens {host} in your browser.',
|
|
12
|
+
'action.link': 'View {label} profile',
|
|
13
|
+
'hint.link': 'Opens {host} in your browser.',
|
|
14
|
+
'action.copyEmail': 'Copy email address',
|
|
15
|
+
'hint.copyEmail': 'Copies the address to your clipboard.',
|
|
16
|
+
'action.copyPhone': 'Copy phone number',
|
|
17
|
+
'hint.copyPhone': 'Copies the number to your clipboard.',
|
|
18
|
+
'action.language': 'Language',
|
|
19
|
+
'hint.language': 'Show this card in another language.',
|
|
20
|
+
'action.back': 'Back',
|
|
21
|
+
'hint.back': 'Return to the main menu.',
|
|
22
|
+
'action.exit': 'Exit',
|
|
23
|
+
'hint.exit': 'Closes the card.',
|
|
24
|
+
'status.contact': 'Email draft opened.',
|
|
25
|
+
'status.contactFailed': 'Could not open an email client — {value}',
|
|
26
|
+
'status.save': 'Contact card opened.',
|
|
27
|
+
'status.saveFailed': 'Contact card saved to {value}',
|
|
28
|
+
'status.saveError': 'Could not create the contact card.',
|
|
29
|
+
'status.browser': 'Opened in your browser.',
|
|
30
|
+
'status.browserFailed': 'Could not open a browser — {value}',
|
|
31
|
+
'status.copied': 'Copied to clipboard.',
|
|
32
|
+
'status.copyFailed': 'Clipboard unavailable — {value}',
|
|
33
|
+
'status.language': 'Language changed.',
|
|
34
|
+
'keys.navigate': 'navigate',
|
|
35
|
+
'keys.select': 'select',
|
|
36
|
+
'keys.quit': 'quit',
|
|
37
|
+
'keys.back': 'back',
|
|
38
|
+
'mail.subject': 'Hello',
|
|
39
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
'meta.name': 'Español',
|
|
3
|
+
'label.website': 'Web',
|
|
4
|
+
'label.email': 'Correo',
|
|
5
|
+
'label.phone': 'Teléfono',
|
|
6
|
+
'action.contact': 'Ponerse en contacto',
|
|
7
|
+
'hint.contact': 'Abre un mensaje nuevo en tu cliente de correo.',
|
|
8
|
+
'action.save': 'Guardar contacto',
|
|
9
|
+
'hint.save': 'Añade la tarjeta de contacto (vCard) a tu agenda.',
|
|
10
|
+
'action.website': 'Visitar sitio web',
|
|
11
|
+
'hint.website': 'Abre {host} en tu navegador.',
|
|
12
|
+
'action.link': 'Ver perfil de {label}',
|
|
13
|
+
'hint.link': 'Abre {host} en tu navegador.',
|
|
14
|
+
'action.copyEmail': 'Copiar correo electrónico',
|
|
15
|
+
'hint.copyEmail': 'Copia la dirección al portapapeles.',
|
|
16
|
+
'action.copyPhone': 'Copiar número de teléfono',
|
|
17
|
+
'hint.copyPhone': 'Copia el número al portapapeles.',
|
|
18
|
+
'action.language': 'Idioma',
|
|
19
|
+
'hint.language': 'Muestra la tarjeta en otro idioma.',
|
|
20
|
+
'action.back': 'Volver',
|
|
21
|
+
'hint.back': 'Vuelve al menú principal.',
|
|
22
|
+
'action.exit': 'Salir',
|
|
23
|
+
'hint.exit': 'Cierra la tarjeta.',
|
|
24
|
+
'status.contact': 'Borrador de correo abierto.',
|
|
25
|
+
'status.contactFailed': 'No se pudo abrir un cliente de correo — {value}',
|
|
26
|
+
'status.save': 'Tarjeta de contacto abierta.',
|
|
27
|
+
'status.saveFailed': 'Tarjeta de contacto guardada en {value}',
|
|
28
|
+
'status.saveError': 'No se pudo crear la tarjeta de contacto.',
|
|
29
|
+
'status.browser': 'Abierto en tu navegador.',
|
|
30
|
+
'status.browserFailed': 'No se pudo abrir un navegador — {value}',
|
|
31
|
+
'status.copied': 'Copiado al portapapeles.',
|
|
32
|
+
'status.copyFailed': 'Portapapeles no disponible — {value}',
|
|
33
|
+
'status.language': 'Idioma cambiado.',
|
|
34
|
+
'keys.navigate': 'navegar',
|
|
35
|
+
'keys.select': 'seleccionar',
|
|
36
|
+
'keys.quit': 'salir',
|
|
37
|
+
'keys.back': 'volver',
|
|
38
|
+
'mail.subject': 'Hola',
|
|
39
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
'meta.name': 'Français',
|
|
3
|
+
'label.website': 'Web',
|
|
4
|
+
'label.email': 'E-mail',
|
|
5
|
+
'label.phone': 'Téléphone',
|
|
6
|
+
'action.contact': 'Prendre contact',
|
|
7
|
+
'hint.contact': 'Ouvre un nouveau message dans votre messagerie.',
|
|
8
|
+
'action.save': 'Enregistrer le contact',
|
|
9
|
+
'hint.save': 'Ajoute la carte de visite (vCard) à votre carnet d’adresses.',
|
|
10
|
+
'action.website': 'Visiter le site web',
|
|
11
|
+
'hint.website': 'Ouvre {host} dans votre navigateur.',
|
|
12
|
+
'action.link': 'Voir le profil {label}',
|
|
13
|
+
'hint.link': 'Ouvre {host} dans votre navigateur.',
|
|
14
|
+
'action.copyEmail': 'Copier l’adresse e-mail',
|
|
15
|
+
'hint.copyEmail': 'Copie l’adresse dans le presse-papiers.',
|
|
16
|
+
'action.copyPhone': 'Copier le numéro de téléphone',
|
|
17
|
+
'hint.copyPhone': 'Copie le numéro dans le presse-papiers.',
|
|
18
|
+
'action.language': 'Langue',
|
|
19
|
+
'hint.language': 'Affiche la carte dans une autre langue.',
|
|
20
|
+
'action.back': 'Retour',
|
|
21
|
+
'hint.back': 'Revient au menu principal.',
|
|
22
|
+
'action.exit': 'Quitter',
|
|
23
|
+
'hint.exit': 'Ferme la carte.',
|
|
24
|
+
'status.contact': 'Brouillon d’e-mail ouvert.',
|
|
25
|
+
'status.contactFailed': 'Impossible d’ouvrir une messagerie — {value}',
|
|
26
|
+
'status.save': 'Carte de visite ouverte.',
|
|
27
|
+
'status.saveFailed': 'Carte de visite enregistrée dans {value}',
|
|
28
|
+
'status.saveError': 'Impossible de créer la carte de visite.',
|
|
29
|
+
'status.browser': 'Ouvert dans votre navigateur.',
|
|
30
|
+
'status.browserFailed': 'Impossible d’ouvrir un navigateur — {value}',
|
|
31
|
+
'status.copied': 'Copié dans le presse-papiers.',
|
|
32
|
+
'status.copyFailed': 'Presse-papiers indisponible — {value}',
|
|
33
|
+
'status.language': 'Langue modifiée.',
|
|
34
|
+
'keys.navigate': 'naviguer',
|
|
35
|
+
'keys.select': 'choisir',
|
|
36
|
+
'keys.quit': 'quitter',
|
|
37
|
+
'keys.back': 'retour',
|
|
38
|
+
'mail.subject': 'Bonjour',
|
|
39
|
+
};
|