@kntic/links 0.1.0 → 0.3.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/package.json +1 -1
- package/src/cli.js +4 -0
- package/src/commands/add.js +19 -1
- package/src/commands/edit.js +167 -0
- package/src/commands/reorder.js +263 -0
- package/src/config.js +7 -15
- package/src/generator.js +35 -24
- package/src/utils.js +4 -20
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -13,6 +13,8 @@ import { registerQr } from './commands/qr.js';
|
|
|
13
13
|
import { registerConfig } from './commands/config-cmd.js';
|
|
14
14
|
import { registerOpen } from './commands/open-cmd.js';
|
|
15
15
|
import { registerStatus } from './commands/status.js';
|
|
16
|
+
import { registerReorder } from './commands/reorder.js';
|
|
17
|
+
import { registerEdit } from './commands/edit.js';
|
|
16
18
|
|
|
17
19
|
const require = createRequire(import.meta.url);
|
|
18
20
|
const { version } = require('../package.json');
|
|
@@ -35,5 +37,7 @@ registerQr(program);
|
|
|
35
37
|
registerConfig(program);
|
|
36
38
|
registerOpen(program);
|
|
37
39
|
registerStatus(program);
|
|
40
|
+
registerReorder(program);
|
|
41
|
+
registerEdit(program);
|
|
38
42
|
|
|
39
43
|
program.parse();
|
package/src/commands/add.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* atomically. Validates the URL and rejects duplicate labels.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { findConfig, readConfig, writeConfig, validateUrl } from '../config.js';
|
|
8
|
+
import { findConfig, readConfig, writeConfig, validateUrl, validateDate } from '../config.js';
|
|
9
9
|
|
|
10
10
|
export function registerAdd(program) {
|
|
11
11
|
program
|
|
@@ -25,6 +25,24 @@ export function registerAdd(program) {
|
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Validate schedule dates (if provided)
|
|
29
|
+
if (opts.from) {
|
|
30
|
+
const fromCheck = validateDate(opts.from);
|
|
31
|
+
if (!fromCheck.valid) {
|
|
32
|
+
console.error(`Error: --from: ${fromCheck.error}`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (opts.until) {
|
|
38
|
+
const untilCheck = validateDate(opts.until);
|
|
39
|
+
if (!untilCheck.valid) {
|
|
40
|
+
console.error(`Error: --until: ${untilCheck.error}`);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
// Find and read config
|
|
29
47
|
let configPath;
|
|
30
48
|
let config;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* links edit — Edit an existing link's properties in-place.
|
|
3
|
+
*
|
|
4
|
+
* Only specified flags are changed; everything else is left untouched.
|
|
5
|
+
* Supports --no-<flag> to remove optional fields entirely.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { findConfig, readConfig, writeConfig, validateUrl, validateDate } from '../config.js';
|
|
9
|
+
|
|
10
|
+
export function registerEdit(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('edit <label>')
|
|
13
|
+
.description('Edit an existing link')
|
|
14
|
+
.option('--label <new-label>', 'Rename the link')
|
|
15
|
+
.option('--url <url>', 'Update the URL')
|
|
16
|
+
.option('--icon <emoji>', 'Set or update the icon')
|
|
17
|
+
.option('--no-icon', 'Remove the icon')
|
|
18
|
+
.option('--description <text>', 'Set or update the description')
|
|
19
|
+
.option('--no-description', 'Remove the description')
|
|
20
|
+
.option('--from <date>', 'Set or update scheduled_from (ISO 8601)')
|
|
21
|
+
.option('--no-from', 'Remove scheduled_from')
|
|
22
|
+
.option('--until <date>', 'Set or update scheduled_until (ISO 8601)')
|
|
23
|
+
.option('--no-until', 'Remove scheduled_until')
|
|
24
|
+
.action(async (label, opts) => {
|
|
25
|
+
// Detect whether any editing flags were actually provided.
|
|
26
|
+
// Commander sets --no-icon to icon=false, --icon <v> to icon=<v>,
|
|
27
|
+
// and leaves icon=undefined when neither is passed. However,
|
|
28
|
+
// Commander pre-populates boolean defaults for --no-* pairs:
|
|
29
|
+
// when neither --icon nor --no-icon is passed, opts.icon is true (default).
|
|
30
|
+
// We need to check rawArgs or compare against defaults.
|
|
31
|
+
const hasLabel = opts.label !== undefined;
|
|
32
|
+
const hasUrl = opts.url !== undefined;
|
|
33
|
+
// For --no-* pairs: Commander sets default to true. Explicitly passed
|
|
34
|
+
// --icon <val> gives a string; --no-icon gives false; default is true.
|
|
35
|
+
const hasIcon = typeof opts.icon === 'string' || opts.icon === false;
|
|
36
|
+
const hasDescription = typeof opts.description === 'string' || opts.description === false;
|
|
37
|
+
const hasFrom = typeof opts.from === 'string' || opts.from === false;
|
|
38
|
+
const hasUntil = typeof opts.until === 'string' || opts.until === false;
|
|
39
|
+
|
|
40
|
+
const hasAnyOption = hasLabel || hasUrl || hasIcon || hasDescription || hasFrom || hasUntil;
|
|
41
|
+
|
|
42
|
+
if (!hasAnyOption) {
|
|
43
|
+
console.log(
|
|
44
|
+
'Usage: links edit <label> [--url <url>] [--label <new>] [--icon <emoji>] [--description <text>] [--from <date>] [--until <date>]',
|
|
45
|
+
);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Validate inputs before touching config
|
|
50
|
+
if (hasUrl) {
|
|
51
|
+
const urlCheck = validateUrl(opts.url);
|
|
52
|
+
if (!urlCheck.valid) {
|
|
53
|
+
console.error(`Error: ${urlCheck.error}`);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (typeof opts.from === 'string') {
|
|
59
|
+
const fromCheck = validateDate(opts.from);
|
|
60
|
+
if (!fromCheck.valid) {
|
|
61
|
+
console.error(`Error: --from: ${fromCheck.error}`);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (typeof opts.until === 'string') {
|
|
67
|
+
const untilCheck = validateDate(opts.until);
|
|
68
|
+
if (!untilCheck.valid) {
|
|
69
|
+
console.error(`Error: --until: ${untilCheck.error}`);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Find and read config
|
|
76
|
+
let configPath;
|
|
77
|
+
let config;
|
|
78
|
+
try {
|
|
79
|
+
configPath = findConfig();
|
|
80
|
+
config = readConfig(configPath);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`Error: ${err.message}`);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!config.links) {
|
|
88
|
+
config.links = [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Find link by label (case-insensitive)
|
|
92
|
+
const existingIndex = config.links.findIndex(
|
|
93
|
+
(l) => l.label.toLowerCase() === label.toLowerCase(),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (existingIndex === -1) {
|
|
97
|
+
console.error(`No link with label "${label}" found.`);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check rename collision
|
|
103
|
+
if (hasLabel) {
|
|
104
|
+
const collision = config.links.findIndex(
|
|
105
|
+
(l, i) => i !== existingIndex && l.label.toLowerCase() === opts.label.toLowerCase(),
|
|
106
|
+
);
|
|
107
|
+
if (collision !== -1) {
|
|
108
|
+
console.error(`A link with label "${opts.label}" already exists.`);
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const link = config.links[existingIndex];
|
|
115
|
+
const oldLabel = link.label;
|
|
116
|
+
const renamed = hasLabel && opts.label !== oldLabel;
|
|
117
|
+
|
|
118
|
+
// Apply changes — only specified flags
|
|
119
|
+
if (hasLabel) link.label = opts.label;
|
|
120
|
+
if (hasUrl) link.url = opts.url;
|
|
121
|
+
|
|
122
|
+
if (typeof opts.icon === 'string') {
|
|
123
|
+
link.icon = opts.icon;
|
|
124
|
+
} else if (opts.icon === false) {
|
|
125
|
+
delete link.icon;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof opts.description === 'string') {
|
|
129
|
+
link.description = opts.description;
|
|
130
|
+
} else if (opts.description === false) {
|
|
131
|
+
delete link.description;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof opts.from === 'string') {
|
|
135
|
+
link.scheduled_from = opts.from;
|
|
136
|
+
} else if (opts.from === false) {
|
|
137
|
+
delete link.scheduled_from;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof opts.until === 'string') {
|
|
141
|
+
link.scheduled_until = opts.until;
|
|
142
|
+
} else if (opts.until === false) {
|
|
143
|
+
delete link.scheduled_until;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Write back
|
|
147
|
+
try {
|
|
148
|
+
writeConfig(configPath, config);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error(`Error: could not write links.yaml — ${err.message}`);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Output message
|
|
156
|
+
if (renamed) {
|
|
157
|
+
const otherChanges = hasUrl || hasIcon || hasDescription || hasFrom || hasUntil;
|
|
158
|
+
if (otherChanges) {
|
|
159
|
+
console.log(`✓ Renamed "${oldLabel}" → "${link.label}" and updated.`);
|
|
160
|
+
} else {
|
|
161
|
+
console.log(`✓ Renamed "${oldLabel}" → "${link.label}"`);
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
console.log(`✓ Updated: ${link.label}`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* links reorder — Reorder links in links.yaml.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* (no args) — print current order with 1-based indices
|
|
6
|
+
* move <label> <pos> — move a link to the given 1-based position
|
|
7
|
+
* up <label> — move a link one position up
|
|
8
|
+
* down <label> — move a link one position down
|
|
9
|
+
* set <labels...> — specify the complete new order by labels
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { findConfig, readConfig, writeConfig } from '../config.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Print the current link order as a numbered list.
|
|
16
|
+
* @param {object[]} links
|
|
17
|
+
*/
|
|
18
|
+
function printOrder(links) {
|
|
19
|
+
if (links.length === 0) {
|
|
20
|
+
console.log('No links configured.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
links.forEach((link, i) => {
|
|
24
|
+
console.log(`${i + 1}. ${link.label}`);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Find a link index by label (case-insensitive).
|
|
30
|
+
* @param {object[]} links
|
|
31
|
+
* @param {string} label
|
|
32
|
+
* @returns {number} index or -1
|
|
33
|
+
*/
|
|
34
|
+
function findByLabel(links, label) {
|
|
35
|
+
return links.findIndex(
|
|
36
|
+
(l) => l.label.toLowerCase() === label.toLowerCase(),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerReorder(program) {
|
|
41
|
+
const reorderCmd = program
|
|
42
|
+
.command('reorder')
|
|
43
|
+
.description('Reorder links in links.yaml')
|
|
44
|
+
.action(async () => {
|
|
45
|
+
// No subcommand — print current order
|
|
46
|
+
let config;
|
|
47
|
+
try {
|
|
48
|
+
const configPath = findConfig();
|
|
49
|
+
config = readConfig(configPath);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`Error: ${err.message}`);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
printOrder(config.links || []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// --- reorder move <label> <position> ---
|
|
60
|
+
reorderCmd
|
|
61
|
+
.command('move <label> <position>')
|
|
62
|
+
.description('Move a link to the given 1-based position')
|
|
63
|
+
.action(async (label, position) => {
|
|
64
|
+
let configPath, config;
|
|
65
|
+
try {
|
|
66
|
+
configPath = findConfig();
|
|
67
|
+
config = readConfig(configPath);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`Error: ${err.message}`);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const links = config.links || [];
|
|
75
|
+
const index = findByLabel(links, label);
|
|
76
|
+
if (index === -1) {
|
|
77
|
+
console.error(`No link with label "${label}" found.`);
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const pos = parseInt(position, 10);
|
|
83
|
+
if (Number.isNaN(pos) || pos < 1 || pos > links.length) {
|
|
84
|
+
console.error(`Invalid position: ${position}. Must be between 1 and ${links.length}.`);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Splice out and insert at new position
|
|
90
|
+
const [item] = links.splice(index, 1);
|
|
91
|
+
links.splice(pos - 1, 0, item);
|
|
92
|
+
config.links = links;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
writeConfig(configPath, config);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error(`Error: could not write links.yaml — ${err.message}`);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(`✓ Moved "${item.label}" to position ${pos}.`);
|
|
103
|
+
printOrder(links);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// --- reorder up <label> ---
|
|
107
|
+
reorderCmd
|
|
108
|
+
.command('up <label>')
|
|
109
|
+
.description('Move a link one position up')
|
|
110
|
+
.action(async (label) => {
|
|
111
|
+
let configPath, config;
|
|
112
|
+
try {
|
|
113
|
+
configPath = findConfig();
|
|
114
|
+
config = readConfig(configPath);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(`Error: ${err.message}`);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const links = config.links || [];
|
|
122
|
+
const index = findByLabel(links, label);
|
|
123
|
+
if (index === -1) {
|
|
124
|
+
console.error(`No link with label "${label}" found.`);
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (index === 0) {
|
|
130
|
+
console.log(`${links[index].label} is already at the top.`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Swap with previous
|
|
135
|
+
[links[index - 1], links[index]] = [links[index], links[index - 1]];
|
|
136
|
+
config.links = links;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
writeConfig(configPath, config);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(`Error: could not write links.yaml — ${err.message}`);
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log(`✓ Moved "${links[index - 1].label}" up to position ${index}.`);
|
|
147
|
+
printOrder(links);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// --- reorder down <label> ---
|
|
151
|
+
reorderCmd
|
|
152
|
+
.command('down <label>')
|
|
153
|
+
.description('Move a link one position down')
|
|
154
|
+
.action(async (label) => {
|
|
155
|
+
let configPath, config;
|
|
156
|
+
try {
|
|
157
|
+
configPath = findConfig();
|
|
158
|
+
config = readConfig(configPath);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error(`Error: ${err.message}`);
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const links = config.links || [];
|
|
166
|
+
const index = findByLabel(links, label);
|
|
167
|
+
if (index === -1) {
|
|
168
|
+
console.error(`No link with label "${label}" found.`);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (index === links.length - 1) {
|
|
174
|
+
console.log(`${links[index].label} is already at the bottom.`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Swap with next
|
|
179
|
+
[links[index], links[index + 1]] = [links[index + 1], links[index]];
|
|
180
|
+
config.links = links;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
writeConfig(configPath, config);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error(`Error: could not write links.yaml — ${err.message}`);
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log(`✓ Moved "${links[index + 1].label}" down to position ${index + 2}.`);
|
|
191
|
+
printOrder(links);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// --- reorder set <labels...> ---
|
|
195
|
+
reorderCmd
|
|
196
|
+
.command('set <labels...>')
|
|
197
|
+
.description('Specify the complete new order by passing all labels')
|
|
198
|
+
.action(async (labels) => {
|
|
199
|
+
let configPath, config;
|
|
200
|
+
try {
|
|
201
|
+
configPath = findConfig();
|
|
202
|
+
config = readConfig(configPath);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error(`Error: ${err.message}`);
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const links = config.links || [];
|
|
210
|
+
|
|
211
|
+
if (labels.length !== links.length) {
|
|
212
|
+
console.error(
|
|
213
|
+
`Expected ${links.length} labels but got ${labels.length}. All existing labels must be specified.`,
|
|
214
|
+
);
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Validate all provided labels exist and build new order
|
|
220
|
+
const newLinks = [];
|
|
221
|
+
const used = new Set();
|
|
222
|
+
|
|
223
|
+
for (const label of labels) {
|
|
224
|
+
const index = findByLabel(links, label);
|
|
225
|
+
if (index === -1) {
|
|
226
|
+
console.error(`No link with label "${label}" found.`);
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const key = links[index].label.toLowerCase();
|
|
232
|
+
if (used.has(key)) {
|
|
233
|
+
console.error(`Duplicate label "${label}" in arguments.`);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
used.add(key);
|
|
238
|
+
newLinks.push(links[index]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Verify all existing labels are covered
|
|
242
|
+
for (const link of links) {
|
|
243
|
+
if (!used.has(link.label.toLowerCase())) {
|
|
244
|
+
console.error(`Missing label "${link.label}" — all existing labels must be specified.`);
|
|
245
|
+
process.exitCode = 1;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
config.links = newLinks;
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
writeConfig(configPath, config);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
console.error(`Error: could not write links.yaml — ${err.message}`);
|
|
256
|
+
process.exitCode = 1;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log('✓ Links reordered.');
|
|
261
|
+
printOrder(newLinks);
|
|
262
|
+
});
|
|
263
|
+
}
|
package/src/config.js
CHANGED
|
@@ -1,20 +1,7 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs';
|
|
2
2
|
import { resolve, dirname, join } from 'node:path';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
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
5
|
// ---------------------------------------------------------------------------
|
|
19
6
|
// Validation helpers
|
|
20
7
|
// ---------------------------------------------------------------------------
|
|
@@ -154,7 +141,12 @@ export function findConfig(startDir = process.cwd()) {
|
|
|
154
141
|
readFileSync(candidate); // existence check
|
|
155
142
|
return candidate;
|
|
156
143
|
} catch {
|
|
157
|
-
// not found —
|
|
144
|
+
// not found — check for git root boundary before climbing
|
|
145
|
+
if (existsSync(join(dir, '.git'))) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
'links.yaml not found (stopped at git root). Run "links init" to create a new project.',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
158
150
|
}
|
|
159
151
|
|
|
160
152
|
const parent = dirname(dir);
|
package/src/generator.js
CHANGED
|
@@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs';
|
|
|
9
9
|
import { resolve, dirname, extname } from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { isLinkActive } from './utils.js';
|
|
12
|
+
import { loadTheme } from './themes/loader.js';
|
|
12
13
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
|
|
@@ -27,6 +28,15 @@ function esc(str) {
|
|
|
27
28
|
.replace(/'/g, ''');
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Extract the --accent-color value from a CSS string.
|
|
33
|
+
* Falls back to '#6366f1' (indigo) if not found.
|
|
34
|
+
*/
|
|
35
|
+
export function extractAccentColor(css) {
|
|
36
|
+
const match = css.match(/--accent-color:\s*([^;]+);/);
|
|
37
|
+
return match ? match[1].trim() : '#6366f1';
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
/** MIME type lookup for common image formats. */
|
|
31
41
|
function imageMime(filePath) {
|
|
32
42
|
const ext = extname(filePath).toLowerCase();
|
|
@@ -70,27 +80,6 @@ export function filterScheduled(links, now = new Date()) {
|
|
|
70
80
|
return links.filter((link) => isLinkActive(link, now));
|
|
71
81
|
}
|
|
72
82
|
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
// Theme CSS loading
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Load theme CSS from the themes directory.
|
|
79
|
-
* @param {string} themeName — name without .css extension
|
|
80
|
-
* @returns {string} CSS content
|
|
81
|
-
*/
|
|
82
|
-
export function loadThemeCSS(themeName) {
|
|
83
|
-
const themePath = resolve(__dirname, 'themes', `${themeName}.css`);
|
|
84
|
-
try {
|
|
85
|
-
return readFileSync(themePath, 'utf8');
|
|
86
|
-
} catch {
|
|
87
|
-
throw new Error(
|
|
88
|
-
`Theme "${themeName}" not found at ${themePath}. ` +
|
|
89
|
-
'Available themes live in src/themes/.',
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
83
|
// ---------------------------------------------------------------------------
|
|
95
84
|
// HTML generation
|
|
96
85
|
// ---------------------------------------------------------------------------
|
|
@@ -113,7 +102,7 @@ export function generatePage(config, options = {}) {
|
|
|
113
102
|
const theme = config.theme || 'minimal-dark';
|
|
114
103
|
|
|
115
104
|
// Load and inline CSS
|
|
116
|
-
const css =
|
|
105
|
+
const css = loadTheme(theme);
|
|
117
106
|
|
|
118
107
|
// Filter links by schedule
|
|
119
108
|
const allLinks = config.links || [];
|
|
@@ -124,16 +113,37 @@ export function generatePage(config, options = {}) {
|
|
|
124
113
|
|
|
125
114
|
// Avatar handling
|
|
126
115
|
let avatarHTML = '';
|
|
116
|
+
let avatarDataUri = null;
|
|
127
117
|
if (config.avatar && config.avatar.trim().length > 0) {
|
|
128
118
|
try {
|
|
129
|
-
|
|
130
|
-
avatarHTML = `<img class="profile__avatar" src="${
|
|
119
|
+
avatarDataUri = inlineImage(config.avatar, configDir);
|
|
120
|
+
avatarHTML = `<img class="profile__avatar" src="${avatarDataUri}" alt="${esc(name)}" width="88" height="88">`;
|
|
131
121
|
} catch {
|
|
132
122
|
// Fallback: reference the file directly (it will be copied to output dir)
|
|
133
123
|
avatarHTML = `<img class="profile__avatar" src="${esc(config.avatar)}" alt="${esc(name)}" width="88" height="88">`;
|
|
134
124
|
}
|
|
135
125
|
}
|
|
136
126
|
|
|
127
|
+
// Favicon handling — Strategy A (avatar) or Strategy B (SVG letter)
|
|
128
|
+
let faviconHTML = '';
|
|
129
|
+
if (avatarDataUri) {
|
|
130
|
+
// Strategy A: reuse the already-computed avatar data URI
|
|
131
|
+
const avatarMime = avatarDataUri.startsWith('data:image/svg') ? 'image/svg+xml' : 'image/png';
|
|
132
|
+
faviconHTML = ` <link rel="icon" type="${avatarMime}" href="${avatarDataUri}">\n`
|
|
133
|
+
+ ` <link rel="icon" type="${avatarMime}" sizes="32x32" href="${avatarDataUri}">`;
|
|
134
|
+
} else {
|
|
135
|
+
// Strategy B: generate SVG favicon from first letter of name
|
|
136
|
+
const firstChar = name[0].toUpperCase();
|
|
137
|
+
const accent = extractAccentColor(css);
|
|
138
|
+
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>`
|
|
139
|
+
+ `<rect width='64' height='64' rx='12' fill='${accent}'/>`
|
|
140
|
+
+ `<text x='32' y='32' text-anchor='middle' dominant-baseline='central' font-family='system-ui,sans-serif' font-size='32' font-weight='700' fill='white'>${firstChar}</text>`
|
|
141
|
+
+ `</svg>`;
|
|
142
|
+
const svgDataUri = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
|
143
|
+
faviconHTML = ` <link rel="icon" type="image/svg+xml" href="${svgDataUri}">\n`
|
|
144
|
+
+ ` <link rel="icon" type="image/svg+xml" sizes="32x32" href="${svgDataUri}">`;
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
// Build link list HTML
|
|
138
148
|
const linksHTML = links.map((link) => {
|
|
139
149
|
const icon = link.icon ? `<span class="links__icon">${esc(link.icon)}</span>` : '';
|
|
@@ -162,6 +172,7 @@ export function generatePage(config, options = {}) {
|
|
|
162
172
|
<meta property="og:title" content="${esc(name)}">
|
|
163
173
|
<meta property="og:description" content="${esc(ogDescription)}">
|
|
164
174
|
<meta property="og:type" content="website">
|
|
175
|
+
${faviconHTML}
|
|
165
176
|
<style>
|
|
166
177
|
${css}
|
|
167
178
|
</style>
|
package/src/utils.js
CHANGED
|
@@ -1,27 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared utility helpers for @kntic/links.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - fatal(message, code) — pretty-print error and exit
|
|
6
|
+
* - isLinkActive(link, now) — schedule-aware link visibility check
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
|
-
import { readFile } from 'node:fs/promises';
|
|
6
|
-
import { resolve } from 'node:path';
|
|
7
|
-
import yaml from 'js-yaml';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Default config filename.
|
|
11
|
-
*/
|
|
12
|
-
export const CONFIG_FILE = 'links.yml';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Load and parse the links YAML config from the current directory.
|
|
16
|
-
* @param {string} [dir=process.cwd()] - Directory to look in.
|
|
17
|
-
* @returns {Promise<object>} Parsed config object.
|
|
18
|
-
*/
|
|
19
|
-
export async function loadConfig(dir = process.cwd()) {
|
|
20
|
-
const filePath = resolve(dir, CONFIG_FILE);
|
|
21
|
-
const raw = await readFile(filePath, 'utf8');
|
|
22
|
-
return yaml.load(raw);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
9
|
/**
|
|
26
10
|
* Pretty-print an error and exit.
|
|
27
11
|
* @param {string} message
|