@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 +21 -0
- package/README.md +149 -0
- package/package.json +46 -0
- package/src/.gitkeep +0 -0
- package/src/cli.js +39 -0
- package/src/commands/add.js +83 -0
- package/src/commands/config-cmd.js +49 -0
- package/src/commands/deploy.js +97 -0
- package/src/commands/init.js +107 -0
- package/src/commands/list.js +108 -0
- package/src/commands/open-cmd.js +64 -0
- package/src/commands/qr.js +99 -0
- package/src/commands/remove.js +55 -0
- package/src/commands/status.js +64 -0
- package/src/commands/theme.js +86 -0
- package/src/config.js +221 -0
- package/src/generator.js +185 -0
- package/src/themes/.gitkeep +0 -0
- package/src/themes/README.md +110 -0
- package/src/themes/developer.css +210 -0
- package/src/themes/glass.css +215 -0
- package/src/themes/loader.js +61 -0
- package/src/themes/minimal-dark.css +172 -0
- package/src/themes/minimal-light.css +176 -0
- package/src/themes/terminal.css +229 -0
- package/src/utils.js +80 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* links list — List all configured links.
|
|
3
|
+
*
|
|
4
|
+
* Reads the nearest links.yaml and prints a formatted table of links.
|
|
5
|
+
* Supports --json for machine-readable output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { findConfig, readConfig } from '../config.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a schedule status string for a link.
|
|
13
|
+
*
|
|
14
|
+
* Format:
|
|
15
|
+
* ⏰ from 2026-04-01 (only scheduled_from)
|
|
16
|
+
* ⏰ until 2026-12-31 (only scheduled_until)
|
|
17
|
+
* ⏰ 2026-04-01 → 2026-12-31 (both)
|
|
18
|
+
* '' (always active)
|
|
19
|
+
*/
|
|
20
|
+
function scheduleStatus(link) {
|
|
21
|
+
const hasFrom = !!link.scheduled_from;
|
|
22
|
+
const hasUntil = !!link.scheduled_until;
|
|
23
|
+
|
|
24
|
+
if (hasFrom && hasUntil) {
|
|
25
|
+
return `⏰ ${link.scheduled_from} → ${link.scheduled_until}`;
|
|
26
|
+
}
|
|
27
|
+
if (hasFrom) {
|
|
28
|
+
return `⏰ from ${link.scheduled_from}`;
|
|
29
|
+
}
|
|
30
|
+
if (hasUntil) {
|
|
31
|
+
return `⏰ until ${link.scheduled_until}`;
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pad a string to a given width.
|
|
38
|
+
*/
|
|
39
|
+
function pad(str, width) {
|
|
40
|
+
const len = str.length;
|
|
41
|
+
return len >= width ? str : str + ' '.repeat(width - len);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function registerList(program) {
|
|
45
|
+
program
|
|
46
|
+
.command('list')
|
|
47
|
+
.description('List all links')
|
|
48
|
+
.option('--json', 'Output links as a JSON array')
|
|
49
|
+
.action(async (opts) => {
|
|
50
|
+
// Find and read config
|
|
51
|
+
let config;
|
|
52
|
+
try {
|
|
53
|
+
const configPath = findConfig();
|
|
54
|
+
config = readConfig(configPath);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error(`Error: ${err.message}`);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const links = config.links || [];
|
|
62
|
+
|
|
63
|
+
if (links.length === 0) {
|
|
64
|
+
console.log('No links yet. Use: links add <label> <url>');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// JSON output
|
|
69
|
+
if (opts.json) {
|
|
70
|
+
console.log(JSON.stringify(links, null, 2));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Calculate column widths
|
|
75
|
+
const idxWidth = String(links.length).length;
|
|
76
|
+
let labelWidth = 5; // minimum "LABEL"
|
|
77
|
+
let urlWidth = 3; // minimum "URL"
|
|
78
|
+
let iconWidth = 0;
|
|
79
|
+
|
|
80
|
+
const hasIcons = links.some((l) => l.icon);
|
|
81
|
+
|
|
82
|
+
for (const link of links) {
|
|
83
|
+
if (link.label.length > labelWidth) labelWidth = link.label.length;
|
|
84
|
+
if (link.url.length > urlWidth) urlWidth = link.url.length;
|
|
85
|
+
if (link.icon && link.icon.length > iconWidth) iconWidth = link.icon.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Print header
|
|
89
|
+
let header = `${pad('#', idxWidth)} ${pad('LABEL', labelWidth)} ${pad('URL', urlWidth)}`;
|
|
90
|
+
if (hasIcons) header += ` ${pad('ICON', iconWidth)}`;
|
|
91
|
+
header += ' SCHEDULE';
|
|
92
|
+
console.log(chalk.bold(header));
|
|
93
|
+
console.log(chalk.gray('─'.repeat(header.length)));
|
|
94
|
+
|
|
95
|
+
// Print rows
|
|
96
|
+
links.forEach((link, i) => {
|
|
97
|
+
const idx = pad(String(i + 1), idxWidth);
|
|
98
|
+
const label = pad(link.label, labelWidth);
|
|
99
|
+
const url = pad(link.url, urlWidth);
|
|
100
|
+
const icon = hasIcons ? ` ${pad(link.icon || '', iconWidth)}` : '';
|
|
101
|
+
const sched = scheduleStatus(link);
|
|
102
|
+
|
|
103
|
+
let line = `${chalk.gray(idx)} ${chalk.cyan(label)} ${url}${icon}`;
|
|
104
|
+
if (sched) line += ` ${chalk.yellow(sched)}`;
|
|
105
|
+
console.log(line);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* links open — Open the deployed page in a browser.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import open from 'open';
|
|
8
|
+
import { findConfig, readConfig } from '../config.js';
|
|
9
|
+
|
|
10
|
+
export function registerOpen(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('open')
|
|
13
|
+
.description('Open the deployed page in a browser')
|
|
14
|
+
.option('--local', 'Open the local dist/index.html instead of the live domain')
|
|
15
|
+
.action(async (options) => {
|
|
16
|
+
let configPath;
|
|
17
|
+
try {
|
|
18
|
+
configPath = findConfig();
|
|
19
|
+
} catch {
|
|
20
|
+
if (options.local) {
|
|
21
|
+
// Allow --local even without config
|
|
22
|
+
const localPath = resolve('dist', 'index.html');
|
|
23
|
+
if (!existsSync(localPath)) {
|
|
24
|
+
console.error(`Local file not found: ${localPath}`);
|
|
25
|
+
console.error('Run: links deploy --self');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
await open(`file://${localPath}`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
console.error('No links.yaml found. Run: links init');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (options.local) {
|
|
36
|
+
const config = readConfig(configPath);
|
|
37
|
+
const outDir = resolve(configPath, '..', 'dist');
|
|
38
|
+
const localPath = resolve(outDir, 'index.html');
|
|
39
|
+
|
|
40
|
+
if (!existsSync(localPath)) {
|
|
41
|
+
console.error(`Local file not found: ${localPath}`);
|
|
42
|
+
console.error('Run: links deploy --self');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await open(`file://${localPath}`);
|
|
47
|
+
console.log(`Opened: file://${localPath}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Default: open domain
|
|
52
|
+
const config = readConfig(configPath);
|
|
53
|
+
const domain = config.domain?.trim();
|
|
54
|
+
|
|
55
|
+
if (!domain) {
|
|
56
|
+
console.error('No domain set. Use --local to preview, or set domain in links.yaml');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const url = domain.startsWith('http') ? domain : `https://${domain}`;
|
|
61
|
+
await open(url);
|
|
62
|
+
console.log(`Opened: ${url}`);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* links qr — Generate a QR code for the link page URL.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* links qr [url] [options]
|
|
6
|
+
*
|
|
7
|
+
* If url is omitted, uses config.domain from links.yaml.
|
|
8
|
+
* --out <file> Save QR code as PNG to specified file path.
|
|
9
|
+
* --link <label> Generate QR for a specific link's URL instead of the page URL.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import QRCode from 'qrcode';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { findConfig, readConfig } from '../config.js';
|
|
15
|
+
|
|
16
|
+
export function registerQr(program) {
|
|
17
|
+
program
|
|
18
|
+
.command('qr')
|
|
19
|
+
.description('Generate a QR code for the link page URL')
|
|
20
|
+
.argument('[url]', 'URL to encode (defaults to config.domain)')
|
|
21
|
+
.option('--out <file>', 'save QR code as PNG to specified file path')
|
|
22
|
+
.option('--link <label>', 'generate QR for a specific link by label')
|
|
23
|
+
.action(async (urlArg, options) => {
|
|
24
|
+
let url = urlArg;
|
|
25
|
+
|
|
26
|
+
// If --link is provided, look up the link's URL from config
|
|
27
|
+
if (options.link) {
|
|
28
|
+
let config;
|
|
29
|
+
try {
|
|
30
|
+
const configPath = findConfig();
|
|
31
|
+
config = readConfig(configPath);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(chalk.red(err.message));
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const links = config.links || [];
|
|
39
|
+
const match = links.find(
|
|
40
|
+
(l) => l.label.toLowerCase() === options.link.toLowerCase(),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (!match) {
|
|
44
|
+
console.error(
|
|
45
|
+
chalk.red(`Link with label "${options.link}" not found in links.yaml.`),
|
|
46
|
+
);
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
url = match.url;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// If no URL yet, fall back to config.domain
|
|
55
|
+
if (!url) {
|
|
56
|
+
try {
|
|
57
|
+
const configPath = findConfig();
|
|
58
|
+
const config = readConfig(configPath);
|
|
59
|
+
|
|
60
|
+
if (config.domain && config.domain.trim().length > 0) {
|
|
61
|
+
url = config.domain.trim();
|
|
62
|
+
// Ensure it has a protocol
|
|
63
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
64
|
+
url = `https://${url}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// config not found — will fall through to error below
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!url) {
|
|
73
|
+
console.error(
|
|
74
|
+
chalk.red(
|
|
75
|
+
'No URL specified and no domain set in links.yaml. Run: links qr <url>',
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
if (options.out) {
|
|
84
|
+
// Save as PNG
|
|
85
|
+
await QRCode.toFile(options.out, url);
|
|
86
|
+
console.log(chalk.green(`QR code saved to ${options.out}`));
|
|
87
|
+
console.log(`URL: ${url}`);
|
|
88
|
+
} else {
|
|
89
|
+
// Render to terminal
|
|
90
|
+
const qrString = await QRCode.toString(url, { type: 'terminal' });
|
|
91
|
+
console.log(qrString);
|
|
92
|
+
console.log(`URL: ${url}`);
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error(chalk.red(`Failed to generate QR code: ${err.message}`));
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* links remove — Remove an existing link entry.
|
|
3
|
+
*
|
|
4
|
+
* Finds a link by label (case-insensitive), removes it from the config,
|
|
5
|
+
* and writes the updated file atomically.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { findConfig, readConfig, writeConfig } from '../config.js';
|
|
9
|
+
|
|
10
|
+
export function registerRemove(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('remove <label>')
|
|
13
|
+
.description('Remove a link')
|
|
14
|
+
.action(async (label) => {
|
|
15
|
+
// Find and read config
|
|
16
|
+
let configPath;
|
|
17
|
+
let config;
|
|
18
|
+
try {
|
|
19
|
+
configPath = findConfig();
|
|
20
|
+
config = readConfig(configPath);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error(`Error: ${err.message}`);
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const links = config.links || [];
|
|
28
|
+
|
|
29
|
+
// Find link by label (case-insensitive)
|
|
30
|
+
const index = links.findIndex(
|
|
31
|
+
(l) => l.label.toLowerCase() === label.toLowerCase(),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (index === -1) {
|
|
35
|
+
console.error(`No link with label "${label}" found.`);
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Remove the link
|
|
41
|
+
const removed = links.splice(index, 1)[0];
|
|
42
|
+
config.links = links;
|
|
43
|
+
|
|
44
|
+
// Write back
|
|
45
|
+
try {
|
|
46
|
+
writeConfig(configPath, config);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error(`Error: could not write links.yaml — ${err.message}`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(`✓ Removed: ${removed.label}`);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* links status — Show current project status summary.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { statSync } from 'node:fs';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { findConfig, readConfig } from '../config.js';
|
|
8
|
+
import { isLinkActive } from '../utils.js';
|
|
9
|
+
|
|
10
|
+
export function registerStatus(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('status')
|
|
13
|
+
.description('Show current project status')
|
|
14
|
+
.action(() => {
|
|
15
|
+
let configPath;
|
|
16
|
+
try {
|
|
17
|
+
configPath = findConfig();
|
|
18
|
+
} catch {
|
|
19
|
+
console.error('No links.yaml found. Run: links init');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const config = readConfig(configPath);
|
|
24
|
+
const links = config.links ?? [];
|
|
25
|
+
|
|
26
|
+
// Count scheduled (have scheduling fields) vs active
|
|
27
|
+
const scheduledLinks = links.filter(
|
|
28
|
+
(l) => l.scheduled_from || l.scheduled_until,
|
|
29
|
+
);
|
|
30
|
+
const activeLinks = links.filter((l) => isLinkActive(l));
|
|
31
|
+
|
|
32
|
+
// Last modified
|
|
33
|
+
let lastModified = 'unknown';
|
|
34
|
+
try {
|
|
35
|
+
const stat = statSync(configPath);
|
|
36
|
+
lastModified = stat.mtime.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
|
|
37
|
+
} catch {
|
|
38
|
+
// ignore
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Bio truncation
|
|
42
|
+
const bio = config.bio?.trim() || '';
|
|
43
|
+
const bioDisplay = bio.length > 60 ? bio.slice(0, 57) + '...' : bio || chalk.dim('not set');
|
|
44
|
+
|
|
45
|
+
// Domain
|
|
46
|
+
const domain = config.domain?.trim() || '';
|
|
47
|
+
|
|
48
|
+
// Output
|
|
49
|
+
console.log();
|
|
50
|
+
console.log(chalk.bold(' Links Project Status'));
|
|
51
|
+
console.log(chalk.dim(' ─────────────────────────────'));
|
|
52
|
+
console.log(` ${chalk.cyan('Name:')} ${config.name}`);
|
|
53
|
+
console.log(` ${chalk.cyan('Bio:')} ${bioDisplay}`);
|
|
54
|
+
console.log(` ${chalk.cyan('Theme:')} ${config.theme || 'minimal-dark'}`);
|
|
55
|
+
console.log(` ${chalk.cyan('Domain:')} ${domain || chalk.dim('not set')}`);
|
|
56
|
+
console.log(` ${chalk.cyan('Avatar:')} ${config.avatar ? chalk.green('set') : chalk.dim('not set')}`);
|
|
57
|
+
console.log(` ${chalk.cyan('Total links:')} ${links.length}`);
|
|
58
|
+
console.log(` ${chalk.cyan('Active links:')} ${activeLinks.length}`);
|
|
59
|
+
console.log(` ${chalk.cyan('Scheduled:')} ${scheduledLinks.length}`);
|
|
60
|
+
console.log(` ${chalk.cyan('Last modified:')} ${lastModified}`);
|
|
61
|
+
console.log(` ${chalk.cyan('Config:')} ${configPath}`);
|
|
62
|
+
console.log();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* links theme — Switch and list themes.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* links theme list — list available themes (default)
|
|
6
|
+
* links theme set <name> — set the active theme in links.yaml
|
|
7
|
+
* links theme — alias for `links theme list`
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { findConfig, readConfig, writeConfig } from '../config.js';
|
|
11
|
+
import { listThemes } from '../themes/loader.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Print the list of available themes, marking the active one with ✓.
|
|
15
|
+
* @param {string} activeTheme — currently configured theme name
|
|
16
|
+
*/
|
|
17
|
+
function printThemeList(activeTheme) {
|
|
18
|
+
const themes = listThemes();
|
|
19
|
+
|
|
20
|
+
if (themes.length === 0) {
|
|
21
|
+
console.log('No themes installed.');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const name of themes) {
|
|
26
|
+
const marker = name === activeTheme ? ' ✓' : '';
|
|
27
|
+
console.log(` ${name}${marker}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registerTheme(program) {
|
|
32
|
+
const themeCmd = program
|
|
33
|
+
.command('theme')
|
|
34
|
+
.description('Switch and list themes')
|
|
35
|
+
.action(() => {
|
|
36
|
+
// `links theme` with no subcommand → default to list behaviour
|
|
37
|
+
try {
|
|
38
|
+
const configPath = findConfig();
|
|
39
|
+
const config = readConfig(configPath);
|
|
40
|
+
printThemeList(config.theme || 'minimal-dark');
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(err.message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// --- links theme list ---------------------------------------------------
|
|
48
|
+
themeCmd
|
|
49
|
+
.command('list')
|
|
50
|
+
.description('List available themes')
|
|
51
|
+
.action(() => {
|
|
52
|
+
try {
|
|
53
|
+
const configPath = findConfig();
|
|
54
|
+
const config = readConfig(configPath);
|
|
55
|
+
printThemeList(config.theme || 'minimal-dark');
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(err.message);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- links theme set <name> ---------------------------------------------
|
|
63
|
+
themeCmd
|
|
64
|
+
.command('set <name>')
|
|
65
|
+
.description('Set the active theme')
|
|
66
|
+
.action((name) => {
|
|
67
|
+
try {
|
|
68
|
+
const available = listThemes();
|
|
69
|
+
|
|
70
|
+
if (!available.includes(name)) {
|
|
71
|
+
console.error(`Unknown theme: ${name}. Run: links theme list`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const configPath = findConfig();
|
|
76
|
+
const config = readConfig(configPath);
|
|
77
|
+
config.theme = name;
|
|
78
|
+
writeConfig(configPath, config);
|
|
79
|
+
|
|
80
|
+
console.log(`✓ Theme set to: ${name}. Rebuild with: links deploy --self`);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(err.message);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { resolve, dirname, join } from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Schema defaults
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CONFIG = {
|
|
10
|
+
name: 'My Links',
|
|
11
|
+
bio: '',
|
|
12
|
+
avatar: '',
|
|
13
|
+
theme: 'minimal-dark',
|
|
14
|
+
domain: '',
|
|
15
|
+
links: [],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Validation helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate that a URL string starts with http:// or https://.
|
|
24
|
+
* @param {string} url
|
|
25
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
26
|
+
*/
|
|
27
|
+
export function validateUrl(url) {
|
|
28
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
29
|
+
return { valid: false, error: 'url must be a non-empty string' };
|
|
30
|
+
}
|
|
31
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
32
|
+
return { valid: false, error: `url must start with http:// or https:// — got "${url}"` };
|
|
33
|
+
}
|
|
34
|
+
return { valid: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate that a string is a valid ISO 8601 date (YYYY-MM-DD or full ISO 8601).
|
|
39
|
+
* @param {string} str
|
|
40
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
41
|
+
*/
|
|
42
|
+
export function validateDate(str) {
|
|
43
|
+
if (typeof str !== 'string' || str.length === 0) {
|
|
44
|
+
return { valid: false, error: 'date must be a non-empty string' };
|
|
45
|
+
}
|
|
46
|
+
if (Number.isNaN(Date.parse(str))) {
|
|
47
|
+
return { valid: false, error: `not a valid ISO 8601 date — got "${str}"` };
|
|
48
|
+
}
|
|
49
|
+
return { valid: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate a full config object against the links.yaml schema.
|
|
54
|
+
* Collects all errors instead of failing on the first one.
|
|
55
|
+
* @param {object} config
|
|
56
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
57
|
+
*/
|
|
58
|
+
export function validateConfig(config) {
|
|
59
|
+
const errors = [];
|
|
60
|
+
|
|
61
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
62
|
+
return { valid: false, errors: ['links.yaml: config must be a YAML mapping (object)'] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- top-level required fields ---
|
|
66
|
+
if (typeof config.name !== 'string' || config.name.trim().length === 0) {
|
|
67
|
+
errors.push("links.yaml: missing required field 'name' (must be a non-empty string)");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- top-level optional string fields ---
|
|
71
|
+
for (const field of ['bio', 'avatar', 'domain']) {
|
|
72
|
+
if (config[field] !== undefined && config[field] !== null && typeof config[field] !== 'string') {
|
|
73
|
+
errors.push(`links.yaml: field '${field}' must be a string`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- theme ---
|
|
78
|
+
if (config.theme !== undefined && config.theme !== null) {
|
|
79
|
+
if (typeof config.theme !== 'string' || config.theme.trim().length === 0) {
|
|
80
|
+
errors.push("links.yaml: field 'theme' must be a non-empty string");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- links array ---
|
|
85
|
+
if (config.links !== undefined && config.links !== null) {
|
|
86
|
+
if (!Array.isArray(config.links)) {
|
|
87
|
+
errors.push("links.yaml: field 'links' must be an array");
|
|
88
|
+
} else {
|
|
89
|
+
config.links.forEach((link, i) => {
|
|
90
|
+
const prefix = `links.yaml: links[${i}]`;
|
|
91
|
+
|
|
92
|
+
if (link === null || typeof link !== 'object' || Array.isArray(link)) {
|
|
93
|
+
errors.push(`${prefix} must be an object`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// required
|
|
98
|
+
if (typeof link.label !== 'string' || link.label.trim().length === 0) {
|
|
99
|
+
errors.push(`${prefix}: missing required field 'label' (must be a non-empty string)`);
|
|
100
|
+
}
|
|
101
|
+
if (typeof link.url !== 'string' || link.url.trim().length === 0) {
|
|
102
|
+
errors.push(`${prefix}: missing required field 'url' (must be a non-empty string)`);
|
|
103
|
+
} else {
|
|
104
|
+
const urlCheck = validateUrl(link.url);
|
|
105
|
+
if (!urlCheck.valid) {
|
|
106
|
+
errors.push(`${prefix}: ${urlCheck.error}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// optional strings
|
|
111
|
+
for (const field of ['icon', 'description']) {
|
|
112
|
+
if (link[field] !== undefined && link[field] !== null && typeof link[field] !== 'string') {
|
|
113
|
+
errors.push(`${prefix}: field '${field}' must be a string`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// optional ISO date strings
|
|
118
|
+
for (const field of ['scheduled_from', 'scheduled_until']) {
|
|
119
|
+
if (link[field] !== undefined && link[field] !== null) {
|
|
120
|
+
if (typeof link[field] !== 'string') {
|
|
121
|
+
errors.push(`${prefix}: field '${field}' must be an ISO 8601 date string`);
|
|
122
|
+
} else {
|
|
123
|
+
const dateCheck = validateDate(link[field]);
|
|
124
|
+
if (!dateCheck.valid) {
|
|
125
|
+
errors.push(`${prefix}: field '${field}' — ${dateCheck.error}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { valid: errors.length === 0, errors };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Config file discovery
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Walk up from `startDir` (defaults to cwd) looking for links.yaml.
|
|
143
|
+
* @param {string} [startDir=process.cwd()]
|
|
144
|
+
* @returns {string} Absolute path to the nearest links.yaml.
|
|
145
|
+
* @throws {Error} If no links.yaml is found before reaching the filesystem root.
|
|
146
|
+
*/
|
|
147
|
+
export function findConfig(startDir = process.cwd()) {
|
|
148
|
+
let dir = resolve(startDir);
|
|
149
|
+
|
|
150
|
+
// eslint-disable-next-line no-constant-condition
|
|
151
|
+
while (true) {
|
|
152
|
+
const candidate = join(dir, 'links.yaml');
|
|
153
|
+
try {
|
|
154
|
+
readFileSync(candidate); // existence check
|
|
155
|
+
return candidate;
|
|
156
|
+
} catch {
|
|
157
|
+
// not found — go up
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const parent = dirname(dir);
|
|
161
|
+
if (parent === dir) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
'links.yaml not found. Run "links init" to create a new project.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
dir = parent;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Read / Write
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Read, parse, and validate a links.yaml file.
|
|
176
|
+
* @param {string} filePath — absolute or relative path to links.yaml
|
|
177
|
+
* @returns {object} Parsed and validated config object.
|
|
178
|
+
* @throws {Error} On read failure, YAML parse failure, or validation failure.
|
|
179
|
+
*/
|
|
180
|
+
export function readConfig(filePath) {
|
|
181
|
+
let raw;
|
|
182
|
+
try {
|
|
183
|
+
raw = readFileSync(resolve(filePath), 'utf8');
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw new Error(`links.yaml: unable to read file "${filePath}" — ${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let config;
|
|
189
|
+
try {
|
|
190
|
+
config = yaml.load(raw);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
throw new Error(`links.yaml: YAML parse error — ${err.message}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { valid, errors } = validateConfig(config);
|
|
196
|
+
if (!valid) {
|
|
197
|
+
throw new Error(errors.join('\n'));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return config;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Serialize a config object to YAML and write it atomically.
|
|
205
|
+
* Writes to a temporary file first, then renames into place.
|
|
206
|
+
* @param {string} filePath — absolute or relative path to links.yaml
|
|
207
|
+
* @param {object} config — config object to persist
|
|
208
|
+
*/
|
|
209
|
+
export function writeConfig(filePath, config) {
|
|
210
|
+
const dest = resolve(filePath);
|
|
211
|
+
const tmp = `${dest}.tmp`;
|
|
212
|
+
|
|
213
|
+
const content = yaml.dump(config, {
|
|
214
|
+
lineWidth: 120,
|
|
215
|
+
noRefs: true,
|
|
216
|
+
sortKeys: false,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
writeFileSync(tmp, content, 'utf8');
|
|
220
|
+
renameSync(tmp, dest);
|
|
221
|
+
}
|