ispbills-icli 8.1.1 → 8.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/README.md +33 -3
- package/bin/icli.js +4 -3
- package/package.json +1 -1
- package/src/client.js +63 -5
- package/src/clipboard.js +42 -0
- package/src/commands.js +22 -0
- package/src/gist.js +90 -0
- package/src/gitrepo.js +78 -0
- package/src/memory.js +72 -0
- package/src/tui/app.js +233 -17
- package/src/tui/components.js +109 -27
- package/src/tui/composer.js +85 -25
- package/src/tui/markdown.js +110 -19
- package/src/userdir.js +18 -0
package/src/tui/composer.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
// Bottom-pinned input composer with cursor editing, input history, readline
|
|
2
2
|
// key bindings and a Copilot-style slash-command palette that appears ABOVE
|
|
3
3
|
// the input and is navigable with the arrow keys.
|
|
4
|
-
import { html, useState, useEffect, useCallback } from './dom.js';
|
|
4
|
+
import { html, useState, useEffect, useCallback, useRef } from './dom.js';
|
|
5
5
|
import { Box, Text, useInput } from 'ink';
|
|
6
6
|
import { C, glyphs, borderStyle } from './theme.js';
|
|
7
7
|
import { SLASH_COMMANDS } from '../commands.js';
|
|
8
8
|
|
|
9
|
+
// @-mention entity types. Selecting one inserts e.g. "@olt:" so the user can
|
|
10
|
+
// append an identifier (@olt:3); the backend treats it as a scoped reference.
|
|
11
|
+
const MENTIONS = [
|
|
12
|
+
{ tag: '@device', desc: 'Any device by name or id' },
|
|
13
|
+
{ tag: '@olt', desc: 'An OLT' },
|
|
14
|
+
{ tag: '@router', desc: 'A router / NAS' },
|
|
15
|
+
{ tag: '@onu', desc: 'An ONU' },
|
|
16
|
+
{ tag: '@customer', desc: 'A customer' },
|
|
17
|
+
{ tag: '@vlan', desc: 'A VLAN id' },
|
|
18
|
+
{ tag: '@circuit', desc: 'A circuit / uplink' },
|
|
19
|
+
{ tag: '@ip', desc: 'An IP address' },
|
|
20
|
+
{ tag: '@mac', desc: 'A MAC address' },
|
|
21
|
+
];
|
|
22
|
+
|
|
9
23
|
export function Composer({ onSubmit, busy, width = 80 }) {
|
|
10
24
|
const [value, setValue] = useState('');
|
|
11
25
|
const [cursor, setCursor] = useState(0);
|
|
@@ -13,9 +27,29 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
13
27
|
const [histIdx, setHistIdx] = useState(-1);
|
|
14
28
|
const [sel, setSel] = useState(0);
|
|
15
29
|
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
30
|
+
// Undo/redo history: snapshots of { value, cursor }. Consecutive typing is
|
|
31
|
+
// coalesced into a single undo step via `lastMut`.
|
|
32
|
+
const undoRef = useRef([]);
|
|
33
|
+
const redoRef = useRef([]);
|
|
34
|
+
const lastMut = useRef('');
|
|
35
|
+
const snapshot = (kind) => {
|
|
36
|
+
if (kind === 'type' && lastMut.current === 'type') return; // coalesce a typing run
|
|
37
|
+
undoRef.current.push({ value, cursor });
|
|
38
|
+
if (undoRef.current.length > 200) undoRef.current.shift();
|
|
39
|
+
redoRef.current = [];
|
|
40
|
+
lastMut.current = kind;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Determine the token being typed at the cursor to drive the palette.
|
|
44
|
+
const upto = value.slice(0, cursor);
|
|
45
|
+
const tokStart = Math.max(upto.lastIndexOf(' '), upto.lastIndexOf('\n')) + 1;
|
|
46
|
+
const token = upto.slice(tokStart);
|
|
47
|
+
const slashMode = value.startsWith('/') && tokStart === 0;
|
|
48
|
+
const mentionMode = token.startsWith('@');
|
|
49
|
+
const matches = slashMode
|
|
50
|
+
? SLASH_COMMANDS.filter((s) => s.cmd.startsWith(token))
|
|
51
|
+
: mentionMode
|
|
52
|
+
? MENTIONS.filter((m) => m.tag.startsWith(token.toLowerCase()))
|
|
19
53
|
: [];
|
|
20
54
|
const menuOpen = matches.length > 0;
|
|
21
55
|
|
|
@@ -24,6 +58,7 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
24
58
|
const submit = useCallback((text) => {
|
|
25
59
|
const q = text.trim();
|
|
26
60
|
setValue(''); setCursor(0); setHistIdx(-1); setSel(0);
|
|
61
|
+
undoRef.current = []; redoRef.current = []; lastMut.current = '';
|
|
27
62
|
if (q) {
|
|
28
63
|
setHistory((h) => (h[h.length - 1] === q ? h : [...h, q]));
|
|
29
64
|
onSubmit(q);
|
|
@@ -32,22 +67,40 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
32
67
|
|
|
33
68
|
const setText = (v, c) => { setValue(v); setCursor(c ?? v.length); };
|
|
34
69
|
|
|
70
|
+
// Complete the active token to the highlighted palette entry.
|
|
71
|
+
const complete = () => {
|
|
72
|
+
if (!menuOpen) return;
|
|
73
|
+
if (slashMode) { const c = matches[sel].cmd + ' '; setText(c, c.length); return; }
|
|
74
|
+
const ins = matches[sel].tag + ':';
|
|
75
|
+
const v = value.slice(0, tokStart) + ins + value.slice(cursor);
|
|
76
|
+
setText(v, tokStart + ins.length);
|
|
77
|
+
};
|
|
78
|
+
|
|
35
79
|
useInput((input, key) => {
|
|
36
80
|
if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
|
|
37
81
|
|
|
38
82
|
// ── Submit / complete ──
|
|
39
|
-
|
|
40
|
-
|
|
83
|
+
// Some terminals/PTYs deliver Enter as input="\r" without key.return, so
|
|
84
|
+
// treat a lone CR/LF as Enter too (a paste is a longer chunk, never "\r").
|
|
85
|
+
const enter = key.return || input === '\r' || input === '\n';
|
|
86
|
+
if (enter) {
|
|
87
|
+
// In slash mode Enter runs the command; in mention mode it completes.
|
|
88
|
+
if (menuOpen && slashMode) { submit(matches[sel].cmd); return; }
|
|
89
|
+
if (menuOpen && mentionMode) { complete(); return; }
|
|
41
90
|
submit(value);
|
|
42
91
|
return;
|
|
43
92
|
}
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
if (key.tab && !key.shift) { complete(); return; }
|
|
94
|
+
|
|
95
|
+
// ── Undo / redo ──
|
|
96
|
+
if (key.ctrl && input === 'z') {
|
|
97
|
+
const prev = undoRef.current.pop();
|
|
98
|
+
if (prev) { redoRef.current.push({ value, cursor }); setValue(prev.value); setCursor(prev.cursor); lastMut.current = 'undo'; }
|
|
47
99
|
return;
|
|
48
100
|
}
|
|
49
|
-
if (key.
|
|
50
|
-
|
|
101
|
+
if (key.ctrl && (input === 'y' || (input === 'z' && key.shift))) {
|
|
102
|
+
const nx = redoRef.current.pop();
|
|
103
|
+
if (nx) { undoRef.current.push({ value, cursor }); setValue(nx.value); setCursor(nx.cursor); lastMut.current = 'redo'; }
|
|
51
104
|
return;
|
|
52
105
|
}
|
|
53
106
|
|
|
@@ -76,8 +129,8 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
76
129
|
// ── Cursor movement ──
|
|
77
130
|
if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
|
|
78
131
|
if (key.rightArrow) {
|
|
79
|
-
// At end of
|
|
80
|
-
if (menuOpen && cursor >= value.length) {
|
|
132
|
+
// At end of the active token, Right accepts the highlighted completion.
|
|
133
|
+
if (menuOpen && cursor >= value.length) { complete(); return; }
|
|
81
134
|
setCursor((c) => Math.min(value.length, c + 1));
|
|
82
135
|
return;
|
|
83
136
|
}
|
|
@@ -86,25 +139,30 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
86
139
|
|
|
87
140
|
// ── Deletion ──
|
|
88
141
|
if (key.backspace || key.delete) {
|
|
89
|
-
if (cursor > 0) { setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
|
|
142
|
+
if (cursor > 0) { snapshot('del'); setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
|
|
90
143
|
return;
|
|
91
144
|
}
|
|
92
|
-
if (key.ctrl && input === 'u') { setValue(value.slice(cursor)); setCursor(0); return; } // to start
|
|
93
|
-
if (key.ctrl && input === 'k') { setValue(value.slice(0, cursor)); return; } // to end
|
|
145
|
+
if (key.ctrl && input === 'u') { snapshot('cut'); setValue(value.slice(cursor)); setCursor(0); return; } // to start
|
|
146
|
+
if (key.ctrl && input === 'k') { snapshot('cut'); setValue(value.slice(0, cursor)); return; } // to end
|
|
94
147
|
if (key.ctrl && input === 'w') { // prev word
|
|
95
148
|
let i = cursor;
|
|
96
149
|
while (i > 0 && value[i - 1] === ' ') i--;
|
|
97
150
|
while (i > 0 && value[i - 1] !== ' ') i--;
|
|
151
|
+
snapshot('cut');
|
|
98
152
|
setValue(value.slice(0, i) + value.slice(cursor)); setCursor(i);
|
|
99
153
|
return;
|
|
100
154
|
}
|
|
101
155
|
|
|
102
|
-
// ── Printable input ──
|
|
156
|
+
// ── Printable input & paste ──
|
|
103
157
|
if (input && !key.ctrl && !key.meta) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
158
|
+
// Strip bracketed-paste markers, then flatten newlines/tabs from a paste.
|
|
159
|
+
let text = input.replace(/\x1b\[20[01]~/g, '');
|
|
160
|
+
const isPaste = text.length > 1 || /[\r\n\t]/.test(text);
|
|
161
|
+
text = text.replace(/[\r\n\t]+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '');
|
|
162
|
+
if (!text) return;
|
|
163
|
+
snapshot(isPaste ? 'paste' : 'type');
|
|
164
|
+
setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
|
|
165
|
+
setCursor((c) => c + text.length);
|
|
108
166
|
}
|
|
109
167
|
});
|
|
110
168
|
|
|
@@ -112,13 +170,14 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
112
170
|
const at = value[cursor] ?? ' ';
|
|
113
171
|
const after = value.slice(cursor + 1);
|
|
114
172
|
const empty = value.length === 0;
|
|
115
|
-
const
|
|
116
|
-
const ghost =
|
|
173
|
+
const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
|
|
174
|
+
const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
|
|
117
175
|
|
|
118
176
|
return html`
|
|
119
177
|
<${Box} flexDirection="column" width=${width}>
|
|
120
178
|
${menuOpen
|
|
121
179
|
? html`<${Box} flexDirection="column" paddingX=${1} marginBottom=${0}>
|
|
180
|
+
${slashMode ? null : html`<${Box}><${Text} color=${C.cyan} bold>@ mention <//><${Text} color=${C.gray}>a device, customer or entity<//><//>`}
|
|
122
181
|
${(() => {
|
|
123
182
|
const MAX = 8;
|
|
124
183
|
let start = 0;
|
|
@@ -127,10 +186,11 @@ export function Composer({ onSubmit, busy, width = 80 }) {
|
|
|
127
186
|
return window.map((s, wi) => {
|
|
128
187
|
const i = start + wi;
|
|
129
188
|
const on = i === sel;
|
|
130
|
-
const
|
|
189
|
+
const key = slashMode ? (s.cmd + (s.hint ? ' ' + s.hint : '')) : s.tag;
|
|
190
|
+
const label = key.padEnd(21);
|
|
131
191
|
return html`<${Box} key=${'m' + i}>
|
|
132
192
|
<${Text} color=${on ? C.accent : C.gray}>${on ? glyphs.prompt + ' ' : ' '}<//>
|
|
133
|
-
<${Text} color=${on ? C.accent : C.white} bold=${on}>${label}<//>
|
|
193
|
+
<${Text} color=${on ? C.accent : slashMode ? C.white : C.cyan} bold=${on}>${label}<//>
|
|
134
194
|
<${Text} color=${C.gray}>${s.desc}<//>
|
|
135
195
|
<//>`;
|
|
136
196
|
});
|
package/src/tui/markdown.js
CHANGED
|
@@ -1,55 +1,145 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Small, dependency-free markdown → Ink renderer. Supports headings, bold,
|
|
2
|
+
// italic, strikethrough, inline code, links, bullet/numbered lists,
|
|
3
|
+
// blockquotes, horizontal rules, fenced code blocks and GFM tables. Ink
|
|
3
4
|
// handles wrapping to the available width automatically.
|
|
4
|
-
import { html
|
|
5
|
+
import { html } from './dom.js';
|
|
5
6
|
import { Box, Text } from 'ink';
|
|
6
|
-
import { C, glyphs } from './theme.js';
|
|
7
|
+
import { C, glyphs, asciiSafe } from './theme.js';
|
|
7
8
|
|
|
8
9
|
let keyId = 0;
|
|
9
10
|
const k = () => `md${keyId++}`;
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
const stripInline = (s) =>
|
|
13
|
+
String(s)
|
|
14
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
15
|
+
.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2')
|
|
16
|
+
.replace(/_([^_]+)_/g, '$1')
|
|
17
|
+
.replace(/~~([^~]+)~~/g, '$1')
|
|
18
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
19
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
|
20
|
+
|
|
21
|
+
const cellWidth = (s) => stripInline(s).length;
|
|
22
|
+
|
|
23
|
+
/** Parse a single line into styled Ink <Text> spans. */
|
|
12
24
|
function renderInline(line) {
|
|
13
25
|
const spans = [];
|
|
14
|
-
//
|
|
15
|
-
const re = /(
|
|
26
|
+
// Order matters: links, bold, strike, italic, code.
|
|
27
|
+
const re = /(\[([^\]]+)\]\(([^)]+)\)|\*\*([^*]+)\*\*|~~([^~]+)~~|`([^`]+)`|\*([^*]+)\*|_([^_]+)_)/g;
|
|
16
28
|
let last = 0;
|
|
17
29
|
let m;
|
|
18
30
|
while ((m = re.exec(line)) !== null) {
|
|
19
31
|
if (m.index > last) spans.push(html`<${Text} key=${k()}>${line.slice(last, m.index)}<//>`);
|
|
20
|
-
if (m[2] !== undefined) spans.push(html`<${Text} key=${k()}
|
|
21
|
-
else if (m[
|
|
32
|
+
if (m[2] !== undefined) spans.push(html`<${Text} key=${k()} color=${C.blue} underline>${m[2]}<//>`);
|
|
33
|
+
else if (m[4] !== undefined) spans.push(html`<${Text} key=${k()} bold>${m[4]}<//>`);
|
|
34
|
+
else if (m[5] !== undefined) spans.push(html`<${Text} key=${k()} strikethrough color=${C.gray}>${m[5]}<//>`);
|
|
35
|
+
else if (m[6] !== undefined) spans.push(html`<${Text} key=${k()} color=${C.cyan}>${m[6]}<//>`);
|
|
36
|
+
else if (m[7] !== undefined) spans.push(html`<${Text} key=${k()} italic>${m[7]}<//>`);
|
|
37
|
+
else if (m[8] !== undefined) spans.push(html`<${Text} key=${k()} italic>${m[8]}<//>`);
|
|
22
38
|
last = m.index + m[0].length;
|
|
23
39
|
}
|
|
24
40
|
if (last < line.length) spans.push(html`<${Text} key=${k()}>${line.slice(last)}<//>`);
|
|
25
41
|
return spans.length ? spans : [html`<${Text} key=${k()}> <//>`];
|
|
26
42
|
}
|
|
27
43
|
|
|
44
|
+
const isTableRow = (l) => /^\s*\|.*\|\s*$/.test(l);
|
|
45
|
+
const isTableSep = (l) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(l);
|
|
46
|
+
|
|
47
|
+
const splitCells = (l) =>
|
|
48
|
+
l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
|
49
|
+
|
|
50
|
+
/** Render a parsed GFM table as an aligned, bordered box. */
|
|
51
|
+
function renderTable(header, rows) {
|
|
52
|
+
const cols = header.length;
|
|
53
|
+
const widths = header.map((h, i) =>
|
|
54
|
+
Math.max(cellWidth(h), ...rows.map((r) => cellWidth(r[i] || ''))),
|
|
55
|
+
);
|
|
56
|
+
const bar = glyphs.vbar;
|
|
57
|
+
const pad = (text, w) => {
|
|
58
|
+
const gap = Math.max(0, w - cellWidth(text));
|
|
59
|
+
return ' '.repeat(gap);
|
|
60
|
+
};
|
|
61
|
+
const rule = (l, mid, r) =>
|
|
62
|
+
l + widths.map((w) => (asciiSafe() ? '-' : '─').repeat(w + 2)).join(mid) + r;
|
|
63
|
+
const A = asciiSafe();
|
|
64
|
+
const [tl, tm, tr] = A ? ['+', '+', '+'] : ['┌', '┬', '┐'];
|
|
65
|
+
const [ml, mm, mr] = A ? ['+', '+', '+'] : ['├', '┼', '┤'];
|
|
66
|
+
const [bl, bm, br] = A ? ['+', '+', '+'] : ['└', '┴', '┘'];
|
|
67
|
+
|
|
68
|
+
const line = (cells, color, bold) => html`
|
|
69
|
+
<${Box} key=${k()}>
|
|
70
|
+
<${Text} color=${C.gray}>${bar}<//>
|
|
71
|
+
${cells.map((cell, i) => html`
|
|
72
|
+
<${Box} key=${k()}>
|
|
73
|
+
<${Text}> <//>
|
|
74
|
+
<${Text} color=${color} bold=${bold}>${renderInline(cell || '')}<//><${Text}>${pad(cell || '', widths[i])} <//>
|
|
75
|
+
<${Text} color=${C.gray}>${bar}<//>
|
|
76
|
+
<//>`)}
|
|
77
|
+
<//>`;
|
|
78
|
+
|
|
79
|
+
return html`
|
|
80
|
+
<${Box} flexDirection="column" key=${k()} marginY=${0}>
|
|
81
|
+
<${Text} color=${C.gray}>${rule(tl, tm, tr)}<//>
|
|
82
|
+
${line(header, C.accent, true)}
|
|
83
|
+
<${Text} color=${C.gray}>${rule(ml, mm, mr)}<//>
|
|
84
|
+
${rows.map((r) => line(r, C.white, false))}
|
|
85
|
+
<${Text} color=${C.gray}>${rule(bl, bm, br)}<//>
|
|
86
|
+
<//>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
28
89
|
/** Render a markdown string as a column of Ink lines. */
|
|
29
90
|
export function Markdown({ content = '' }) {
|
|
30
91
|
const rows = [];
|
|
31
92
|
let inCode = false;
|
|
32
93
|
const lines = String(content).replace(/\s+$/, '').split('\n');
|
|
33
94
|
|
|
34
|
-
for (
|
|
35
|
-
const line =
|
|
95
|
+
for (let i = 0; i < lines.length; i++) {
|
|
96
|
+
const line = lines[i].replace(/\t/g, ' ');
|
|
97
|
+
|
|
98
|
+
// Fenced code block.
|
|
99
|
+
if (line.trim().startsWith('```')) { inCode = !inCode; continue; }
|
|
100
|
+
if (inCode) { rows.push(html`<${Text} key=${k()} color=${C.green}> ${line}<//>`); continue; }
|
|
36
101
|
|
|
37
|
-
|
|
38
|
-
|
|
102
|
+
// GFM table: header row + separator + body rows.
|
|
103
|
+
if (isTableRow(line) && i + 1 < lines.length && isTableSep(lines[i + 1])) {
|
|
104
|
+
const header = splitCells(line);
|
|
105
|
+
const body = [];
|
|
106
|
+
i += 2;
|
|
107
|
+
while (i < lines.length && isTableRow(lines[i])) { body.push(splitCells(lines[i])); i++; }
|
|
108
|
+
i--;
|
|
109
|
+
rows.push(renderTable(header, body));
|
|
39
110
|
continue;
|
|
40
111
|
}
|
|
41
|
-
|
|
42
|
-
|
|
112
|
+
|
|
113
|
+
// Horizontal rule.
|
|
114
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
|
115
|
+
rows.push(html`<${Text} key=${k()} color=${C.gray}>${(asciiSafe() ? '-' : '─').repeat(40)}<//>`);
|
|
43
116
|
continue;
|
|
44
117
|
}
|
|
45
118
|
|
|
46
|
-
|
|
119
|
+
// Headings.
|
|
120
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
47
121
|
if (h) {
|
|
48
|
-
|
|
122
|
+
const color = h[1].length === 1 ? C.accent : h[1].length === 2 ? C.cyan : C.white;
|
|
123
|
+
rows.push(html`
|
|
124
|
+
<${Box} key=${k()} marginTop=${rows.length ? 1 : 0}>
|
|
125
|
+
<${Text} bold color=${color}>${stripInline(h[2])}<//>
|
|
126
|
+
<//>`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Blockquote.
|
|
131
|
+
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
132
|
+
if (quote) {
|
|
133
|
+
rows.push(html`
|
|
134
|
+
<${Box} key=${k()}>
|
|
135
|
+
<${Text} color=${C.accent}>${glyphs.vbar} <//>
|
|
136
|
+
<${Text} color=${C.gray} italic>${renderInline(quote[1])}<//>
|
|
137
|
+
<//>`);
|
|
49
138
|
continue;
|
|
50
139
|
}
|
|
51
140
|
|
|
52
|
-
|
|
141
|
+
// Bullet list.
|
|
142
|
+
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
53
143
|
if (bullet) {
|
|
54
144
|
rows.push(html`
|
|
55
145
|
<${Box} key=${k()}>
|
|
@@ -59,11 +149,12 @@ export function Markdown({ content = '' }) {
|
|
|
59
149
|
continue;
|
|
60
150
|
}
|
|
61
151
|
|
|
152
|
+
// Numbered list.
|
|
62
153
|
const num = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
|
|
63
154
|
if (num) {
|
|
64
155
|
rows.push(html`
|
|
65
156
|
<${Box} key=${k()}>
|
|
66
|
-
<${Text} color=${C.accent}>${num[1]}${num[2]}. <//>
|
|
157
|
+
<${Text} color=${C.accent} bold>${num[1]}${num[2]}. <//>
|
|
67
158
|
<${Text}>${renderInline(num[3])}<//>
|
|
68
159
|
<//>`);
|
|
69
160
|
continue;
|
package/src/userdir.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mkdirSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
const BASE = join(homedir(), '.config', 'ispbills-icli');
|
|
6
|
+
|
|
7
|
+
/** A filesystem-safe key identifying the logged-in IspBills user. */
|
|
8
|
+
export function userKey(cfg) {
|
|
9
|
+
const raw = cfg?.user?.email || cfg?.user?.name || (cfg?.user?.id != null ? 'id-' + cfg.user.id : '') || 'default';
|
|
10
|
+
return String(raw).toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 64) || 'default';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Ensure and return a per-user subdirectory under the config dir. */
|
|
14
|
+
export function userDir(cfg, ...parts) {
|
|
15
|
+
const dir = join(BASE, ...parts, userKey(cfg));
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
return dir;
|
|
18
|
+
}
|