@kntic/links 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KNTIC
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 ADDED
@@ -0,0 +1,149 @@
1
+ # @kntic/links
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
+ [![npm version](https://img.shields.io/npm/v/@kntic/links)](https://www.npmjs.com/package/@kntic/links)
5
+ [![Node >= 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
6
+
7
+ A CLI for building and deploying link-in-bio pages. No accounts, no tracking, no third-party servers. You own the HTML.
8
+
9
+ ---
10
+
11
+ ## Quick Start
12
+
13
+ Five commands from zero to a deployed page:
14
+
15
+ ```bash
16
+ # 1. Install
17
+ npm install -g @kntic/links
18
+
19
+ # 2. Scaffold a new project
20
+ links init my-page && cd my-page
21
+
22
+ # 3. Add some links
23
+ links add "GitHub" "https://github.com/you"
24
+ links add "Blog" "https://your-blog.dev"
25
+
26
+ # 4. Build a self-contained HTML file
27
+ links deploy --self
28
+
29
+ # 5. Open it
30
+ open dist/index.html
31
+ ```
32
+
33
+ That's it. `dist/index.html` is a single file — inline CSS, base64 avatar, no external requests. Drop it on any static host.
34
+
35
+ ---
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ npm install -g @kntic/links
41
+ ```
42
+
43
+ Requires Node.js 18 or later.
44
+
45
+ ---
46
+
47
+ ## Command Reference
48
+
49
+ | Command | Description |
50
+ |---------|-------------|
51
+ | `links init [directory]` | Scaffold a new `links.yaml` project. `--force` to overwrite. |
52
+ | `links add <label> <url>` | Add a link. `--from`/`--until` for scheduling, `--update` to replace. |
53
+ | `links remove <label>` | Remove a link by label (case-insensitive). |
54
+ | `links list` | List all links. `--json` for machine-readable output. |
55
+ | `links deploy --self` | Generate a self-contained HTML page to `dist/`. `--out <dir>` to change output, `--open` to open in browser. |
56
+ | `links theme list` | List available themes. |
57
+ | `links theme set <name>` | Set the active theme in `links.yaml`. |
58
+ | `links qr` | Generate a QR code for your page URL. |
59
+ | `links config` | Open `links.yaml` in your `$EDITOR`. |
60
+ | `links open` | Open your deployed page URL in the browser. `--local` for `dist/index.html`. |
61
+ | `links status` | Show project config summary. |
62
+
63
+ ---
64
+
65
+ ## `links.yaml` Schema
66
+
67
+ ```yaml
68
+ # Required
69
+ name: "Your Name"
70
+ url: "https://your-site.com"
71
+
72
+ # Optional
73
+ bio: "A short bio line."
74
+ avatar: "avatar.png" # Path to image — gets base64-inlined on build
75
+ theme: "minimal-dark" # Any theme name from src/themes/
76
+
77
+ # Links
78
+ links:
79
+ - label: "GitHub"
80
+ url: "https://github.com/you"
81
+ - label: "Blog"
82
+ url: "https://your-blog.dev"
83
+ scheduled_from: "2026-04-01" # Optional: link becomes visible on this date
84
+ scheduled_until: "2026-12-31" # Optional: link hidden after this date
85
+ ```
86
+
87
+ ### Scheduling
88
+
89
+ Links support `scheduled_from` and `scheduled_until` fields (ISO 8601 date strings). The generator filters links at build time — only active links appear in the output HTML.
90
+
91
+ ---
92
+
93
+ ## Themes
94
+
95
+ Five built-in themes ship with Links:
96
+
97
+ | Theme | Description |
98
+ |-------|-------------|
99
+ | `minimal-dark` | Default. Muted violet accent on dark background. |
100
+ | `minimal-light` | Clean off-white with indigo accent. |
101
+ | `terminal` | Green-on-black with cursor blink and scanlines. |
102
+ | `glass` | Glassmorphism with backdrop-filter blur and purple/blue gradient. |
103
+ | `developer` | IDE-inspired aesthetic with KNTIC orange and left border bar. |
104
+
105
+ ```bash
106
+ # List themes
107
+ links theme list
108
+
109
+ # Switch theme
110
+ links theme set terminal
111
+
112
+ # Rebuild
113
+ links deploy --self
114
+ ```
115
+
116
+ ### Custom Themes
117
+
118
+ Themes are plain CSS files. Copy any built-in theme and override the 17 CSS custom properties. See [`src/themes/README.md`](src/themes/README.md) for the full token contract.
119
+
120
+ > 📸 **Screenshots coming soon.**
121
+
122
+ ---
123
+
124
+ ## Self-Hosted by Default
125
+
126
+ `links deploy --self` generates a single HTML file with everything inlined:
127
+
128
+ - CSS is embedded in a `<style>` tag
129
+ - Avatar is base64-encoded into an `<img>` src
130
+ - Zero external requests — no fonts CDN, no analytics, no tracking
131
+
132
+ Upload `dist/index.html` anywhere: Nginx, S3, GitHub Pages, Netlify, a Raspberry Pi — it doesn't matter.
133
+
134
+ ---
135
+
136
+ ## Contributing
137
+
138
+ 1. Fork the repo
139
+ 2. Create a feature branch
140
+ 3. Make your changes (themes are a great first contribution)
141
+ 4. Submit a merge request
142
+
143
+ Please keep the zero-dependency-on-external-services philosophy. If it can be done with a single HTML file, it should be.
144
+
145
+ ---
146
+
147
+ ## License
148
+
149
+ [MIT](LICENSE) © 2026 KNTIC
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@kntic/links",
3
+ "version": "0.1.0",
4
+ "description": "CLI for building and deploying self-hosted link-in-bio pages. No accounts, no tracking — just HTML.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "links": "src/cli.js"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "LICENSE",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "keywords": [
19
+ "linktree",
20
+ "link-page",
21
+ "link-in-bio",
22
+ "cli",
23
+ "privacy",
24
+ "self-hosted",
25
+ "developer",
26
+ "static-site"
27
+ ],
28
+ "homepage": "https://kntic.ai",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://gitlab.kommune7.wien/kommune7/apps/links.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://gitlab.kommune7.wien/kommune7/apps/links/-/issues"
35
+ },
36
+ "scripts": {
37
+ "prepublishOnly": "node -e \"import('./src/config.js').then(() => console.log('prepublish check passed'))\""
38
+ },
39
+ "dependencies": {
40
+ "chalk": "^5.3.0",
41
+ "commander": "^12.1.0",
42
+ "js-yaml": "^4.1.0",
43
+ "open": "^10.1.0",
44
+ "qrcode": "^1.5.4"
45
+ }
46
+ }
package/src/.gitkeep ADDED
File without changes
package/src/cli.js ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createRequire } from 'node:module';
4
+ import { Command } from 'commander';
5
+
6
+ import { registerInit } from './commands/init.js';
7
+ import { registerAdd } from './commands/add.js';
8
+ import { registerRemove } from './commands/remove.js';
9
+ import { registerList } from './commands/list.js';
10
+ import { registerDeploy } from './commands/deploy.js';
11
+ import { registerTheme } from './commands/theme.js';
12
+ import { registerQr } from './commands/qr.js';
13
+ import { registerConfig } from './commands/config-cmd.js';
14
+ import { registerOpen } from './commands/open-cmd.js';
15
+ import { registerStatus } from './commands/status.js';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const { version } = require('../package.json');
19
+
20
+ const program = new Command();
21
+
22
+ program
23
+ .name('links')
24
+ .description('CLI tool for managing and deploying link-in-bio pages')
25
+ .version(version);
26
+
27
+ // Register all subcommands
28
+ registerInit(program);
29
+ registerAdd(program);
30
+ registerRemove(program);
31
+ registerList(program);
32
+ registerDeploy(program);
33
+ registerTheme(program);
34
+ registerQr(program);
35
+ registerConfig(program);
36
+ registerOpen(program);
37
+ registerStatus(program);
38
+
39
+ program.parse();
@@ -0,0 +1,83 @@
1
+ /**
2
+ * links add — Add a new link entry.
3
+ *
4
+ * Reads the nearest links.yaml, appends a new link, and writes it back
5
+ * atomically. Validates the URL and rejects duplicate labels.
6
+ */
7
+
8
+ import { findConfig, readConfig, writeConfig, validateUrl } from '../config.js';
9
+
10
+ export function registerAdd(program) {
11
+ program
12
+ .command('add <label> <url>')
13
+ .description('Add a new link')
14
+ .option('--icon <emoji>', 'Icon emoji or name for the link')
15
+ .option('--description <text>', 'Short description of the link')
16
+ .option('--from <date>', 'Scheduled start date (ISO 8601)')
17
+ .option('--until <date>', 'Scheduled end date (ISO 8601)')
18
+ .option('--update', 'Replace an existing link with the same label')
19
+ .action(async (label, url, opts) => {
20
+ // Validate URL
21
+ const urlCheck = validateUrl(url);
22
+ if (!urlCheck.valid) {
23
+ console.error(`Error: ${urlCheck.error}`);
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+
28
+ // Find and read config
29
+ let configPath;
30
+ let config;
31
+ try {
32
+ configPath = findConfig();
33
+ config = readConfig(configPath);
34
+ } catch (err) {
35
+ console.error(`Error: ${err.message}`);
36
+ process.exitCode = 1;
37
+ return;
38
+ }
39
+
40
+ // Ensure links array exists
41
+ if (!config.links) {
42
+ config.links = [];
43
+ }
44
+
45
+ // Check for duplicate label (case-insensitive)
46
+ const existingIndex = config.links.findIndex(
47
+ (l) => l.label.toLowerCase() === label.toLowerCase(),
48
+ );
49
+
50
+ if (existingIndex !== -1 && !opts.update) {
51
+ console.error(
52
+ `A link with label "${label}" already exists. Use --update to replace it.`,
53
+ );
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+
58
+ // Build link object
59
+ const link = { label, url };
60
+ if (opts.icon) link.icon = opts.icon;
61
+ if (opts.description) link.description = opts.description;
62
+ if (opts.from) link.scheduled_from = opts.from;
63
+ if (opts.until) link.scheduled_until = opts.until;
64
+
65
+ // Add or replace
66
+ if (existingIndex !== -1) {
67
+ config.links[existingIndex] = link;
68
+ } else {
69
+ config.links.push(link);
70
+ }
71
+
72
+ // Write back
73
+ try {
74
+ writeConfig(configPath, config);
75
+ } catch (err) {
76
+ console.error(`Error: could not write links.yaml — ${err.message}`);
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+
81
+ console.log(`✓ Added: ${label} → ${url}`);
82
+ });
83
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * links config — Open links.yaml in the user's preferred editor.
3
+ */
4
+
5
+ import { spawnSync } from 'node:child_process';
6
+ import { findConfig } from '../config.js';
7
+
8
+ export function registerConfig(program) {
9
+ program
10
+ .command('config')
11
+ .description('Open links.yaml in your default editor')
12
+ .action(() => {
13
+ let configPath;
14
+ try {
15
+ configPath = findConfig();
16
+ } catch {
17
+ console.error('No links.yaml found. Run: links init');
18
+ process.exit(1);
19
+ }
20
+
21
+ const editor =
22
+ process.env.EDITOR ||
23
+ process.env.VISUAL ||
24
+ 'nano';
25
+
26
+ const result = spawnSync(editor, [configPath], {
27
+ stdio: 'inherit',
28
+ });
29
+
30
+ if (result.error) {
31
+ // If nano wasn't found, try vi as last resort
32
+ if (result.error.code === 'ENOENT' && editor === 'nano') {
33
+ const fallback = spawnSync('vi', [configPath], {
34
+ stdio: 'inherit',
35
+ });
36
+ if (fallback.error) {
37
+ console.error(`Could not open editor. Set $EDITOR and try again.`);
38
+ process.exit(1);
39
+ }
40
+ process.exit(fallback.status ?? 0);
41
+ }
42
+
43
+ console.error(`Could not open editor "${editor}": ${result.error.message}`);
44
+ process.exit(1);
45
+ }
46
+
47
+ process.exit(result.status ?? 0);
48
+ });
49
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * links deploy --self — Build a self-contained static HTML page.
3
+ */
4
+
5
+ import { mkdirSync, writeFileSync, copyFileSync, existsSync, statSync } from 'node:fs';
6
+ import { resolve, dirname, basename, join } from 'node:path';
7
+ import chalk from 'chalk';
8
+ import { findConfig, readConfig } from '../config.js';
9
+ import { generatePage } from '../generator.js';
10
+
11
+ export function registerDeploy(program) {
12
+ program
13
+ .command('deploy')
14
+ .description('Build and deploy the link page')
15
+ .option('--self', 'Generate a self-contained static HTML page')
16
+ .option('--out <dir>', 'Output directory', './dist')
17
+ .option('--open', 'Open the generated page in a browser after build')
18
+ .action(async (opts) => {
19
+ if (!opts.self) {
20
+ console.log(
21
+ chalk.yellow('Usage: links deploy --self [--out <dir>] [--open]'),
22
+ );
23
+ console.log('Other deploy targets are not yet implemented.');
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+
28
+ // 1. Locate and read config
29
+ let configPath;
30
+ try {
31
+ configPath = findConfig();
32
+ } catch (err) {
33
+ console.error(chalk.red(`✗ ${err.message}`));
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+
38
+ let config;
39
+ try {
40
+ config = readConfig(configPath);
41
+ } catch (err) {
42
+ console.error(chalk.red(`✗ ${err.message}`));
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+
47
+ const configDir = dirname(configPath);
48
+
49
+ // 2. Generate HTML
50
+ let html;
51
+ try {
52
+ html = generatePage(config, { configDir });
53
+ } catch (err) {
54
+ console.error(chalk.red(`✗ Build failed: ${err.message}`));
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+
59
+ // 3. Write output
60
+ const outDir = resolve(opts.out);
61
+ mkdirSync(outDir, { recursive: true });
62
+
63
+ const outFile = join(outDir, 'index.html');
64
+ writeFileSync(outFile, html, 'utf8');
65
+
66
+ // 4. Copy avatar as fallback asset (if it's a local file)
67
+ if (config.avatar && config.avatar.trim().length > 0) {
68
+ const avatarSrc = resolve(configDir, config.avatar);
69
+ if (existsSync(avatarSrc)) {
70
+ try {
71
+ const avatarDest = join(outDir, basename(config.avatar));
72
+ copyFileSync(avatarSrc, avatarDest);
73
+ } catch {
74
+ // non-fatal — avatar is already inlined
75
+ }
76
+ }
77
+ }
78
+
79
+ // 5. Summary
80
+ const size = statSync(outFile).size;
81
+ const sizeStr = size < 1024
82
+ ? `${size} B`
83
+ : `${(size / 1024).toFixed(1)} KB`;
84
+
85
+ console.log(chalk.green(`✓ Built to ${outFile}`) + chalk.dim(` (${sizeStr})`));
86
+
87
+ // 6. Optionally open in browser
88
+ if (opts.open) {
89
+ try {
90
+ const open = (await import('open')).default;
91
+ await open(outFile);
92
+ } catch {
93
+ console.log(chalk.yellow('⚠ Could not open browser automatically.'));
94
+ }
95
+ }
96
+ });
97
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * links init — Scaffold a new link page project.
3
+ *
4
+ * Creates a links.yaml with sensible defaults and prints a getting-started
5
+ * message. Safe to run — never overwrites an existing links.yaml unless
6
+ * --force is passed.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
10
+ import { resolve, join } from 'node:path';
11
+ import { userInfo } from 'node:os';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Template
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function buildTemplate(name) {
18
+ return `# links.yaml — your link-in-bio page configuration
19
+ # Docs: links help
20
+
21
+ name: "${name}"
22
+
23
+ # Short bio displayed under your name
24
+ bio: ""
25
+
26
+ # Avatar image URL (uncomment and set your image URL)
27
+ # avatar: "https://example.com/avatar.png"
28
+
29
+ # Theme for the generated page
30
+ theme: minimal-dark
31
+
32
+ # Your page URL once deployed (uncomment and set your domain)
33
+ # domain: "https://links.example.com"
34
+
35
+ # Links — add as many as you like
36
+ links:
37
+ - label: "My Website"
38
+ url: "https://example.com"
39
+ # icon: "globe"
40
+ # description: "My personal website"
41
+
42
+ - label: "Twitter"
43
+ url: "https://twitter.com/example"
44
+ # icon: "twitter"
45
+ `;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Command
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export function registerInit(program) {
53
+ program
54
+ .command('init [directory]')
55
+ .description('Initialise a new links project')
56
+ .option('--force', 'Overwrite existing links.yaml', false)
57
+ .action(async (directory, opts) => {
58
+ const targetDir = directory ? resolve(directory) : process.cwd();
59
+ const filePath = join(targetDir, 'links.yaml');
60
+
61
+ // If a directory argument was provided, ensure it exists
62
+ if (directory && !existsSync(targetDir)) {
63
+ try {
64
+ mkdirSync(targetDir, { recursive: true });
65
+ } catch (err) {
66
+ console.error(`Error: could not create directory "${targetDir}" — ${err.message}`);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ }
71
+
72
+ // Safety check — never overwrite without --force
73
+ if (existsSync(filePath) && !opts.force) {
74
+ console.error('links.yaml already exists. Use --force to overwrite.');
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+
79
+ // Resolve a sensible default name
80
+ let defaultName = 'My Links';
81
+ try {
82
+ const info = userInfo();
83
+ if (info.username) {
84
+ defaultName = info.username;
85
+ }
86
+ } catch {
87
+ // userInfo can throw on some platforms — fall back silently
88
+ }
89
+
90
+ const content = buildTemplate(defaultName);
91
+
92
+ try {
93
+ writeFileSync(filePath, content, 'utf8');
94
+ } catch (err) {
95
+ console.error(`Error: could not write links.yaml — ${err.message}`);
96
+ process.exitCode = 1;
97
+ return;
98
+ }
99
+
100
+ // Success message
101
+ const relPath = directory ? join(directory, 'links.yaml') : 'links.yaml';
102
+ console.log(`✔ Created ${relPath}\n`);
103
+ console.log('Next steps:');
104
+ console.log(' links add <label> <url> Add a link');
105
+ console.log(' links deploy --self --out ./dist Build your page');
106
+ });
107
+ }