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.
- package/LICENSE +21 -0
- package/README.md +26 -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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ahmet Erkan Paşahan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# termcard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Renders and runs interactive terminal business cards. You usually don't install this
|
|
4
|
+
directly — start with:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm create termcard@latest
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Using it in your own package
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
#!/usr/bin/env node
|
|
14
|
+
import { start } from 'termcard';
|
|
15
|
+
|
|
16
|
+
await start(new URL('../card.config.json', import.meta.url));
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## CLI
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
termcard preview [config] # show the card
|
|
23
|
+
termcard validate [config] # check the config
|
|
24
|
+
termcard build [config] # web page, vCards and curl files in dist/
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Configuration: <https://github.com/aepasahan/termcard/blob/main/docs/configuration.md>
|
package/bin/termcard.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termcard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Interactive business cards for the terminal — npx yourname",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./browser": "./src/browser.js",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"termcard": "bin/termcard.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18.18"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"business-card",
|
|
26
|
+
"terminal",
|
|
27
|
+
"cli",
|
|
28
|
+
"npx",
|
|
29
|
+
"vcard",
|
|
30
|
+
"card"
|
|
31
|
+
],
|
|
32
|
+
"author": "Ahmet Erkan Paşahan <ae.pasahan@creetivelab.com> (https://aepasahan.com)",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/aepasahan/termcard.git",
|
|
37
|
+
"directory": "packages/termcard"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/aepasahan/termcard#readme",
|
|
40
|
+
"bugs": "https://github.com/aepasahan/termcard/issues"
|
|
41
|
+
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe entry point (no Node.js built-ins): validation, layout and
|
|
3
|
+
* HTML/plain rendering. Used by the web config builder.
|
|
4
|
+
*
|
|
5
|
+
* import { normalizeConfig, layoutCard, toHtml } from 'termcard/browser';
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { normalizeConfig, ConfigError, isEmail, urlProblem, TEXT_LIMIT, DESCRIPTION_LIMIT, ICON_SETS } from './config/schema.js';
|
|
9
|
+
export { templates, DEFAULT_TEMPLATE, CARD_OPTIONS } from './config/templates.js';
|
|
10
|
+
export { themes, THEME_DESCRIPTIONS, DEFAULT_THEME } from './config/themes.js';
|
|
11
|
+
export { BUILT_IN_LANGUAGES, createTranslator, detectLanguage, languageFromHeader, languageName } from './i18n/index.js';
|
|
12
|
+
export { layoutCard, layoutHeader } from './render/card.js';
|
|
13
|
+
export { toPlain, toHtml, escapeHtml } from './render/format.js';
|
|
14
|
+
export { webIconSet } from './render/icons.js';
|
|
15
|
+
export { buildMenu } from './tui/actions.js';
|
|
16
|
+
export { createVCard } from './vcard.js';
|
|
17
|
+
export { slugify } from './text.js';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** `termcard build`: writes every output format of a card into a directory. */
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { layoutCard } from '../render/card.js';
|
|
6
|
+
import { toAnsi, toPlain } from '../render/format.js';
|
|
7
|
+
import { createVCard } from '../vcard.js';
|
|
8
|
+
import { renderWebPage } from './web.js';
|
|
9
|
+
import { integrations } from './integrations.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} config normalized config
|
|
13
|
+
* @param {{ outDir: string, packageName?: string }} options
|
|
14
|
+
* @returns {string[]} written file names, relative to outDir
|
|
15
|
+
*/
|
|
16
|
+
export class UnsafeOutputError extends Error {
|
|
17
|
+
constructor(dir) {
|
|
18
|
+
super(`${dir} is not empty and was not created by termcard build; choose another --out folder`);
|
|
19
|
+
this.name = 'UnsafeOutputError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Marks a folder as termcard output, so a rebuild may safely empty it.
|
|
24
|
+
const MARKER = '.termcard-build';
|
|
25
|
+
|
|
26
|
+
export function buildCard(config, { outDir, packageName }) {
|
|
27
|
+
if (existsSync(outDir) && readdirSync(outDir).length) {
|
|
28
|
+
if (!existsSync(join(outDir, MARKER))) throw new UnsafeOutputError(outDir);
|
|
29
|
+
rmSync(outDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
mkdirSync(outDir, { recursive: true });
|
|
32
|
+
writeFileSync(join(outDir, MARKER), 'Generated by termcard build. This folder is replaced on every build.\n');
|
|
33
|
+
const written = [];
|
|
34
|
+
const write = (name, content) => {
|
|
35
|
+
const path = join(outDir, name);
|
|
36
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
37
|
+
writeFileSync(path, content);
|
|
38
|
+
written.push(name);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const langs = config.language.available;
|
|
42
|
+
for (const lang of langs) {
|
|
43
|
+
const lines = layoutCard(config, lang);
|
|
44
|
+
write(`card.${lang}.ansi`, `\n${toAnsi(lines, config.theme, 256)}\n\n`);
|
|
45
|
+
write(`card.${lang}.txt`, `\n${toPlain(lines)}\n\n`);
|
|
46
|
+
write(`contact.${lang}.vcf`, createVCard(config, lang));
|
|
47
|
+
}
|
|
48
|
+
write('index.html', renderWebPage(config, { command: packageName ? `npx ${packageName}` : 'npx termcard' }));
|
|
49
|
+
if (packageName) {
|
|
50
|
+
for (const [name, code] of Object.entries(integrations(packageName, langs))) write(`integrations/${name}`, code);
|
|
51
|
+
}
|
|
52
|
+
return written;
|
|
53
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snippets that make `curl yourdomain.com` print the card. They fetch the
|
|
3
|
+
* prebuilt text files from the published npm package via jsDelivr, so the
|
|
4
|
+
* site always shows the latest released card without redeploying.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const shared = (pkg, langs) => `const CARD_BASE = 'https://cdn.jsdelivr.net/npm/${pkg}@latest/dist';
|
|
8
|
+
const LANGS = ${JSON.stringify(langs)};
|
|
9
|
+
const TERMINAL = /^(curl|wget|httpie|xh|fetch|libfetch)\\b/i;
|
|
10
|
+
|
|
11
|
+
function cardFile(url, headers) {
|
|
12
|
+
const ua = headers.get('user-agent') || '';
|
|
13
|
+
if (!TERMINAL.test(ua)) return null;
|
|
14
|
+
const path = url.pathname.replace(/\\/+$/, '') || '/';
|
|
15
|
+
const fromPath = LANGS.find((l) => path === '/' + l);
|
|
16
|
+
if (path !== '/' && !fromPath) return null;
|
|
17
|
+
const accepted = (headers.get('accept-language') || '').split(',').map((p) => p.trim().slice(0, 2).toLowerCase());
|
|
18
|
+
const lang = fromPath || LANGS.find((l) => url.searchParams.get('lang') === l) || accepted.find((l) => LANGS.includes(l)) || LANGS[0];
|
|
19
|
+
const plain = /^wget/i.test(ua) || url.searchParams.has('plain');
|
|
20
|
+
return CARD_BASE + '/card.' + lang + (plain ? '.txt' : '.ansi');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TEXT_HEADERS = {
|
|
24
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
25
|
+
'cache-control': 'public, max-age=3600',
|
|
26
|
+
vary: 'user-agent, accept-language',
|
|
27
|
+
};`;
|
|
28
|
+
|
|
29
|
+
export function integrations(pkg, langs) {
|
|
30
|
+
const ordered = langs.includes('en') ? ['en', ...langs.filter((l) => l !== 'en')] : langs;
|
|
31
|
+
const routes = JSON.stringify(['/', ...ordered.map((l) => `/${l}`)]);
|
|
32
|
+
return {
|
|
33
|
+
'cloudflare-worker.js': `// Cloudflare Worker: add a route for your domain (e.g. example.com/*).
|
|
34
|
+
// Terminal clients get the card; everyone else gets your site unchanged.
|
|
35
|
+
${shared(pkg, ordered)}
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
async fetch(request) {
|
|
39
|
+
const file = cardFile(new URL(request.url), request.headers);
|
|
40
|
+
if (!file) return fetch(request);
|
|
41
|
+
const res = await fetch(file, { cf: { cacheTtl: 3600 } });
|
|
42
|
+
return res.ok ? new Response(res.body, { headers: TEXT_HEADERS }) : fetch(request);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
`,
|
|
46
|
+
'nextjs-middleware.js': `// Next.js / Vercel: save as middleware.js in your project root.
|
|
47
|
+
import { NextResponse } from 'next/server';
|
|
48
|
+
${shared(pkg, ordered)}
|
|
49
|
+
|
|
50
|
+
export const config = { matcher: ${routes} };
|
|
51
|
+
|
|
52
|
+
export async function middleware(request) {
|
|
53
|
+
const file = cardFile(request.nextUrl, request.headers);
|
|
54
|
+
if (!file) return NextResponse.next();
|
|
55
|
+
const res = await fetch(file, { next: { revalidate: 3600 } });
|
|
56
|
+
return res.ok ? new NextResponse(await res.text(), { headers: TEXT_HEADERS }) : NextResponse.next();
|
|
57
|
+
}
|
|
58
|
+
`,
|
|
59
|
+
'netlify-edge.js': `// Netlify: save as netlify/edge-functions/terminal-card.js
|
|
60
|
+
${shared(pkg, ordered)}
|
|
61
|
+
|
|
62
|
+
export default async (request, context) => {
|
|
63
|
+
const file = cardFile(new URL(request.url), request.headers);
|
|
64
|
+
if (!file) return context.next();
|
|
65
|
+
const res = await fetch(file);
|
|
66
|
+
return res.ok ? new Response(res.body, { headers: TEXT_HEADERS }) : context.next();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const config = { path: ${routes} };
|
|
70
|
+
`,
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/build/web.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Standalone, dependency-free web version of the card (one HTML file). */
|
|
2
|
+
|
|
3
|
+
import { layoutCard } from '../render/card.js';
|
|
4
|
+
import { webIconSet } from '../render/icons.js';
|
|
5
|
+
import { toHtml, escapeHtml } from '../render/format.js';
|
|
6
|
+
import { createTranslator, languageName } from '../i18n/index.js';
|
|
7
|
+
import { localize } from '../text.js';
|
|
8
|
+
|
|
9
|
+
export function renderWebPage(config, { command }) {
|
|
10
|
+
const { theme } = config;
|
|
11
|
+
const langs = config.language.available;
|
|
12
|
+
const fallback = langs.includes('en') ? 'en' : langs[0];
|
|
13
|
+
const data = Object.fromEntries(
|
|
14
|
+
langs.map((lang) => {
|
|
15
|
+
const t = createTranslator(lang, config.strings);
|
|
16
|
+
return [lang, { card: toHtml(layoutCard(config, lang, { iconSet: webIconSet(config.icons) })), save: t('action.save'), vcf: `contact.${lang}.vcf` }];
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
const description = [config.name, localize(config.title, fallback), localize(config.tagline, fallback)].filter(Boolean).join(' — ');
|
|
20
|
+
// JSON embedded in <script> must not be able to close the tag.
|
|
21
|
+
const json = JSON.stringify(data).replace(/</g, '\\u003c');
|
|
22
|
+
|
|
23
|
+
return `<!doctype html>
|
|
24
|
+
<html lang="${fallback}">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8">
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
28
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'">
|
|
29
|
+
<meta name="referrer" content="no-referrer">
|
|
30
|
+
<title>${escapeHtml(config.name)}</title>
|
|
31
|
+
<meta name="description" content="${escapeHtml(description)}">
|
|
32
|
+
<meta property="og:title" content="${escapeHtml(config.name)}">
|
|
33
|
+
<meta property="og:description" content="${escapeHtml(description)}">
|
|
34
|
+
<meta name="color-scheme" content="dark">
|
|
35
|
+
<style>
|
|
36
|
+
:root{--bg:#0e0e0e;--window:#161616;--line:#262626;--text:${theme.text === 'default' ? '#eeeeee' : theme.text};--muted:${theme.muted};--faint:${theme.faint};--accent:${theme.accent}}
|
|
37
|
+
*{box-sizing:border-box}
|
|
38
|
+
html,body{margin:0;background:var(--bg);color:var(--text)}
|
|
39
|
+
body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px 16px;
|
|
40
|
+
font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:min(15px,calc((100vw - 72px) / 32))}
|
|
41
|
+
.window{width:max-content;max-width:100%;background:var(--window);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
|
42
|
+
.bar{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);color:var(--faint);font-size:.85em}
|
|
43
|
+
.lang{display:flex;gap:4px}
|
|
44
|
+
.lang button{all:unset;cursor:pointer;padding:2px 8px;border-radius:4px;color:var(--faint)}
|
|
45
|
+
.lang button[aria-pressed="true"]{color:var(--accent)}
|
|
46
|
+
.lang button:focus-visible,.btn:focus-visible,a:focus-visible{outline:1px solid var(--accent);outline-offset:2px}
|
|
47
|
+
pre{margin:20px;padding:1.6em 2.4em;border:1px solid var(--line);border-radius:6px;font:inherit;line-height:1.45;white-space:pre;overflow-x:auto}
|
|
48
|
+
a{color:inherit;text-decoration:none}
|
|
49
|
+
a:hover span{text-decoration:underline;text-underline-offset:3px}
|
|
50
|
+
.name{color:var(--text);font-weight:700}.text{color:var(--text)}.muted{color:var(--muted)}.faint{color:var(--faint)}.accent{color:var(--accent)}
|
|
51
|
+
.foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:0 20px 20px}
|
|
52
|
+
.cmd{display:flex;align-items:center;gap:10px}
|
|
53
|
+
.cmd code::before{content:"$ ";color:var(--faint)}
|
|
54
|
+
.btn{all:unset;cursor:pointer;color:var(--faint);font-size:.85em;padding:2px 8px;border:1px solid var(--line);border-radius:4px}
|
|
55
|
+
.btn:hover{color:var(--accent)}
|
|
56
|
+
@media (max-width:600px){body{padding:16px}pre{margin:12px;padding:1.2em 1.4em}.foot{padding:0 12px 14px}}
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<main class="window">
|
|
61
|
+
<div class="bar"><span>~ ${escapeHtml(command)}</span>${
|
|
62
|
+
langs.length > 1
|
|
63
|
+
? `<span class="lang" role="group" aria-label="Language">${langs.map((l) => `<button type="button" data-lang="${l}" title="${escapeHtml(languageName(l, config.strings))}">${l.toUpperCase()}</button>`).join('')}</span>`
|
|
64
|
+
: ''
|
|
65
|
+
}</div>
|
|
66
|
+
<pre id="card" aria-label="${escapeHtml(config.name)}">${data[fallback].card}</pre>
|
|
67
|
+
<div class="foot">
|
|
68
|
+
<span class="cmd"><code>${escapeHtml(command)}</code><button class="btn" type="button" id="copy">Copy</button></span>
|
|
69
|
+
<a class="btn" id="vcf" href="${data[fallback].vcf}" download>${escapeHtml(data[fallback].save)}</a>
|
|
70
|
+
</div>
|
|
71
|
+
</main>
|
|
72
|
+
<script>
|
|
73
|
+
(() => {
|
|
74
|
+
const data = ${json};
|
|
75
|
+
const langs = Object.keys(data);
|
|
76
|
+
const $ = (id) => document.getElementById(id);
|
|
77
|
+
const has = (lang) => Object.prototype.hasOwnProperty.call(data, lang);
|
|
78
|
+
const set = (lang) => {
|
|
79
|
+
if (!has(lang)) lang = langs.includes('${fallback}') ? '${fallback}' : langs[0];
|
|
80
|
+
document.documentElement.lang = lang;
|
|
81
|
+
$('card').innerHTML = data[lang].card;
|
|
82
|
+
$('vcf').textContent = data[lang].save;
|
|
83
|
+
$('vcf').href = data[lang].vcf;
|
|
84
|
+
document.querySelectorAll('[data-lang]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.lang === lang)));
|
|
85
|
+
};
|
|
86
|
+
document.querySelectorAll('[data-lang]').forEach((b) => b.addEventListener('click', () => {
|
|
87
|
+
set(b.dataset.lang);
|
|
88
|
+
const url = new URL(location.href); url.searchParams.set('lang', b.dataset.lang); history.replaceState(null, '', url);
|
|
89
|
+
}));
|
|
90
|
+
$('copy').addEventListener('click', async () => {
|
|
91
|
+
try { await navigator.clipboard.writeText($('copy').previousElementSibling.textContent); $('copy').textContent = '✓'; setTimeout(() => { $('copy').textContent = 'Copy'; }, 1500); } catch {}
|
|
92
|
+
});
|
|
93
|
+
const param = new URLSearchParams(location.search).get('lang');
|
|
94
|
+
const browser = (navigator.languages || [navigator.language]).map((l) => String(l).slice(0, 2).toLowerCase()).find(has);
|
|
95
|
+
set(param || browser);
|
|
96
|
+
})();
|
|
97
|
+
</script>
|
|
98
|
+
</body>
|
|
99
|
+
</html>
|
|
100
|
+
`;
|
|
101
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** `termcard` command-line tool for card authors: preview, validate, build. */
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { loadConfig } from './config/load.js';
|
|
7
|
+
import { ConfigError } from './config/schema.js';
|
|
8
|
+
import { run } from './run.js';
|
|
9
|
+
import { buildCard, UnsafeOutputError } from './build/index.js';
|
|
10
|
+
|
|
11
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
12
|
+
const DEFAULT_CONFIG = 'card.config.json';
|
|
13
|
+
|
|
14
|
+
const HELP = `termcard ${VERSION} — terminal business cards
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
termcard preview [config] Show the card (accepts the same flags as the card itself)
|
|
18
|
+
termcard validate [config] Check a config and list any problems
|
|
19
|
+
termcard build [config] [--out d] Write terminal, web, vCard and curl files (default: dist)
|
|
20
|
+
|
|
21
|
+
Create a new card:
|
|
22
|
+
npm create termcard@latest
|
|
23
|
+
|
|
24
|
+
The config defaults to ./${DEFAULT_CONFIG}.`;
|
|
25
|
+
|
|
26
|
+
/** Package name of the card, read from the package.json next to the config. */
|
|
27
|
+
function packageNameFor(configPath) {
|
|
28
|
+
const pkg = join(dirname(resolve(configPath)), 'package.json');
|
|
29
|
+
if (!existsSync(pkg)) return undefined;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(pkg, 'utf8')).name;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function cli(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
38
|
+
const [command, ...rest] = argv;
|
|
39
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') return io.stdout.write(`${HELP}\n`), 0;
|
|
40
|
+
if (command === '--version' || command === '-v') return io.stdout.write(`${VERSION}\n`), 0;
|
|
41
|
+
|
|
42
|
+
const positional = rest[0] && !rest[0].startsWith('-') ? rest[0] : undefined;
|
|
43
|
+
const configPath = positional ?? DEFAULT_CONFIG;
|
|
44
|
+
const flags = positional ? rest.slice(1) : rest;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (command === 'preview') return await run(loadConfig(configPath), { argv: flags, ...io });
|
|
48
|
+
|
|
49
|
+
if (command === 'validate') {
|
|
50
|
+
const config = loadConfig(configPath);
|
|
51
|
+
io.stdout.write(
|
|
52
|
+
`✓ ${configPath} is valid\n` +
|
|
53
|
+
` template ${config.template}\n` +
|
|
54
|
+
` languages ${config.language.available.join(', ')} (default: ${config.language.default})\n` +
|
|
55
|
+
` fields ${config.card.fields.join(', ') || '—'}\n` +
|
|
56
|
+
` menu ${config.menu.enabled ? config.menu.items.map((i) => (typeof i === 'string' ? i : 'custom')).join(', ') : 'off'}\n`,
|
|
57
|
+
);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (command === 'build') {
|
|
62
|
+
const { values } = parseArgs({ args: flags, options: { out: { type: 'string', default: 'dist' }, name: { type: 'string' } } });
|
|
63
|
+
const config = loadConfig(configPath);
|
|
64
|
+
const outDir = resolve(values.out);
|
|
65
|
+
const files = buildCard(config, { outDir, packageName: values.name ?? packageNameFor(configPath) });
|
|
66
|
+
io.stdout.write(`✓ Built ${files.length} files in ${values.out}/\n${files.map((f) => ` ${f}`).join('\n')}\n`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof ConfigError || error instanceof UnsafeOutputError || error.code?.startsWith('ERR_PARSE_ARGS')) {
|
|
71
|
+
io.stderr.write(`${error.message}\n`);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
io.stderr.write(`Unknown command "${command}".\n\n${HELP}\n`);
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { normalizeConfig, ConfigError } from './schema.js';
|
|
4
|
+
|
|
5
|
+
const MAX_CONFIG_BYTES = 64 * 1024;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads and validates a JSON config file. Only JSON is accepted on purpose:
|
|
9
|
+
* a config can never execute code.
|
|
10
|
+
*/
|
|
11
|
+
export function loadConfig(file) {
|
|
12
|
+
const path = file instanceof URL ? fileURLToPath(file) : file;
|
|
13
|
+
let source;
|
|
14
|
+
try {
|
|
15
|
+
source = readFileSync(path, 'utf8');
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new ConfigError([`cannot read ${path} (${error.code ?? error.message})`]);
|
|
18
|
+
}
|
|
19
|
+
if (Buffer.byteLength(source) > MAX_CONFIG_BYTES) throw new ConfigError([`${path} is larger than 64 KB`]);
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = JSON.parse(source);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw new ConfigError([`${path} is not valid JSON (${error.message})`]);
|
|
25
|
+
}
|
|
26
|
+
return normalizeConfig(raw);
|
|
27
|
+
}
|