@sambitcreate/parsely-cli 2.1.0 → 2.2.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.
@@ -1,9 +1,9 @@
1
1
  import puppeteer from 'puppeteer-core';
2
2
  import * as cheerio from 'cheerio';
3
3
  import OpenAI from 'openai';
4
- import { execSync } from 'child_process';
5
- import { existsSync } from 'fs';
6
- import { loadConfig } from '../utils/helpers.js';
4
+ import { constants as fsConstants } from 'node:fs';
5
+ import { access } from 'node:fs/promises';
6
+ import { loadConfig, normalizeRecipeUrl, sanitizeTerminalText } from '../utils/helpers.js';
7
7
  const BROWSER_ARGS = [
8
8
  '--no-sandbox',
9
9
  '--disable-setuid-sandbox',
@@ -11,6 +11,10 @@ const BROWSER_ARGS = [
11
11
  ];
12
12
  const BROWSER_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
13
13
  '(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36';
14
+ const PAGE_TIMEOUT_MS = 20_000;
15
+ const NETWORK_IDLE_TIMEOUT_MS = 5_000;
16
+ const AI_TIMEOUT_MS = 30_000;
17
+ const AI_SOURCE_LIMIT = 120_000;
14
18
  /* ------------------------------------------------------------------ */
15
19
  /* JSON-LD helpers */
16
20
  /* ------------------------------------------------------------------ */
@@ -63,7 +67,7 @@ function normalizeText(value) {
63
67
  if (!trimmed) {
64
68
  return undefined;
65
69
  }
66
- const $ = cheerio.load(`<body>${trimmed}</body>`);
70
+ const $ = cheerio.load(`<body>${sanitizeTerminalText(trimmed)}</body>`);
67
71
  const text = $('body').text().replace(/\s+/g, ' ').trim();
68
72
  return text || undefined;
69
73
  }
@@ -91,27 +95,46 @@ function normalizeInstruction(value) {
91
95
  }
92
96
  return undefined;
93
97
  }
94
- function normalizeBrowserRecipe(recipe) {
98
+ function normalizeInstructions(value) {
99
+ if (Array.isArray(value)) {
100
+ const steps = value
101
+ .map((step) => normalizeInstruction(step))
102
+ .filter((step) => Boolean(step));
103
+ return steps.length > 0 ? steps : undefined;
104
+ }
105
+ const single = normalizeInstruction(value);
106
+ return single ? [single] : undefined;
107
+ }
108
+ function normalizeRecipePayload(recipe, source) {
95
109
  const recipeIngredient = Array.isArray(recipe.recipeIngredient)
96
110
  ? recipe.recipeIngredient
97
111
  .map((item) => normalizeText(item))
98
112
  .filter((item) => Boolean(item))
99
113
  : undefined;
100
- const recipeInstructions = Array.isArray(recipe.recipeInstructions)
101
- ? recipe.recipeInstructions
102
- .map((step) => normalizeInstruction(step))
103
- .filter((step) => Boolean(step))
104
- : undefined;
105
114
  return {
106
115
  name: normalizeText(recipe.name),
107
- prepTime: typeof recipe.prepTime === 'string' ? recipe.prepTime.trim() : undefined,
108
- cookTime: typeof recipe.cookTime === 'string' ? recipe.cookTime.trim() : undefined,
109
- totalTime: typeof recipe.totalTime === 'string' ? recipe.totalTime.trim() : undefined,
116
+ prepTime: typeof recipe.prepTime === 'string' ? sanitizeTerminalText(recipe.prepTime.trim()) : undefined,
117
+ cookTime: typeof recipe.cookTime === 'string' ? sanitizeTerminalText(recipe.cookTime.trim()) : undefined,
118
+ totalTime: typeof recipe.totalTime === 'string' ? sanitizeTerminalText(recipe.totalTime.trim()) : undefined,
110
119
  recipeIngredient,
111
- recipeInstructions,
112
- source: 'browser',
120
+ recipeInstructions: normalizeInstructions(recipe.recipeInstructions),
121
+ source,
113
122
  };
114
123
  }
124
+ function normalizeBrowserRecipe(recipe) {
125
+ return normalizeRecipePayload(recipe, 'browser');
126
+ }
127
+ export function normalizeAiRecipe(recipe) {
128
+ return normalizeRecipePayload(recipe, 'ai');
129
+ }
130
+ function hasRecipeContent(recipe) {
131
+ return Boolean(recipe.name ||
132
+ recipe.prepTime ||
133
+ recipe.cookTime ||
134
+ recipe.totalTime ||
135
+ recipe.recipeIngredient?.length ||
136
+ recipe.recipeInstructions?.length);
137
+ }
115
138
  export function extractRecipeFromHtml(html) {
116
139
  const $ = cheerio.load(html);
117
140
  const scripts = [];
@@ -134,19 +157,21 @@ const CHROME_PATHS = [
134
157
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
135
158
  '/Applications/Chromium.app/Contents/MacOS/Chromium',
136
159
  ];
137
- function findChrome() {
138
- // Check well-known paths
139
- for (const p of CHROME_PATHS) {
140
- if (existsSync(p))
141
- return p;
142
- }
143
- // Try `which`
144
- try {
145
- const result = execSync('which chromium-browser || which chromium || which google-chrome 2>/dev/null', { encoding: 'utf-8' }).trim();
146
- if (result)
147
- return result;
160
+ async function findChrome() {
161
+ const candidates = [
162
+ process.env['PUPPETEER_EXECUTABLE_PATH'],
163
+ process.env['CHROME_PATH'],
164
+ ...CHROME_PATHS,
165
+ ].filter((path) => Boolean(path));
166
+ for (const candidate of candidates) {
167
+ try {
168
+ await access(candidate, fsConstants.X_OK);
169
+ return candidate;
170
+ }
171
+ catch {
172
+ // Try the next well-known location.
173
+ }
148
174
  }
149
- catch { /* not found */ }
150
175
  return null;
151
176
  }
152
177
  function createAbortError() {
@@ -168,15 +193,66 @@ async function configurePage(page) {
168
193
  Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] });
169
194
  });
170
195
  }
196
+ function createTimedSignal(signal, timeoutMs) {
197
+ const controller = new AbortController();
198
+ if (signal?.aborted) {
199
+ controller.abort();
200
+ }
201
+ const onAbort = () => controller.abort();
202
+ signal?.addEventListener('abort', onAbort, { once: true });
203
+ const timeout = setTimeout(() => {
204
+ controller.abort();
205
+ }, timeoutMs);
206
+ return {
207
+ signal: controller.signal,
208
+ cleanup: () => {
209
+ clearTimeout(timeout);
210
+ signal?.removeEventListener('abort', onAbort);
211
+ },
212
+ };
213
+ }
214
+ function formatTimeoutError(message, signal) {
215
+ return signal?.aborted ? createAbortError() : new Error(message);
216
+ }
217
+ function limitAiSource(value) {
218
+ return value.trim().slice(0, AI_SOURCE_LIMIT);
219
+ }
220
+ async function fetchAiSource(url, signal) {
221
+ throwIfAborted(signal);
222
+ const { signal: timedSignal, cleanup } = createTimedSignal(signal, PAGE_TIMEOUT_MS);
223
+ try {
224
+ const response = await fetch(url, {
225
+ headers: {
226
+ 'accept-language': 'en-US,en;q=0.9',
227
+ 'user-agent': BROWSER_USER_AGENT,
228
+ },
229
+ signal: timedSignal,
230
+ });
231
+ if (!response.ok) {
232
+ throw new Error(`Failed to load recipe page for AI fallback (${response.status})`);
233
+ }
234
+ return limitAiSource(await response.text());
235
+ }
236
+ catch (error) {
237
+ if (timedSignal.aborted) {
238
+ throw formatTimeoutError('Timed out loading recipe page for AI fallback', signal);
239
+ }
240
+ throw error;
241
+ }
242
+ finally {
243
+ cleanup();
244
+ }
245
+ }
171
246
  /* ------------------------------------------------------------------ */
172
247
  /* Scraping strategies */
173
248
  /* ------------------------------------------------------------------ */
174
249
  async function scrapeWithBrowser(url, onStatus, signal) {
175
250
  throwIfAborted(signal);
176
- const chromePath = findChrome();
251
+ const chromePath = await findChrome();
177
252
  if (!chromePath)
178
- return null; // No browser available – skip to AI
253
+ return { recipe: null }; // No browser available – skip to AI
179
254
  let browser = null;
255
+ let settledHtml;
180
256
  const onAbort = async () => {
181
257
  if (browser) {
182
258
  try {
@@ -198,8 +274,8 @@ async function scrapeWithBrowser(url, onStatus, signal) {
198
274
  const page = await browser.newPage();
199
275
  await configurePage(page);
200
276
  onStatus?.({ phase: 'browser', message: 'Loading recipe page…' });
201
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20_000 });
202
- await page.waitForNetworkIdle({ idleTime: 500, timeout: 5_000 }).catch(() => undefined);
277
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: PAGE_TIMEOUT_MS });
278
+ await page.waitForNetworkIdle({ idleTime: 500, timeout: NETWORK_IDLE_TIMEOUT_MS }).catch(() => undefined);
203
279
  throwIfAborted(signal);
204
280
  const html = await page.content();
205
281
  if (containsBrowserChallenge(html)) {
@@ -207,53 +283,77 @@ async function scrapeWithBrowser(url, onStatus, signal) {
207
283
  await page.waitForFunction(() => !document.documentElement.outerHTML.includes('cf_chl'), { timeout: 5_000 }).catch(() => undefined);
208
284
  }
209
285
  throwIfAborted(signal);
210
- const settledHtml = await page.content();
286
+ settledHtml = await page.content();
211
287
  onStatus?.({ phase: 'parsing', message: 'Scanning recipe schema and JSON-LD blocks…' });
212
- await browser.close();
213
- browser = null;
214
- return extractRecipeFromHtml(settledHtml);
288
+ return {
289
+ recipe: extractRecipeFromHtml(settledHtml),
290
+ html: settledHtml,
291
+ };
215
292
  }
216
293
  catch (error) {
217
294
  if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
218
295
  throw createAbortError();
219
296
  }
297
+ onStatus?.({ phase: 'browser', message: 'Browser extraction failed. Preparing AI fallback…' });
298
+ return { recipe: null, html: settledHtml };
299
+ }
300
+ finally {
301
+ signal?.removeEventListener('abort', onAbort);
220
302
  if (browser) {
221
303
  try {
222
304
  await browser.close();
223
305
  }
224
- catch { /* noop */ }
306
+ catch {
307
+ // Ignore close errors during teardown.
308
+ }
225
309
  }
226
- return null;
227
- }
228
- finally {
229
- signal?.removeEventListener('abort', onAbort);
230
310
  }
231
311
  }
232
- async function scrapeWithAI(url, signal) {
312
+ async function scrapeWithAI(url, pageSource, signal) {
233
313
  throwIfAborted(signal);
234
314
  const { openaiApiKey } = loadConfig();
235
315
  if (!openaiApiKey || openaiApiKey === 'YOUR_API_KEY_HERE') {
236
316
  throw new Error('OpenAI API key not found. Create a .env.local file with OPENAI_API_KEY=your_key');
237
317
  }
238
318
  const client = new OpenAI({ apiKey: openaiApiKey });
239
- const response = await client.chat.completions.create({
240
- model: 'gpt-4o-mini',
241
- messages: [
242
- {
243
- role: 'system',
244
- content: 'You are a recipe scraper. Extract cookTime, prepTime, totalTime, ' +
245
- 'recipeIngredient, and recipeInstructions from the provided URL. ' +
246
- 'Return the data in a valid JSON object.',
247
- },
248
- { role: 'user', content: `Scrape this recipe: ${url}` },
249
- ],
250
- response_format: { type: 'json_object' },
251
- }, { signal });
319
+ const { signal: timedSignal, cleanup } = createTimedSignal(signal, AI_TIMEOUT_MS);
320
+ let response;
321
+ try {
322
+ response = await client.chat.completions.create({
323
+ model: 'gpt-4o-mini',
324
+ messages: [
325
+ {
326
+ role: 'system',
327
+ content: 'You extract recipe data from supplied page content. Use only the provided page content. ' +
328
+ 'Return a JSON object with optional name, prepTime, cookTime, totalTime, recipeIngredient, and recipeInstructions fields.',
329
+ },
330
+ {
331
+ role: 'user',
332
+ content: `Recipe URL: ${url}\n\n` +
333
+ 'Page content:\n' +
334
+ pageSource,
335
+ },
336
+ ],
337
+ response_format: { type: 'json_object' },
338
+ }, { signal: timedSignal });
339
+ }
340
+ catch (error) {
341
+ if (timedSignal.aborted) {
342
+ throw formatTimeoutError('AI recipe extraction timed out', signal);
343
+ }
344
+ throw error;
345
+ }
346
+ finally {
347
+ cleanup();
348
+ }
252
349
  const content = response.choices[0]?.message?.content;
253
350
  if (!content)
254
351
  throw new Error('AI returned empty response');
255
- const recipe = JSON.parse(content);
256
- return { ...recipe, source: 'ai' };
352
+ const recipe = normalizeAiRecipe(JSON.parse(content));
353
+ if (!hasRecipeContent(recipe)) {
354
+ throw new Error('AI could not extract recipe data from the page');
355
+ }
356
+ return recipe;
257
357
  }
258
358
  /* ------------------------------------------------------------------ */
259
359
  /* Public orchestrator */
@@ -264,17 +364,26 @@ async function scrapeWithAI(url, signal) {
264
364
  * Calls `onStatus` with progress updates so the TUI can reflect each phase.
265
365
  */
266
366
  export async function scrapeRecipe(url, onStatus, signal) {
367
+ const normalizedUrl = normalizeRecipeUrl(url);
368
+ if (!normalizedUrl) {
369
+ const error = new Error('Invalid URL. Please enter a valid http or https recipe URL.');
370
+ onStatus({ phase: 'error', message: error.message });
371
+ throw error;
372
+ }
267
373
  // Phase 1 – browser scraping
268
374
  onStatus({ phase: 'browser', message: 'Launching browser\u2026' });
269
- const browserResult = await scrapeWithBrowser(url, onStatus, signal);
270
- if (browserResult) {
271
- onStatus({ phase: 'done', message: 'Recipe found!', recipe: browserResult });
272
- return browserResult;
375
+ const browserResult = await scrapeWithBrowser(normalizedUrl, onStatus, signal);
376
+ if (browserResult.recipe) {
377
+ onStatus({ phase: 'done', message: 'Recipe found!', recipe: browserResult.recipe });
378
+ return browserResult.recipe;
273
379
  }
274
380
  // Phase 2 – AI fallback
275
381
  onStatus({ phase: 'ai', message: 'Falling back to AI scraper\u2026' });
276
382
  try {
277
- const aiResult = await scrapeWithAI(url, signal);
383
+ const pageSource = browserResult.html && browserResult.html.trim()
384
+ ? limitAiSource(browserResult.html)
385
+ : await fetchAiSource(normalizedUrl, signal);
386
+ const aiResult = await scrapeWithAI(normalizedUrl, pageSource, signal);
278
387
  onStatus({ phase: 'done', message: 'Recipe extracted via AI!', recipe: aiResult });
279
388
  return aiResult;
280
389
  }
package/dist/theme.d.ts CHANGED
@@ -1,44 +1,91 @@
1
- /**
2
- * Parsely CLI theme - color palette and symbols for the TUI.
3
- * Tuned for a warm, food-forward terminal UI.
4
- */
5
- export declare const theme: {
6
- readonly colors: {
7
- readonly brand: "#009c3f";
8
- readonly primary: "#86c06c";
9
- readonly secondary: "#ffbf69";
10
- readonly accent: "#ff7f50";
11
- readonly text: "#f6f2ea";
12
- readonly muted: "#97a3b0";
13
- readonly subtle: "#536170";
14
- readonly error: "#ff6b6b";
15
- readonly success: "#7bd389";
16
- readonly warning: "#ffd166";
17
- readonly info: "#6ec5ff";
18
- readonly banner: "#f6f2ea";
19
- readonly border: "#3a4654";
20
- readonly borderFocus: "#86c06c";
21
- readonly label: "#ffbf69";
22
- readonly chip: "#24303b";
23
- readonly recipePaper: "#FDFFF7";
24
- readonly recipeText: "#0aa043";
25
- readonly recipeMuted: "#8b9689";
26
- readonly recipeSubtle: "#5f7564";
27
- readonly recipeBorder: "#0aa043";
28
- readonly recipeSoft: "#dcead5";
29
- readonly recipePanel: "#f7fbef";
1
+ export type ThemeMode = 'light' | 'dark';
2
+ declare const themes: {
3
+ readonly light: {
4
+ readonly mode: "light";
5
+ readonly colors: {
6
+ readonly brand: "#009c3f";
7
+ readonly primary: "#0aa043";
8
+ readonly secondary: "#ffbf69";
9
+ readonly accent: "#ff7f50";
10
+ readonly text: "#17311d";
11
+ readonly muted: "#5f7564";
12
+ readonly subtle: "#7a8b7a";
13
+ readonly error: "#c24141";
14
+ readonly success: "#0aa043";
15
+ readonly warning: "#b7791f";
16
+ readonly info: "#2563eb";
17
+ readonly banner: "#009c3f";
18
+ readonly border: "#b7cbb3";
19
+ readonly borderFocus: "#0aa043";
20
+ readonly label: "#b7791f";
21
+ readonly chip: "#e4efe0";
22
+ readonly recipePaper: "#FDFFF7";
23
+ readonly recipeText: "#0aa043";
24
+ readonly recipeMuted: "#5f7564";
25
+ readonly recipeSubtle: "#43684b";
26
+ readonly recipeBorder: "#0aa043";
27
+ readonly recipeSoft: "#dcead5";
28
+ readonly recipePanel: "#f7fbef";
29
+ };
30
+ readonly symbols: {
31
+ readonly bullet: "•";
32
+ readonly arrow: "→";
33
+ readonly check: "✓";
34
+ readonly cross: "✗";
35
+ readonly dot: "·";
36
+ readonly ellipsis: "…";
37
+ readonly line: "─";
38
+ readonly active: "◉";
39
+ readonly pending: "○";
40
+ readonly skip: "−";
41
+ };
30
42
  };
31
- readonly symbols: {
32
- readonly bullet: "";
33
- readonly arrow: "→";
34
- readonly check: "";
35
- readonly cross: "";
36
- readonly dot: "·";
37
- readonly ellipsis: "";
38
- readonly line: "";
39
- readonly active: "";
40
- readonly pending: "";
41
- readonly skip: "";
43
+ readonly dark: {
44
+ readonly mode: "dark";
45
+ readonly colors: {
46
+ readonly brand: "#009c3f";
47
+ readonly primary: "#86c06c";
48
+ readonly secondary: "#ffbf69";
49
+ readonly accent: "#ff7f50";
50
+ readonly text: "#e7eef8";
51
+ readonly muted: "#97a3b0";
52
+ readonly subtle: "#64748b";
53
+ readonly error: "#ff7b7b";
54
+ readonly success: "#7bd389";
55
+ readonly warning: "#ffd166";
56
+ readonly info: "#6ec5ff";
57
+ readonly banner: "#e7eef8";
58
+ readonly border: "#2c394d";
59
+ readonly borderFocus: "#86c06c";
60
+ readonly label: "#ffbf69";
61
+ readonly chip: "#192132";
62
+ readonly recipePaper: "#0F1729";
63
+ readonly recipeText: "#8de58b";
64
+ readonly recipeMuted: "#b7c2d4";
65
+ readonly recipeSubtle: "#8b99ad";
66
+ readonly recipeBorder: "#009c3f";
67
+ readonly recipeSoft: "#2b3c34";
68
+ readonly recipePanel: "#111b30";
69
+ };
70
+ readonly symbols: {
71
+ readonly bullet: "•";
72
+ readonly arrow: "→";
73
+ readonly check: "✓";
74
+ readonly cross: "✗";
75
+ readonly dot: "·";
76
+ readonly ellipsis: "…";
77
+ readonly line: "─";
78
+ readonly active: "◉";
79
+ readonly pending: "○";
80
+ readonly skip: "−";
81
+ };
42
82
  };
43
83
  };
44
- export type Theme = typeof theme;
84
+ export type Theme = (typeof themes)[ThemeMode];
85
+ export declare function resolveInitialThemeMode(env?: NodeJS.ProcessEnv): ThemeMode;
86
+ export declare function detectPreferredThemeMode(env?: NodeJS.ProcessEnv): Promise<ThemeMode>;
87
+ export declare function getTheme(mode: ThemeMode): Theme;
88
+ export declare function setActiveTheme(mode: ThemeMode): Theme;
89
+ export declare function toggleThemeMode(mode: ThemeMode): ThemeMode;
90
+ export declare const theme: Theme;
91
+ export {};
package/dist/theme.js CHANGED
@@ -1,43 +1,125 @@
1
- /**
2
- * Parsely CLI theme - color palette and symbols for the TUI.
3
- * Tuned for a warm, food-forward terminal UI.
4
- */
5
- export const theme = {
6
- colors: {
7
- brand: '#009c3f',
8
- primary: '#86c06c',
9
- secondary: '#ffbf69',
10
- accent: '#ff7f50',
11
- text: '#f6f2ea',
12
- muted: '#97a3b0',
13
- subtle: '#536170',
14
- error: '#ff6b6b',
15
- success: '#7bd389',
16
- warning: '#ffd166',
17
- info: '#6ec5ff',
18
- banner: '#f6f2ea',
19
- border: '#3a4654',
20
- borderFocus: '#86c06c',
21
- label: '#ffbf69',
22
- chip: '#24303b',
23
- recipePaper: '#FDFFF7',
24
- recipeText: '#0aa043',
25
- recipeMuted: '#8b9689',
26
- recipeSubtle: '#5f7564',
27
- recipeBorder: '#0aa043',
28
- recipeSoft: '#dcead5',
29
- recipePanel: '#f7fbef',
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const symbols = {
5
+ bullet: '\u2022',
6
+ arrow: '\u2192',
7
+ check: '\u2713',
8
+ cross: '\u2717',
9
+ dot: '\u00B7',
10
+ ellipsis: '\u2026',
11
+ line: '\u2500',
12
+ active: '\u25c9',
13
+ pending: '\u25cb',
14
+ skip: '\u2212',
15
+ };
16
+ const themes = {
17
+ light: {
18
+ mode: 'light',
19
+ colors: {
20
+ brand: '#009c3f',
21
+ primary: '#0aa043',
22
+ secondary: '#ffbf69',
23
+ accent: '#ff7f50',
24
+ text: '#17311d',
25
+ muted: '#5f7564',
26
+ subtle: '#7a8b7a',
27
+ error: '#c24141',
28
+ success: '#0aa043',
29
+ warning: '#b7791f',
30
+ info: '#2563eb',
31
+ banner: '#009c3f',
32
+ border: '#b7cbb3',
33
+ borderFocus: '#0aa043',
34
+ label: '#b7791f',
35
+ chip: '#e4efe0',
36
+ recipePaper: '#FDFFF7',
37
+ recipeText: '#0aa043',
38
+ recipeMuted: '#5f7564',
39
+ recipeSubtle: '#43684b',
40
+ recipeBorder: '#0aa043',
41
+ recipeSoft: '#dcead5',
42
+ recipePanel: '#f7fbef',
43
+ },
44
+ symbols,
30
45
  },
31
- symbols: {
32
- bullet: '\u2022',
33
- arrow: '\u2192',
34
- check: '\u2713',
35
- cross: '\u2717',
36
- dot: '\u00B7',
37
- ellipsis: '\u2026',
38
- line: '\u2500',
39
- active: '\u25c9',
40
- pending: '\u25cb',
41
- skip: '\u2212',
46
+ dark: {
47
+ mode: 'dark',
48
+ colors: {
49
+ brand: '#009c3f',
50
+ primary: '#86c06c',
51
+ secondary: '#ffbf69',
52
+ accent: '#ff7f50',
53
+ text: '#e7eef8',
54
+ muted: '#97a3b0',
55
+ subtle: '#64748b',
56
+ error: '#ff7b7b',
57
+ success: '#7bd389',
58
+ warning: '#ffd166',
59
+ info: '#6ec5ff',
60
+ banner: '#e7eef8',
61
+ border: '#2c394d',
62
+ borderFocus: '#86c06c',
63
+ label: '#ffbf69',
64
+ chip: '#192132',
65
+ recipePaper: '#0F1729',
66
+ recipeText: '#8de58b',
67
+ recipeMuted: '#b7c2d4',
68
+ recipeSubtle: '#8b99ad',
69
+ recipeBorder: '#009c3f',
70
+ recipeSoft: '#2b3c34',
71
+ recipePanel: '#111b30',
72
+ },
73
+ symbols,
42
74
  },
43
75
  };
76
+ function inferThemeModeFromColorEnv(env) {
77
+ const colorfgbg = env['COLORFGBG'];
78
+ if (!colorfgbg) {
79
+ return null;
80
+ }
81
+ const backgroundCode = Number.parseInt(colorfgbg.split(';').at(-1) ?? '', 10);
82
+ if (!Number.isFinite(backgroundCode)) {
83
+ return null;
84
+ }
85
+ return backgroundCode <= 6 || backgroundCode === 8 ? 'dark' : 'light';
86
+ }
87
+ export function resolveInitialThemeMode(env = process.env) {
88
+ const override = env['PARSELY_THEME'];
89
+ if (override === 'light' || override === 'dark') {
90
+ return override;
91
+ }
92
+ return inferThemeModeFromColorEnv(env) ?? 'light';
93
+ }
94
+ export async function detectPreferredThemeMode(env = process.env) {
95
+ const initial = resolveInitialThemeMode(env);
96
+ if (env['PARSELY_THEME'] === 'light' || env['PARSELY_THEME'] === 'dark' || env['COLORFGBG']) {
97
+ return initial;
98
+ }
99
+ if (process.platform !== 'darwin') {
100
+ return initial;
101
+ }
102
+ try {
103
+ const { stdout } = await execFileAsync('defaults', ['read', '-g', 'AppleInterfaceStyle']);
104
+ return stdout.trim() === 'Dark' ? 'dark' : 'light';
105
+ }
106
+ catch {
107
+ return initial;
108
+ }
109
+ }
110
+ let activeTheme = themes[resolveInitialThemeMode()];
111
+ export function getTheme(mode) {
112
+ return themes[mode];
113
+ }
114
+ export function setActiveTheme(mode) {
115
+ activeTheme = themes[mode];
116
+ return activeTheme;
117
+ }
118
+ export function toggleThemeMode(mode) {
119
+ return mode === 'dark' ? 'light' : 'dark';
120
+ }
121
+ export const theme = new Proxy({}, {
122
+ get(_target, property) {
123
+ return activeTheme[property];
124
+ },
125
+ });
@@ -13,6 +13,7 @@ export declare function formatMinutes(mins: number): string;
13
13
  export declare function loadConfig(): {
14
14
  openaiApiKey?: string;
15
15
  };
16
+ export declare function sanitizeTerminalText(input: string): string;
16
17
  export declare function sanitizeSingleLineInput(input: string): string;
17
18
  export declare function normalizeRecipeUrl(input: string): string | null;
18
19
  export declare function getUrlHost(url?: string): string;