vexi-cli 0.5.1

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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Streaming client for OpenAI-compatible chat completion APIs.
3
+ *
4
+ * Used for: OpenAI, OpenRouter, Groq, and Google Gemini (via its
5
+ * OpenAI-compatibility endpoint). All of these speak the same
6
+ * `POST /chat/completions` + Server-Sent Events protocol, which keeps
7
+ * Vexi dependency-free (plain `fetch`, no SDKs).
8
+ */
9
+ import { ProviderError } from './types.js';
10
+ export function createOpenAICompatProvider(opts) {
11
+ return {
12
+ id: opts.id,
13
+ model: opts.model,
14
+ async stream(messages, onText) {
15
+ const res = await fetch(`${opts.baseUrl}/chat/completions`, {
16
+ method: 'POST',
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ Authorization: `Bearer ${opts.apiKey}`,
20
+ ...opts.extraHeaders,
21
+ },
22
+ body: JSON.stringify({
23
+ model: opts.model,
24
+ messages,
25
+ stream: true,
26
+ }),
27
+ }).catch((err) => {
28
+ throw new ProviderError(`Network error: ${err.message}`);
29
+ });
30
+ if (!res.ok || !res.body) {
31
+ const body = await res.text().catch(() => '');
32
+ throw new ProviderError(`${opts.id} API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
33
+ }
34
+ let full = '';
35
+ for await (const data of sseEvents(res.body)) {
36
+ if (data === '[DONE]')
37
+ break;
38
+ try {
39
+ const json = JSON.parse(data);
40
+ const text = json.choices?.[0]?.delta?.content;
41
+ if (text) {
42
+ full += text;
43
+ onText(text);
44
+ }
45
+ }
46
+ catch {
47
+ // Ignore malformed/keep-alive chunks
48
+ }
49
+ }
50
+ return full;
51
+ },
52
+ };
53
+ }
54
+ /**
55
+ * Parse a Server-Sent Events byte stream and yield each `data:` payload.
56
+ * Shared by all providers (exported for the Anthropic client too).
57
+ */
58
+ export async function* sseEvents(body) {
59
+ const reader = body.getReader();
60
+ const decoder = new TextDecoder();
61
+ let buffer = '';
62
+ try {
63
+ while (true) {
64
+ const { done, value } = await reader.read();
65
+ if (done)
66
+ break;
67
+ buffer += decoder.decode(value, { stream: true });
68
+ let newlineIndex;
69
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
70
+ const line = buffer.slice(0, newlineIndex).trim();
71
+ buffer = buffer.slice(newlineIndex + 1);
72
+ if (line.startsWith('data:')) {
73
+ yield line.slice(5).trim();
74
+ }
75
+ }
76
+ }
77
+ }
78
+ finally {
79
+ reader.releaseLock();
80
+ }
81
+ }
82
+ export function truncate(text, max) {
83
+ return text.length > max ? `${text.slice(0, max)}…` : text;
84
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared types for AI providers.
3
+ */
4
+ /** Error thrown when a provider API call fails. */
5
+ export class ProviderError extends Error {
6
+ status;
7
+ constructor(message, status) {
8
+ super(message);
9
+ this.status = status;
10
+ this.name = 'ProviderError';
11
+ }
12
+ /** True when the API key is invalid / unauthorized. */
13
+ get isAuthError() {
14
+ return this.status === 401 || this.status === 403;
15
+ }
16
+ }
17
+ /** Human-friendly provider metadata. */
18
+ export const PROVIDER_INFO = {
19
+ anthropic: { label: 'Anthropic (Claude)', defaultModel: 'claude-sonnet-4-5' },
20
+ openai: { label: 'OpenAI (GPT)', defaultModel: 'gpt-4o-mini' },
21
+ openrouter: { label: 'OpenRouter', defaultModel: 'openrouter/auto' },
22
+ groq: { label: 'Groq', defaultModel: 'llama-3.3-70b-versatile' },
23
+ gemini: { label: 'Google Gemini', defaultModel: 'gemini-2.5-flash' },
24
+ };
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Vexi Replay — standalone HTML export.
3
+ *
4
+ * `vexi replay --export` turns a recorded session into a single .html file:
5
+ * - playback controls (play/pause, 1x/2x/4x speed)
6
+ * - messages appear with their real timing, code typed character by character
7
+ * - session summary at the end (duration, messages, model)
8
+ * - "Export video" button using browser-only APIs (getDisplayMedia +
9
+ * MediaRecorder) — no ffmpeg/ImageMagick in the CLI, keeping the npm
10
+ * package lightweight
11
+ * - full RTL support when exported with --lang ar (dir="rtl")
12
+ *
13
+ * The session JSON is embedded in the page and rendered via textContent,
14
+ * so message content can never inject markup (XSS-safe).
15
+ */
16
+ import { promises as fs } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { loadSession } from './recorder.js';
19
+ /** UI labels for the generated page, per export language. */
20
+ const LABELS = {
21
+ en: {
22
+ title: 'Vexi session replay',
23
+ play: 'Play',
24
+ pause: 'Pause',
25
+ restart: 'Restart',
26
+ speed: 'Speed',
27
+ you: 'you',
28
+ vexi: 'vexi',
29
+ summary: 'Session summary',
30
+ duration: 'Duration',
31
+ messages: 'Messages',
32
+ model: 'Model',
33
+ project: 'Project',
34
+ exportVideo: 'Export video',
35
+ exportHint: 'Pick this tab in the screen picker, press Play, then Stop sharing when done.',
36
+ install: 'Replay your own coding sessions:',
37
+ },
38
+ es: {
39
+ title: 'Repetición de sesión de Vexi',
40
+ play: 'Reproducir',
41
+ pause: 'Pausa',
42
+ restart: 'Reiniciar',
43
+ speed: 'Velocidad',
44
+ you: 'tú',
45
+ vexi: 'vexi',
46
+ summary: 'Resumen de la sesión',
47
+ duration: 'Duración',
48
+ messages: 'Mensajes',
49
+ model: 'Modelo',
50
+ project: 'Proyecto',
51
+ exportVideo: 'Exportar vídeo',
52
+ exportHint: 'Elige esta pestaña en el selector de pantalla, pulsa Reproducir y detén la compartición al terminar.',
53
+ install: 'Reproduce tus propias sesiones de programación:',
54
+ },
55
+ pt: {
56
+ title: 'Replay de sessão do Vexi',
57
+ play: 'Reproduzir',
58
+ pause: 'Pausar',
59
+ restart: 'Reiniciar',
60
+ speed: 'Velocidade',
61
+ you: 'você',
62
+ vexi: 'vexi',
63
+ summary: 'Resumo da sessão',
64
+ duration: 'Duração',
65
+ messages: 'Mensagens',
66
+ model: 'Modelo',
67
+ project: 'Projeto',
68
+ exportVideo: 'Exportar vídeo',
69
+ exportHint: 'Escolha esta aba no seletor de tela, pressione Reproduzir e pare o compartilhamento ao final.',
70
+ install: 'Reproduza suas próprias sessões de programação:',
71
+ },
72
+ fr: {
73
+ title: 'Relecture de session Vexi',
74
+ play: 'Lecture',
75
+ pause: 'Pause',
76
+ restart: 'Recommencer',
77
+ speed: 'Vitesse',
78
+ you: 'vous',
79
+ vexi: 'vexi',
80
+ summary: 'Résumé de la session',
81
+ duration: 'Durée',
82
+ messages: 'Messages',
83
+ model: 'Modèle',
84
+ project: 'Projet',
85
+ exportVideo: 'Exporter la vidéo',
86
+ exportHint: 'Choisissez cet onglet dans le sélecteur d\'écran, lancez la lecture, puis arrêtez le partage à la fin.',
87
+ install: 'Rejouez vos propres sessions de codage :',
88
+ },
89
+ ar: {
90
+ title: 'إعادة تشغيل جلسة Vexi',
91
+ play: 'تشغيل',
92
+ pause: 'إيقاف مؤقت',
93
+ restart: 'إعادة',
94
+ speed: 'السرعة',
95
+ you: 'أنت',
96
+ vexi: 'vexi',
97
+ summary: 'ملخص الجلسة',
98
+ duration: 'المدة',
99
+ messages: 'الرسائل',
100
+ model: 'النموذج',
101
+ project: 'المشروع',
102
+ exportVideo: 'تصدير فيديو',
103
+ exportHint: 'اختر هذا التبويب في نافذة مشاركة الشاشة، اضغط تشغيل، ثم أوقف المشاركة عند الانتهاء.',
104
+ install: 'أعد تشغيل جلسات البرمجة الخاصة بك:',
105
+ },
106
+ };
107
+ /** Export a recorded session as a standalone HTML file. Returns the path. */
108
+ export async function exportReplay(root, opts) {
109
+ const loaded = await loadSession(root, opts.session);
110
+ if (!loaded) {
111
+ throw new Error('No recorded sessions found in .vexi/sessions/ — chat with Vexi first.');
112
+ }
113
+ const html = buildReplayHtml(loaded.session, opts.lang);
114
+ const out = opts.out ?? join(process.cwd(), `vexi-replay-${loaded.name.replace(/\.json$/, '')}.html`);
115
+ await fs.writeFile(out, html, 'utf8');
116
+ return out;
117
+ }
118
+ /** Build the standalone replay HTML document. */
119
+ export function buildReplayHtml(session, lang) {
120
+ const L = LABELS[lang];
121
+ const rtl = lang === 'ar';
122
+ const last = session.events[session.events.length - 1];
123
+ const durationMs = last ? last.at : 0;
124
+ // Embed data safely: escape `<` so `</script>` in content can't close the tag.
125
+ const data = JSON.stringify({ session, durationMs }).replaceAll('<', '\\u003c');
126
+ return `<!DOCTYPE html>
127
+ <html lang="${lang}" dir="${rtl ? 'rtl' : 'ltr'}">
128
+ <head>
129
+ <meta charset="utf-8">
130
+ <meta name="viewport" content="width=device-width, initial-scale=1">
131
+ <title>${L.title} — ${escapeHtml(session.project)}</title>
132
+ <style>
133
+ :root { --accent: #2979FF; --bg: #0a0a0f; --panel: #12121a; --text: #e8e8f0; --dim: #8888a0; }
134
+ * { box-sizing: border-box; }
135
+ body { margin: 0; background: var(--bg); color: var(--text);
136
+ font: 15px/1.6 ui-monospace, 'Cascadia Code', Consolas, Menlo, monospace; }
137
+ header { padding: 24px 20px 8px; text-align: center; }
138
+ header h1 { color: var(--accent); margin: 0 0 4px; font-size: 22px; letter-spacing: 2px; }
139
+ header .meta { color: var(--dim); font-size: 12px; }
140
+ #controls { position: sticky; top: 0; display: flex; gap: 8px; justify-content: center;
141
+ align-items: center; padding: 12px; background: rgba(10,10,15,.92);
142
+ backdrop-filter: blur(6px); z-index: 5; }
143
+ button, select { background: var(--panel); color: var(--text); border: 1px solid #2a2a3a;
144
+ border-radius: 8px; padding: 8px 16px; font: inherit; cursor: pointer; }
145
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 700; }
146
+ button:hover { border-color: var(--accent); }
147
+ #bar { height: 3px; background: #1c1c28; }
148
+ #bar > div { height: 100%; width: 0; background: var(--accent); transition: width .2s linear; }
149
+ main { max-width: 860px; margin: 0 auto; padding: 24px 16px 80px; }
150
+ .msg { margin: 18px 0; opacity: 0; transform: translateY(8px); transition: all .3s ease; }
151
+ .msg.shown { opacity: 1; transform: none; }
152
+ .msg .who { font-size: 11px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 6px; }
153
+ .msg.user .who { color: #9aa6ff; }
154
+ .msg.assistant .who { color: var(--accent); }
155
+ .msg .body { background: var(--panel); border: 1px solid #1e1e2c; border-radius: 12px;
156
+ padding: 14px 16px; white-space: pre-wrap; word-wrap: break-word; }
157
+ .msg.user .body { border-inline-start: 3px solid #9aa6ff; }
158
+ .msg.assistant .body { border-inline-start: 3px solid var(--accent); }
159
+ .caret { display: inline-block; width: 8px; background: var(--accent); animation: blink .8s infinite; }
160
+ @keyframes blink { 50% { opacity: 0; } }
161
+ #summary { display: none; margin-top: 36px; border: 1px solid var(--accent); border-radius: 12px;
162
+ padding: 20px; background: var(--panel); }
163
+ #summary h2 { color: var(--accent); margin: 0 0 12px; font-size: 16px; }
164
+ #summary table { width: 100%; border-collapse: collapse; }
165
+ #summary td { padding: 4px 0; } #summary td:first-child { color: var(--dim); width: 40%; }
166
+ footer { text-align: center; padding: 28px 16px 40px; color: var(--dim); font-size: 13px; }
167
+ footer code { display: inline-block; margin-top: 8px; background: var(--panel); color: var(--accent);
168
+ border: 1px solid #2a2a3a; border-radius: 8px; padding: 8px 18px; font-weight: 700; }
169
+ #hint { text-align: center; color: var(--dim); font-size: 12px; padding: 0 16px; }
170
+ </style>
171
+ </head>
172
+ <body>
173
+ <header>
174
+ <h1>VEXI</h1>
175
+ <div class="meta">${L.title} · ${escapeHtml(session.project)} · ${escapeHtml(session.model)}</div>
176
+ </header>
177
+ <div id="controls">
178
+ <button id="play" class="primary">▶ ${L.play}</button>
179
+ <button id="restart">⟲ ${L.restart}</button>
180
+ <select id="speed" aria-label="${L.speed}">
181
+ <option value="1">1×</option><option value="2">2×</option><option value="4">4×</option>
182
+ </select>
183
+ <button id="record">⏺ ${L.exportVideo}</button>
184
+ </div>
185
+ <div id="bar"><div></div></div>
186
+ <p id="hint">${L.exportHint}</p>
187
+ <main id="feed"></main>
188
+ <main>
189
+ <section id="summary">
190
+ <h2>${L.summary}</h2>
191
+ <table>
192
+ <tr><td>${L.project}</td><td id="s-project"></td></tr>
193
+ <tr><td>${L.model}</td><td id="s-model"></td></tr>
194
+ <tr><td>${L.messages}</td><td id="s-messages"></td></tr>
195
+ <tr><td>${L.duration}</td><td id="s-duration"></td></tr>
196
+ </table>
197
+ </section>
198
+ </main>
199
+ <footer>
200
+ ${L.install}
201
+ <br><code>npm install -g vexi</code>
202
+ <br><a href="https://github.com/Elomami1976/vexi" style="color:var(--dim)">github.com/Elomami1976/vexi</a>
203
+ </footer>
204
+ <script>
205
+ const DATA = ${data};
206
+ const LBL = { play: ${JSON.stringify('▶ ' + L.play)}, pause: ${JSON.stringify('⏸ ' + L.pause)}, you: ${JSON.stringify(L.you)}, vexi: ${JSON.stringify(L.vexi)} };
207
+ const feed = document.getElementById('feed');
208
+ const bar = document.querySelector('#bar > div');
209
+ const playBtn = document.getElementById('play');
210
+ const events = DATA.session.events;
211
+
212
+ // Replay timing: real inter-message gaps, capped so playback stays snappy.
213
+ const MAX_GAP = 1800, TYPE_MS = 12, MAX_TYPE = 6000;
214
+ let playing = false, idx = 0, timer = null, abortTyping = null;
215
+
216
+ function speed() { return Number(document.getElementById('speed').value); }
217
+
218
+ function addMessage(ev, done) {
219
+ const div = document.createElement('div');
220
+ div.className = 'msg ' + ev.role;
221
+ const who = document.createElement('div');
222
+ who.className = 'who';
223
+ who.textContent = ev.role === 'user' ? LBL.you : LBL.vexi;
224
+ const body = document.createElement('div');
225
+ body.className = 'body';
226
+ div.append(who, body);
227
+ feed.append(div);
228
+ requestAnimationFrame(() => div.classList.add('shown'));
229
+
230
+ // Typing effect, character by character (textContent = XSS-safe)
231
+ const text = ev.content;
232
+ const perChar = Math.min(TYPE_MS, MAX_TYPE / Math.max(text.length, 1)) / speed();
233
+ let i = 0, cancelled = false;
234
+ abortTyping = () => { cancelled = true; body.textContent = text; };
235
+ const caret = document.createElement('span');
236
+ caret.className = 'caret';
237
+ caret.textContent = '\\u00a0';
238
+ body.append(caret);
239
+ (function type() {
240
+ if (cancelled) { caret.remove(); return done(); }
241
+ const step = Math.max(1, Math.round(2 * speed()));
242
+ i = Math.min(text.length, i + step);
243
+ body.textContent = text.slice(0, i);
244
+ if (i < text.length) { body.append(caret); setTimeout(type, perChar * step); }
245
+ else { caret.remove(); done(); }
246
+ })();
247
+ div.scrollIntoView({ behavior: 'smooth', block: 'end' });
248
+ }
249
+
250
+ function next() {
251
+ if (!playing) return;
252
+ if (idx >= events.length) return finish();
253
+ bar.style.width = (idx / events.length * 100) + '%';
254
+ const ev = events[idx];
255
+ const prev = idx > 0 ? events[idx - 1].at : 0;
256
+ const gap = Math.min(Math.max(ev.at - prev, 200), MAX_GAP) / speed();
257
+ timer = setTimeout(() => addMessage(ev, () => { idx++; next(); }), gap);
258
+ }
259
+
260
+ function finish() {
261
+ playing = false;
262
+ playBtn.textContent = LBL.play;
263
+ bar.style.width = '100%';
264
+ const s = document.getElementById('summary');
265
+ s.style.display = 'block';
266
+ document.getElementById('s-project').textContent = DATA.session.project;
267
+ document.getElementById('s-model').textContent = DATA.session.model + ' (' + DATA.session.provider + ')';
268
+ document.getElementById('s-messages').textContent = events.length;
269
+ const sec = Math.round(DATA.durationMs / 1000);
270
+ document.getElementById('s-duration').textContent = Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
271
+ s.scrollIntoView({ behavior: 'smooth' });
272
+ }
273
+
274
+ playBtn.onclick = () => {
275
+ playing = !playing;
276
+ playBtn.textContent = playing ? LBL.pause : LBL.play;
277
+ if (playing) next(); else { clearTimeout(timer); if (abortTyping) abortTyping(); }
278
+ };
279
+ document.getElementById('restart').onclick = () => {
280
+ clearTimeout(timer); if (abortTyping) abortTyping();
281
+ feed.innerHTML = ''; idx = 0; bar.style.width = '0';
282
+ document.getElementById('summary').style.display = 'none';
283
+ playing = true; playBtn.textContent = LBL.pause; next();
284
+ };
285
+
286
+ // Browser-side video export (MediaRecorder + getDisplayMedia) — zero CLI deps.
287
+ document.getElementById('record').onclick = async () => {
288
+ try {
289
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: false });
290
+ const rec = new MediaRecorder(stream, { mimeType: 'video/webm' });
291
+ const chunks = [];
292
+ rec.ondataavailable = (e) => chunks.push(e.data);
293
+ rec.onstop = () => {
294
+ stream.getTracks().forEach((t) => t.stop());
295
+ const url = URL.createObjectURL(new Blob(chunks, { type: 'video/webm' }));
296
+ const a = document.createElement('a');
297
+ a.href = url; a.download = 'vexi-replay.webm'; a.click();
298
+ URL.revokeObjectURL(url);
299
+ };
300
+ stream.getVideoTracks()[0].addEventListener('ended', () => rec.stop());
301
+ rec.start();
302
+ } catch { /* user cancelled the picker */ }
303
+ };
304
+ </script>
305
+ </body>
306
+ </html>
307
+ `;
308
+ }
309
+ function escapeHtml(text) {
310
+ return text
311
+ .replaceAll('&', '&amp;')
312
+ .replaceAll('<', '&lt;')
313
+ .replaceAll('>', '&gt;')
314
+ .replaceAll('"', '&quot;');
315
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Session recorder — Feature 3 (Vexi Replay), recording side.
3
+ *
4
+ * Every chat session is automatically recorded to
5
+ * `.vexi/sessions/YYYY-MM-DD-HHmm.json`: user messages, AI responses and
6
+ * timestamps. Files are written atomically after every turn so a crash
7
+ * never loses more than the in-flight message.
8
+ *
9
+ * The export side (standalone HTML with playback) lives in ./export.ts.
10
+ */
11
+ import { promises as fs } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { writeJsonAtomic, readJson } from '../utils/fs-atomic.js';
14
+ function sessionsDir(root) {
15
+ return join(root, '.vexi', 'sessions');
16
+ }
17
+ /** `2026-06-10-1432` style stamp for session file names. */
18
+ function stamp(date) {
19
+ const p = (n) => String(n).padStart(2, '0');
20
+ return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}`;
21
+ }
22
+ export class SessionRecorder {
23
+ record;
24
+ path;
25
+ t0;
26
+ dirty = false;
27
+ saving = false;
28
+ constructor(root, meta) {
29
+ const now = new Date();
30
+ this.t0 = now.getTime();
31
+ this.record = {
32
+ version: 1,
33
+ startedAt: now.toISOString(),
34
+ ...meta,
35
+ events: [],
36
+ };
37
+ this.path = join(sessionsDir(root), `${stamp(now)}.json`);
38
+ }
39
+ /** Record one message. Call `save()` afterwards (it's debounced). */
40
+ add(role, content) {
41
+ this.record.events.push({ role, content, at: Date.now() - this.t0 });
42
+ this.dirty = true;
43
+ }
44
+ /**
45
+ * Persist to disk (atomic). Serialized so concurrent calls can't race;
46
+ * best-effort — recording must never break the chat loop.
47
+ */
48
+ async save() {
49
+ if (!this.dirty || this.saving || this.record.events.length === 0)
50
+ return;
51
+ this.saving = true;
52
+ this.dirty = false;
53
+ try {
54
+ await writeJsonAtomic(this.path, this.record);
55
+ }
56
+ catch {
57
+ // best effort
58
+ }
59
+ finally {
60
+ this.saving = false;
61
+ }
62
+ }
63
+ }
64
+ /** List recorded session file names (newest first). */
65
+ export async function listSessions(root) {
66
+ const entries = await fs.readdir(sessionsDir(root)).catch(() => []);
67
+ return entries
68
+ .filter((f) => f.endsWith('.json'))
69
+ .sort()
70
+ .reverse();
71
+ }
72
+ /** Load a session by file name, or the most recent one when omitted. */
73
+ export async function loadSession(root, name) {
74
+ const file = name ?? (await listSessions(root))[0];
75
+ if (!file)
76
+ return null;
77
+ const session = await readJson(join(sessionsDir(root), file));
78
+ if (!session || session.version !== 1 || !Array.isArray(session.events))
79
+ return null;
80
+ return { name: file, session };
81
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Minimal .gitignore matcher.
3
+ *
4
+ * Supports the common subset of gitignore syntax used in real projects:
5
+ * comments, blank lines, `dir/` patterns, leading `/` anchors, `*` and `?`
6
+ * wildcards, `**` globstars, and `!` negation. Paths are matched relative
7
+ * to the project root using forward slashes.
8
+ */
9
+ export class GitignoreMatcher {
10
+ rules = [];
11
+ /** Parse the contents of a .gitignore file and add its rules. */
12
+ add(content) {
13
+ for (const rawLine of content.split(/\r?\n/)) {
14
+ const line = rawLine.trim();
15
+ if (!line || line.startsWith('#'))
16
+ continue;
17
+ let pattern = line;
18
+ let negated = false;
19
+ if (pattern.startsWith('!')) {
20
+ negated = true;
21
+ pattern = pattern.slice(1);
22
+ }
23
+ let dirOnly = false;
24
+ if (pattern.endsWith('/')) {
25
+ dirOnly = true;
26
+ pattern = pattern.slice(0, -1);
27
+ }
28
+ // A pattern containing a slash is anchored to the root;
29
+ // otherwise it matches at any depth.
30
+ const anchored = pattern.startsWith('/') || pattern.slice(0, -1).includes('/');
31
+ if (pattern.startsWith('/'))
32
+ pattern = pattern.slice(1);
33
+ const regex = globToRegex(pattern, anchored);
34
+ if (regex)
35
+ this.rules.push({ regex, negated, dirOnly });
36
+ }
37
+ }
38
+ /**
39
+ * Test whether a relative path (forward slashes, no leading slash)
40
+ * is ignored. `isDir` enables `dir/`-only rules.
41
+ */
42
+ ignores(relPath, isDir) {
43
+ let ignored = false;
44
+ for (const rule of this.rules) {
45
+ if (rule.dirOnly && !isDir)
46
+ continue;
47
+ if (rule.regex.test(relPath))
48
+ ignored = !rule.negated;
49
+ }
50
+ return ignored;
51
+ }
52
+ }
53
+ /** Convert a gitignore glob to a RegExp. Returns null for unusable patterns. */
54
+ function globToRegex(glob, anchored) {
55
+ if (!glob)
56
+ return null;
57
+ let re = '';
58
+ let i = 0;
59
+ while (i < glob.length) {
60
+ const ch = glob[i];
61
+ if (ch === '*') {
62
+ if (glob[i + 1] === '*') {
63
+ // `**` matches across directory separators
64
+ re += '.*';
65
+ i += 2;
66
+ if (glob[i] === '/')
67
+ i++; // collapse `**/`
68
+ continue;
69
+ }
70
+ re += '[^/]*';
71
+ }
72
+ else if (ch === '?') {
73
+ re += '[^/]';
74
+ }
75
+ else {
76
+ re += escapeRegex(ch);
77
+ }
78
+ i++;
79
+ }
80
+ // Unanchored patterns match at any depth; all patterns also match
81
+ // everything underneath a matched directory.
82
+ const prefix = anchored ? '^' : '(^|/)';
83
+ try {
84
+ return new RegExp(`${prefix}${re}(/|$)`);
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ function escapeRegex(ch) {
91
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
92
+ }