@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.
- package/README.md +21 -7
- package/dist/app.js +45 -10
- package/dist/cli.js +13 -3
- package/dist/components/Banner.d.ts +1 -1
- package/dist/components/Footer.d.ts +2 -2
- package/dist/components/Footer.js +5 -1
- package/dist/components/LandingScreen.d.ts +2 -1
- package/dist/components/LandingScreen.js +13 -5
- package/dist/components/LoadingScreen.js +0 -2
- package/dist/components/PhaseRail.d.ts +1 -1
- package/dist/components/RecipeCard.d.ts +2 -1
- package/dist/components/RecipeCard.js +135 -18
- package/dist/components/URLInput.d.ts +2 -1
- package/dist/components/URLInput.js +30 -7
- package/dist/services/scraper.d.ts +1 -0
- package/dist/services/scraper.js +174 -63
- package/dist/theme.d.ts +88 -41
- package/dist/theme.js +122 -40
- package/dist/utils/helpers.d.ts +3 -0
- package/dist/utils/helpers.js +18 -0
- package/dist/utils/shortcuts.d.ts +6 -0
- package/dist/utils/shortcuts.js +15 -0
- package/dist/utils/terminal.js +51 -2
- package/dist/utils/text-layout.d.ts +1 -0
- package/dist/utils/text-layout.js +63 -0
- package/package.json +5 -5
|
@@ -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.
|
package/dist/services/scraper.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
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/
|
|
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
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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', {
|
|
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:
|
|
202
|
-
await page.waitForNetworkIdle({ idleTime: 500, timeout:
|
|
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
|
-
|
|
288
|
+
settledHtml = await page.content();
|
|
211
289
|
onStatus?.({ phase: 'parsing', message: 'Scanning recipe schema and JSON-LD blocks…' });
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 {
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
32
|
-
readonly
|
|
33
|
-
readonly
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
});
|