@sambitcreate/parsely-cli 2.1.0 → 2.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.
@@ -24,6 +24,7 @@ export interface ScrapeStatus {
24
24
  */
25
25
  export declare function findRecipeJson(scripts: string[]): Record<string, unknown> | null;
26
26
  export declare function containsBrowserChallenge(html: string): boolean;
27
+ export declare function normalizeAiRecipe(recipe: Record<string, unknown>): Recipe;
27
28
  export declare function extractRecipeFromHtml(html: string): Recipe | null;
28
29
  /**
29
30
  * Scrape a recipe from the given URL.
@@ -1,16 +1,20 @@
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',
10
10
  '--disable-blink-features=AutomationControlled',
11
11
  ];
12
12
  const BROWSER_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
13
- '(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36';
13
+ '(KHTML, like Gecko) Chrome/146.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() {
@@ -165,18 +190,71 @@ async function configurePage(page) {
165
190
  await page.evaluateOnNewDocument(() => {
166
191
  Object.defineProperty(navigator, 'webdriver', { get: () => false });
167
192
  Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
168
- Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3] });
193
+ Object.defineProperty(navigator, 'plugins', {
194
+ get: () => Object.assign([{}, {}, {}], { length: 3, refresh: () => { } }),
195
+ });
169
196
  });
170
197
  }
198
+ function createTimedSignal(signal, timeoutMs) {
199
+ const controller = new AbortController();
200
+ if (signal?.aborted) {
201
+ controller.abort();
202
+ }
203
+ const onAbort = () => controller.abort();
204
+ signal?.addEventListener('abort', onAbort, { once: true });
205
+ const timeout = setTimeout(() => {
206
+ controller.abort();
207
+ }, timeoutMs);
208
+ return {
209
+ signal: controller.signal,
210
+ cleanup: () => {
211
+ clearTimeout(timeout);
212
+ signal?.removeEventListener('abort', onAbort);
213
+ },
214
+ };
215
+ }
216
+ function formatTimeoutError(message, signal) {
217
+ return signal?.aborted ? createAbortError() : new Error(message);
218
+ }
219
+ function limitAiSource(value) {
220
+ return value.trim().slice(0, AI_SOURCE_LIMIT);
221
+ }
222
+ async function fetchAiSource(url, signal) {
223
+ throwIfAborted(signal);
224
+ const { signal: timedSignal, cleanup } = createTimedSignal(signal, PAGE_TIMEOUT_MS);
225
+ try {
226
+ const response = await fetch(url, {
227
+ headers: {
228
+ 'accept-language': 'en-US,en;q=0.9',
229
+ 'user-agent': BROWSER_USER_AGENT,
230
+ },
231
+ signal: timedSignal,
232
+ });
233
+ if (!response.ok) {
234
+ throw new Error(`Failed to load recipe page for AI fallback (${response.status})`);
235
+ }
236
+ return limitAiSource(await response.text());
237
+ }
238
+ catch (error) {
239
+ if (timedSignal.aborted) {
240
+ throw formatTimeoutError('Timed out loading recipe page for AI fallback', signal);
241
+ }
242
+ throw error;
243
+ }
244
+ finally {
245
+ cleanup();
246
+ }
247
+ }
171
248
  /* ------------------------------------------------------------------ */
172
249
  /* Scraping strategies */
173
250
  /* ------------------------------------------------------------------ */
174
251
  async function scrapeWithBrowser(url, onStatus, signal) {
175
252
  throwIfAborted(signal);
176
- const chromePath = findChrome();
253
+ const chromePath = await findChrome();
177
254
  if (!chromePath)
178
- return null; // No browser available – skip to AI
255
+ return { recipe: null }; // No browser available – skip to AI
179
256
  let browser = null;
257
+ let settledHtml;
180
258
  const onAbort = async () => {
181
259
  if (browser) {
182
260
  try {
@@ -198,8 +276,8 @@ async function scrapeWithBrowser(url, onStatus, signal) {
198
276
  const page = await browser.newPage();
199
277
  await configurePage(page);
200
278
  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);
279
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: PAGE_TIMEOUT_MS });
280
+ await page.waitForNetworkIdle({ idleTime: 500, timeout: NETWORK_IDLE_TIMEOUT_MS }).catch(() => undefined);
203
281
  throwIfAborted(signal);
204
282
  const html = await page.content();
205
283
  if (containsBrowserChallenge(html)) {
@@ -207,53 +285,77 @@ async function scrapeWithBrowser(url, onStatus, signal) {
207
285
  await page.waitForFunction(() => !document.documentElement.outerHTML.includes('cf_chl'), { timeout: 5_000 }).catch(() => undefined);
208
286
  }
209
287
  throwIfAborted(signal);
210
- const settledHtml = await page.content();
288
+ settledHtml = await page.content();
211
289
  onStatus?.({ phase: 'parsing', message: 'Scanning recipe schema and JSON-LD blocks…' });
212
- await browser.close();
213
- browser = null;
214
- return extractRecipeFromHtml(settledHtml);
290
+ return {
291
+ recipe: extractRecipeFromHtml(settledHtml),
292
+ html: settledHtml,
293
+ };
215
294
  }
216
295
  catch (error) {
217
296
  if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
218
297
  throw createAbortError();
219
298
  }
299
+ onStatus?.({ phase: 'browser', message: 'Browser extraction failed. Preparing AI fallback…' });
300
+ return { recipe: null, html: settledHtml };
301
+ }
302
+ finally {
303
+ signal?.removeEventListener('abort', onAbort);
220
304
  if (browser) {
221
305
  try {
222
306
  await browser.close();
223
307
  }
224
- catch { /* noop */ }
308
+ catch {
309
+ // Ignore close errors during teardown.
310
+ }
225
311
  }
226
- return null;
227
- }
228
- finally {
229
- signal?.removeEventListener('abort', onAbort);
230
312
  }
231
313
  }
232
- async function scrapeWithAI(url, signal) {
314
+ async function scrapeWithAI(url, pageSource, signal) {
233
315
  throwIfAborted(signal);
234
316
  const { openaiApiKey } = loadConfig();
235
317
  if (!openaiApiKey || openaiApiKey === 'YOUR_API_KEY_HERE') {
236
318
  throw new Error('OpenAI API key not found. Create a .env.local file with OPENAI_API_KEY=your_key');
237
319
  }
238
320
  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 });
321
+ const { signal: timedSignal, cleanup } = createTimedSignal(signal, AI_TIMEOUT_MS);
322
+ let response;
323
+ try {
324
+ response = await client.chat.completions.create({
325
+ model: 'gpt-4o-mini',
326
+ messages: [
327
+ {
328
+ role: 'system',
329
+ content: 'You extract recipe data from supplied page content. Use only the provided page content. ' +
330
+ 'Return a JSON object with optional name, prepTime, cookTime, totalTime, recipeIngredient, and recipeInstructions fields.',
331
+ },
332
+ {
333
+ role: 'user',
334
+ content: `Recipe URL: ${url}\n\n` +
335
+ 'Page content:\n' +
336
+ pageSource,
337
+ },
338
+ ],
339
+ response_format: { type: 'json_object' },
340
+ }, { signal: timedSignal });
341
+ }
342
+ catch (error) {
343
+ if (timedSignal.aborted) {
344
+ throw formatTimeoutError('AI recipe extraction timed out', signal);
345
+ }
346
+ throw error;
347
+ }
348
+ finally {
349
+ cleanup();
350
+ }
252
351
  const content = response.choices[0]?.message?.content;
253
352
  if (!content)
254
353
  throw new Error('AI returned empty response');
255
- const recipe = JSON.parse(content);
256
- return { ...recipe, source: 'ai' };
354
+ const recipe = normalizeAiRecipe(JSON.parse(content));
355
+ if (!hasRecipeContent(recipe)) {
356
+ throw new Error('AI could not extract recipe data from the page');
357
+ }
358
+ return recipe;
257
359
  }
258
360
  /* ------------------------------------------------------------------ */
259
361
  /* Public orchestrator */
@@ -264,17 +366,26 @@ async function scrapeWithAI(url, signal) {
264
366
  * Calls `onStatus` with progress updates so the TUI can reflect each phase.
265
367
  */
266
368
  export async function scrapeRecipe(url, onStatus, signal) {
369
+ const normalizedUrl = normalizeRecipeUrl(url);
370
+ if (!normalizedUrl) {
371
+ const error = new Error('Invalid URL. Please enter a valid http or https recipe URL.');
372
+ onStatus({ phase: 'error', message: error.message });
373
+ throw error;
374
+ }
267
375
  // Phase 1 – browser scraping
268
376
  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;
377
+ const browserResult = await scrapeWithBrowser(normalizedUrl, onStatus, signal);
378
+ if (browserResult.recipe) {
379
+ onStatus({ phase: 'done', message: 'Recipe found!', recipe: browserResult.recipe });
380
+ return browserResult.recipe;
273
381
  }
274
382
  // Phase 2 – AI fallback
275
383
  onStatus({ phase: 'ai', message: 'Falling back to AI scraper\u2026' });
276
384
  try {
277
- const aiResult = await scrapeWithAI(url, signal);
385
+ const pageSource = browserResult.html && browserResult.html.trim()
386
+ ? limitAiSource(browserResult.html)
387
+ : await fetchAiSource(normalizedUrl, signal);
388
+ const aiResult = await scrapeWithAI(normalizedUrl, pageSource, signal);
278
389
  onStatus({ phase: 'done', message: 'Recipe extracted via AI!', recipe: aiResult });
279
390
  return aiResult;
280
391
  }
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
+ });