@sambitcreate/parsely-cli 2.0.0 → 2.1.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 +99 -112
- package/dist/app.js +65 -22
- package/dist/cli.js +28 -1
- package/dist/components/Banner.d.ts +9 -1
- package/dist/components/Banner.js +18 -8
- package/dist/components/ErrorDisplay.js +3 -2
- package/dist/components/Footer.d.ts +2 -1
- package/dist/components/Footer.js +24 -4
- package/dist/components/LandingScreen.d.ts +7 -0
- package/dist/components/LandingScreen.js +74 -0
- package/dist/components/LoadingScreen.d.ts +6 -0
- package/dist/components/LoadingScreen.js +21 -0
- package/dist/components/Panel.d.ts +9 -0
- package/dist/components/Panel.js +6 -0
- package/dist/components/PhaseRail.d.ts +9 -0
- package/dist/components/PhaseRail.js +88 -0
- package/dist/components/RecipeCard.d.ts +3 -1
- package/dist/components/RecipeCard.js +76 -11
- package/dist/components/ScrapingStatus.d.ts +2 -1
- package/dist/components/ScrapingStatus.js +25 -8
- package/dist/components/URLInput.d.ts +3 -1
- package/dist/components/URLInput.js +21 -16
- package/dist/components/Welcome.d.ts +6 -1
- package/dist/components/Welcome.js +5 -2
- package/dist/hooks/useDisplayPalette.d.ts +1 -0
- package/dist/hooks/useDisplayPalette.js +15 -0
- package/dist/hooks/useTerminalViewport.d.ts +6 -0
- package/dist/hooks/useTerminalViewport.js +23 -0
- package/dist/services/scraper.d.ts +8 -1
- package/dist/services/scraper.js +144 -21
- package/dist/theme.d.ts +27 -14
- package/dist/theme.js +27 -14
- package/dist/utils/helpers.d.ts +3 -0
- package/dist/utils/helpers.js +20 -0
- package/dist/utils/terminal.d.ts +8 -0
- package/dist/utils/terminal.js +65 -0
- package/package.json +12 -8
- package/public/parsely-logo.svg +1 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type PropsWithChildren } from 'react';
|
|
2
|
+
import { type BoxProps } from 'ink';
|
|
3
|
+
interface PanelProps extends PropsWithChildren, Omit<BoxProps, 'children'> {
|
|
4
|
+
title?: string;
|
|
5
|
+
eyebrow?: string;
|
|
6
|
+
accentColor?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function Panel({ title, eyebrow, accentColor, children, ...boxProps }: PanelProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { theme } from '../theme.js';
|
|
4
|
+
export function Panel({ title, eyebrow, accentColor = theme.colors.border, children, ...boxProps }) {
|
|
5
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: accentColor, paddingX: 1, paddingY: 0, ...boxProps, children: [(eyebrow || title) && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [eyebrow && (_jsx(Text, { color: theme.colors.muted, children: eyebrow.toUpperCase() })), title && (_jsx(Text, { bold: true, color: accentColor, children: title }))] })), children] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Recipe, ScrapeStatus } from '../services/scraper.js';
|
|
2
|
+
type AppPhase = 'idle' | 'scraping' | 'display' | 'error';
|
|
3
|
+
interface PhaseRailProps {
|
|
4
|
+
phase: AppPhase;
|
|
5
|
+
status?: ScrapeStatus | null;
|
|
6
|
+
recipe?: Recipe | null;
|
|
7
|
+
}
|
|
8
|
+
export declare function PhaseRail({ phase, status, recipe }: PhaseRailProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { theme } from '../theme.js';
|
|
4
|
+
const steps = [
|
|
5
|
+
{
|
|
6
|
+
key: 'browser',
|
|
7
|
+
label: 'Fetch page',
|
|
8
|
+
detail: 'Launch Chrome or Chromium and load the recipe page.',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
key: 'parsing',
|
|
12
|
+
label: 'Read schema',
|
|
13
|
+
detail: 'Look for JSON-LD recipe data and normalize the shape.',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
key: 'ai',
|
|
17
|
+
label: 'AI rescue',
|
|
18
|
+
detail: 'Call gpt-4o-mini only when the page is missing usable recipe data.',
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
function getStepState(step, phase, status, recipe) {
|
|
22
|
+
if (phase === 'idle')
|
|
23
|
+
return 'pending';
|
|
24
|
+
if (phase === 'display' && recipe?.source === 'browser') {
|
|
25
|
+
if (step === 'ai')
|
|
26
|
+
return 'skipped';
|
|
27
|
+
return 'complete';
|
|
28
|
+
}
|
|
29
|
+
if (phase === 'display' && recipe?.source === 'ai') {
|
|
30
|
+
return 'complete';
|
|
31
|
+
}
|
|
32
|
+
const activePhase = status?.phase;
|
|
33
|
+
if (step === 'browser') {
|
|
34
|
+
if (activePhase === 'browser')
|
|
35
|
+
return 'active';
|
|
36
|
+
if (activePhase === 'parsing' || activePhase === 'ai' || activePhase === 'done' || activePhase === 'error') {
|
|
37
|
+
return 'complete';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (step === 'parsing') {
|
|
41
|
+
if (activePhase === 'parsing')
|
|
42
|
+
return 'active';
|
|
43
|
+
if (activePhase === 'ai' || activePhase === 'done' || activePhase === 'error') {
|
|
44
|
+
return 'complete';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (step === 'ai') {
|
|
48
|
+
if (activePhase === 'ai')
|
|
49
|
+
return 'active';
|
|
50
|
+
if (recipe?.source === 'browser' && phase === 'display')
|
|
51
|
+
return 'skipped';
|
|
52
|
+
if (activePhase === 'done' || activePhase === 'error') {
|
|
53
|
+
return recipe?.source === 'browser' ? 'skipped' : 'complete';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return 'pending';
|
|
57
|
+
}
|
|
58
|
+
function getStepColor(state) {
|
|
59
|
+
switch (state) {
|
|
60
|
+
case 'active':
|
|
61
|
+
return theme.colors.secondary;
|
|
62
|
+
case 'complete':
|
|
63
|
+
return theme.colors.success;
|
|
64
|
+
case 'skipped':
|
|
65
|
+
return theme.colors.subtle;
|
|
66
|
+
default:
|
|
67
|
+
return theme.colors.muted;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function getStepSymbol(state) {
|
|
71
|
+
switch (state) {
|
|
72
|
+
case 'active':
|
|
73
|
+
return theme.symbols.active;
|
|
74
|
+
case 'complete':
|
|
75
|
+
return theme.symbols.check;
|
|
76
|
+
case 'skipped':
|
|
77
|
+
return theme.symbols.skip;
|
|
78
|
+
default:
|
|
79
|
+
return theme.symbols.pending;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function PhaseRail({ phase, status, recipe }) {
|
|
83
|
+
return (_jsx(Box, { flexDirection: "column", children: steps.map((step, index) => {
|
|
84
|
+
const state = getStepState(step.key, phase, status, recipe);
|
|
85
|
+
const color = getStepColor(state);
|
|
86
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: index === steps.length - 1 ? 0 : 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: color, bold: true, children: getStepSymbol(state) }), _jsx(Text, { color: state === 'pending' ? theme.colors.text : color, bold: state !== 'pending', children: step.label })] }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: theme.colors.muted, children: step.detail }) })] }, step.key));
|
|
87
|
+
}) }));
|
|
88
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Recipe } from '../services/scraper.js';
|
|
2
2
|
interface RecipeCardProps {
|
|
3
3
|
recipe: Recipe;
|
|
4
|
+
width: number;
|
|
5
|
+
sourceUrl?: string;
|
|
4
6
|
}
|
|
5
|
-
export declare function RecipeCard({ recipe }: RecipeCardProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export declare function RecipeCard({ recipe, width, sourceUrl }: RecipeCardProps): import("react/jsx-runtime").JSX.Element;
|
|
6
8
|
export {};
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import BigText from 'ink-big-text';
|
|
3
4
|
import { theme } from '../theme.js';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
* Extract instruction text from the various formats recipes use.
|
|
7
|
-
*/
|
|
5
|
+
import { formatMinutes, getUrlHost, isoToMinutes } from '../utils/helpers.js';
|
|
6
|
+
import { useDisplayPalette } from '../hooks/useDisplayPalette.js';
|
|
8
7
|
function extractInstructions(recipe) {
|
|
9
8
|
const raw = recipe.recipeInstructions;
|
|
10
9
|
if (!raw)
|
|
@@ -33,14 +32,80 @@ function extractInstructions(recipe) {
|
|
|
33
32
|
}
|
|
34
33
|
return steps;
|
|
35
34
|
}
|
|
36
|
-
function
|
|
35
|
+
function buildOccurrenceKeys(items) {
|
|
36
|
+
const counts = new Map();
|
|
37
|
+
return items.map((item) => {
|
|
38
|
+
const count = (counts.get(item) ?? 0) + 1;
|
|
39
|
+
counts.set(item, count);
|
|
40
|
+
return `${item}-${count}`;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function formatTimeValue(iso) {
|
|
37
44
|
if (!iso)
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
return 'Not listed';
|
|
46
|
+
return formatMinutes(isoToMinutes(iso));
|
|
47
|
+
}
|
|
48
|
+
function buildRule(width, maxWidth) {
|
|
49
|
+
const length = Math.max(18, Math.min(width, maxWidth));
|
|
50
|
+
return theme.symbols.line.repeat(length);
|
|
51
|
+
}
|
|
52
|
+
function splitTitle(title, maxChars, maxLines = 3) {
|
|
53
|
+
const words = title.trim().split(/\s+/).filter(Boolean);
|
|
54
|
+
if (words.length === 0) {
|
|
55
|
+
return ['Untitled recipe'];
|
|
56
|
+
}
|
|
57
|
+
const lines = [];
|
|
58
|
+
let current = '';
|
|
59
|
+
for (const word of words) {
|
|
60
|
+
const next = current ? `${current} ${word}` : word;
|
|
61
|
+
if (current && next.length > maxChars) {
|
|
62
|
+
lines.push(current);
|
|
63
|
+
current = word;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
current = next;
|
|
67
|
+
}
|
|
68
|
+
if (current) {
|
|
69
|
+
lines.push(current);
|
|
70
|
+
}
|
|
71
|
+
while (lines.length > maxLines) {
|
|
72
|
+
const last = lines.pop();
|
|
73
|
+
const prev = lines.pop();
|
|
74
|
+
if (!last || !prev) {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
lines.push(`${prev} ${last}`);
|
|
78
|
+
}
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
function Metric({ label, value }) {
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", width: 18, marginRight: 2, marginBottom: 1, children: [_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: label }), _jsx(Text, { color: theme.colors.recipeText, bold: true, children: value })] }));
|
|
83
|
+
}
|
|
84
|
+
function SidebarCard({ title, children, }) {
|
|
85
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.recipeBorder, paddingX: 1, paddingY: 0, marginBottom: 1, children: [_jsx(Text, { color: theme.colors.recipeBorder, bold: true, children: title }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: children })] }));
|
|
86
|
+
}
|
|
87
|
+
function DetailStack({ label, value }) {
|
|
88
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: label }), _jsx(Text, { color: theme.colors.recipeText, wrap: "wrap", children: value })] }));
|
|
41
89
|
}
|
|
42
|
-
export function RecipeCard({ recipe }) {
|
|
90
|
+
export function RecipeCard({ recipe, width, sourceUrl }) {
|
|
91
|
+
useDisplayPalette(theme.colors.recipePaper);
|
|
43
92
|
const instructions = extractInstructions(recipe);
|
|
44
|
-
const
|
|
45
|
-
|
|
93
|
+
const ingredients = recipe.recipeIngredient ?? [];
|
|
94
|
+
const ingredientKeys = buildOccurrenceKeys(ingredients);
|
|
95
|
+
const instructionKeys = buildOccurrenceKeys(instructions);
|
|
96
|
+
const wide = width >= 124;
|
|
97
|
+
const splitContent = width >= 96;
|
|
98
|
+
const compact = width < 82;
|
|
99
|
+
const sourceHost = getUrlHost(sourceUrl) || 'original page';
|
|
100
|
+
const sourceLabel = recipe.source === 'browser' ? 'Page schema' : 'AI rescue';
|
|
101
|
+
const sourceCopy = recipe.source === 'browser' ? 'Recovered from page schema' : 'Recovered with AI rescue';
|
|
102
|
+
const titleLineCount = width >= 132 ? 2 : 3;
|
|
103
|
+
const titleChars = Math.max(16, Math.ceil((recipe.name ?? 'Untitled recipe').length / titleLineCount) + 4);
|
|
104
|
+
const titleLines = splitTitle(recipe.name ?? 'Untitled recipe', titleChars, titleLineCount);
|
|
105
|
+
const heroRule = buildRule(Math.floor(width * (wide ? 0.46 : 0.7)), 62);
|
|
106
|
+
const sectionRule = buildRule(Math.floor(width * (wide ? 0.5 : 0.82)), 58);
|
|
107
|
+
const sidebarWidth = wide ? 30 : '100%';
|
|
108
|
+
const mainWidth = wide ? '68%' : '100%';
|
|
109
|
+
const showBigTitle = width >= 110;
|
|
110
|
+
return (_jsx(Box, { flexDirection: "column", width: "100%", paddingX: compact ? 1 : 2, paddingY: 1, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: "View original recipe" }), _jsx(Text, { color: theme.colors.recipeSubtle, children: sourceHost }), showBigTitle ? (_jsx(Box, { marginTop: 1, flexDirection: "column", children: titleLines.map((line) => (_jsx(BigText, { text: line, font: "tiny", colors: [theme.colors.recipeText], lineHeight: 0 }, line))) })) : (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: theme.colors.recipeText, bold: true, wrap: "wrap", children: recipe.name ?? 'Untitled recipe' }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.recipeBorder, children: heroRule }) }), _jsxs(Box, { flexDirection: compact ? 'column' : 'row', marginTop: 1, marginBottom: 2, children: [_jsx(Metric, { label: "Prep Time", value: formatTimeValue(recipe.prepTime) }), _jsx(Metric, { label: "Cook Time", value: formatTimeValue(recipe.cookTime) }), _jsx(Metric, { label: "Total Time", value: formatTimeValue(recipe.totalTime) })] }), _jsxs(Box, { flexDirection: wide ? 'row' : 'column', gap: 3, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, width: mainWidth, children: [_jsxs(Box, { flexDirection: splitContent ? 'row' : 'column', gap: 3, alignItems: "flex-end", children: [_jsxs(Box, { width: splitContent ? '38%' : '100%', children: [_jsx(Text, { color: theme.colors.recipeBorder, bold: true, children: "Ingredients" }), _jsx(Text, { color: theme.colors.recipeBorder, children: buildRule(16, 16) })] }), _jsxs(Box, { width: splitContent ? '62%' : '100%', children: [_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: "Instructions" }), _jsx(Text, { color: theme.colors.recipeSoft, children: buildRule(22, 22) })] })] }), _jsx(Text, { color: theme.colors.recipeBorder, children: sectionRule }), _jsxs(Box, { flexDirection: splitContent ? 'row' : 'column', gap: 4, marginTop: 1, flexGrow: 1, children: [_jsx(Box, { flexDirection: "column", width: splitContent ? '38%' : '100%', children: ingredients.length > 0 ? (ingredients.map((item, index) => (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.colors.recipeBorder, bold: true, children: "\u25A1" }), _jsx(Text, { color: theme.colors.recipeText, children: " " }), _jsx(Text, { color: theme.colors.recipeText, bold: true, wrap: "wrap", children: item })] }, ingredientKeys[index])))) : (_jsx(Text, { color: theme.colors.recipeMuted, children: "No ingredients were detected." })) }), _jsx(Box, { flexDirection: "column", width: splitContent ? '62%' : '100%', children: instructions.length > 0 ? (instructions.map((step, index) => (_jsxs(Box, { marginBottom: 1, flexDirection: "row", children: [_jsx(Box, { width: 4, children: _jsx(Text, { color: theme.colors.recipeBorder, bold: true, children: String(index + 1).padStart(2, '0') }) }), _jsx(Text, { color: theme.colors.recipeSubtle, wrap: "wrap", children: step })] }, instructionKeys[index])))) : (_jsx(Text, { color: theme.colors.recipeMuted, children: "No instructions were detected." })) })] })] }), _jsxs(Box, { flexDirection: "column", width: sidebarWidth, minWidth: wide ? 30 : undefined, marginTop: wide ? 2 : 0, children: [_jsxs(SidebarCard, { title: "Recipe brief", children: [_jsx(DetailStack, { label: "Source", value: sourceLabel }), _jsx(DetailStack, { label: "Origin", value: sourceHost }), _jsx(DetailStack, { label: "Status", value: sourceCopy })] }), _jsxs(SidebarCard, { title: "Kitchen rhythm", children: [_jsx(DetailStack, { label: "Ingredients", value: String(ingredients.length) }), _jsx(DetailStack, { label: "Steps", value: String(instructions.length) }), _jsx(DetailStack, { label: "Timeline", value: formatTimeValue(recipe.totalTime) })] }), _jsxs(SidebarCard, { title: "Next actions", children: [_jsxs(Text, { color: theme.colors.recipeText, bold: true, children: ["n", _jsx(Text, { color: theme.colors.recipeMuted, children: " new recipe" })] }), _jsxs(Text, { color: theme.colors.recipeText, bold: true, children: ["q", _jsx(Text, { color: theme.colors.recipeMuted, children: " quit" })] }), _jsxs(Text, { color: theme.colors.recipeText, bold: true, children: ["esc", _jsx(Text, { color: theme.colors.recipeMuted, children: " back" })] })] })] })] })] }) }));
|
|
46
111
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ScrapeStatus } from '../services/scraper.js';
|
|
2
2
|
interface ScrapingStatusProps {
|
|
3
3
|
status: ScrapeStatus;
|
|
4
|
+
width: number;
|
|
4
5
|
}
|
|
5
|
-
export declare function ScrapingStatus({ status }: ScrapingStatusProps): import("react/jsx-runtime").JSX.Element;
|
|
6
|
+
export declare function ScrapingStatus({ status, width }: ScrapingStatusProps): import("react/jsx-runtime").JSX.Element;
|
|
6
7
|
export {};
|
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import Spinner from 'ink-spinner';
|
|
4
|
+
import { Panel } from './Panel.js';
|
|
4
5
|
import { theme } from '../theme.js';
|
|
5
6
|
const phaseLabel = {
|
|
6
|
-
browser: '
|
|
7
|
-
parsing: '
|
|
8
|
-
ai: 'AI
|
|
9
|
-
done: '
|
|
10
|
-
error: '
|
|
7
|
+
browser: 'Fetching recipe page',
|
|
8
|
+
parsing: 'Reading recipe schema',
|
|
9
|
+
ai: 'Running AI rescue',
|
|
10
|
+
done: 'Recipe ready',
|
|
11
|
+
error: 'Scrape failed',
|
|
11
12
|
};
|
|
12
|
-
|
|
13
|
+
function getPhaseColor(phase) {
|
|
14
|
+
switch (phase) {
|
|
15
|
+
case 'done':
|
|
16
|
+
return theme.colors.success;
|
|
17
|
+
case 'error':
|
|
18
|
+
return theme.colors.error;
|
|
19
|
+
case 'ai':
|
|
20
|
+
return theme.colors.accent;
|
|
21
|
+
case 'parsing':
|
|
22
|
+
return theme.colors.secondary;
|
|
23
|
+
default:
|
|
24
|
+
return theme.colors.primary;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function ScrapingStatus({ status, width }) {
|
|
13
28
|
const isActive = status.phase !== 'done' && status.phase !== 'error';
|
|
14
|
-
const
|
|
15
|
-
|
|
29
|
+
const phaseText = phaseLabel[status.phase] ?? status.phase;
|
|
30
|
+
const accentColor = getPhaseColor(status.phase);
|
|
31
|
+
const compact = width < 86;
|
|
32
|
+
return (_jsxs(Panel, { title: phaseText, eyebrow: "Live status", accentColor: accentColor, flexGrow: 1, marginBottom: compact ? 1 : 0, children: [_jsxs(Box, { gap: 1, children: [isActive && (_jsx(Text, { color: accentColor, children: _jsx(Spinner, { type: "dots" }) })), _jsx(Text, { bold: true, color: theme.colors.text, children: status.message })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.colors.muted, children: "Browser mode is cheapest and fastest when the site publishes good recipe metadata." }), _jsx(Text, { color: theme.colors.muted, children: "The AI path stays in reserve until Parsely fails to recover enough structured data." })] })] }));
|
|
16
33
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
interface URLInputProps {
|
|
2
2
|
onSubmit: (url: string) => void;
|
|
3
|
+
mode?: 'default' | 'landing';
|
|
4
|
+
width?: number;
|
|
3
5
|
}
|
|
4
|
-
export declare function URLInput({ onSubmit }: URLInputProps): import("react/jsx-runtime").JSX.Element;
|
|
6
|
+
export declare function URLInput({ onSubmit, mode, width }: URLInputProps): import("react/jsx-runtime").JSX.Element;
|
|
5
7
|
export {};
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
4
|
import TextInput from 'ink-text-input';
|
|
5
5
|
import { theme } from '../theme.js';
|
|
6
|
-
import {
|
|
7
|
-
export function URLInput({ onSubmit }) {
|
|
6
|
+
import { normalizeRecipeUrl, sanitizeSingleLineInput } from '../utils/helpers.js';
|
|
7
|
+
export function URLInput({ onSubmit, mode = 'default', width }) {
|
|
8
8
|
const [value, setValue] = useState('');
|
|
9
9
|
const [error, setError] = useState('');
|
|
10
|
+
const landing = mode === 'landing';
|
|
11
|
+
const landingButtonLabel = ' Go ';
|
|
10
12
|
const handleSubmit = (input) => {
|
|
11
|
-
const
|
|
12
|
-
if (!
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const url = /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
18
|
-
if (!isValidUrl(url)) {
|
|
13
|
+
const url = normalizeRecipeUrl(input);
|
|
14
|
+
if (!url) {
|
|
15
|
+
if (!input.trim()) {
|
|
16
|
+
setError('Please enter a URL');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
19
|
setError('Invalid URL. Please enter a valid recipe URL.');
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
@@ -23,9 +23,14 @@ export function URLInput({ onSubmit }) {
|
|
|
23
23
|
setValue('');
|
|
24
24
|
onSubmit(url);
|
|
25
25
|
};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
const handleChange = (nextValue) => {
|
|
27
|
+
const sanitized = sanitizeSingleLineInput(nextValue);
|
|
28
|
+
setValue(sanitized);
|
|
29
|
+
if (error)
|
|
30
|
+
setError('');
|
|
31
|
+
if (sanitized !== nextValue && sanitized.trim()) {
|
|
32
|
+
handleSubmit(sanitized);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return (_jsxs(Box, { flexDirection: "column", children: [!landing && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.muted, children: "Paste a recipe URL or type a hostname. Parsely will add `https://` when needed." }) })), _jsxs(Box, { alignItems: "center", children: [_jsxs(Box, { borderStyle: "round", borderColor: landing ? theme.colors.brand : theme.colors.borderFocus, width: width, paddingX: 1, paddingY: 0, children: [!landing && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.colors.primary, bold: true, children: "URL" }), _jsx(Text, { color: theme.colors.muted, children: " " })] })), _jsx(TextInput, { value: value, focus: true, onChange: handleChange, onSubmit: handleSubmit, placeholder: landing ? 'Paste recipe link here' : 'Enter recipe URL...' })] }), landing && (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { backgroundColor: theme.colors.brand, color: theme.colors.recipePaper, bold: true, children: landingButtonLabel }) }))] }), !landing && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.muted, children: "Press enter to scrape. Try a specific recipe page, not a site homepage." }) })), error && (_jsx(Box, { marginLeft: landing ? 0 : 2, marginTop: 1, justifyContent: landing ? 'center' : undefined, children: _jsxs(Text, { color: theme.colors.error, children: [theme.symbols.cross, " ", error] }) }))] }));
|
|
31
36
|
}
|
|
@@ -1 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
interface WelcomeProps {
|
|
2
|
+
compact?: boolean;
|
|
3
|
+
minimal?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function Welcome({ compact, minimal }: WelcomeProps): import("react/jsx-runtime").JSX.Element;
|
|
6
|
+
export {};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { Panel } from './Panel.js';
|
|
3
4
|
import { theme } from '../theme.js';
|
|
4
|
-
export function Welcome() {
|
|
5
|
-
return (_jsxs(Box, { flexDirection: "column",
|
|
5
|
+
export function Welcome({ compact = false, minimal = false }) {
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Panel, { title: minimal ? 'Paste a recipe page to extract the cookable bits.' : 'Turn any recipe page into a clean cooking brief.', eyebrow: "Recipe deck", accentColor: theme.colors.primary, children: _jsx(Text, { color: theme.colors.text, children: minimal
|
|
7
|
+
? 'Parsely pulls out timing, ingredients, and steps without the surrounding clutter.'
|
|
8
|
+
: 'Parsely strips away popups, rambling intros, and clutter so you can focus on timing, ingredients, and steps.' }) }), !minimal && (_jsxs(Panel, { title: "What happens next", eyebrow: "Workflow", accentColor: theme.colors.secondary, marginTop: 1, children: [_jsxs(Text, { color: theme.colors.text, children: [theme.symbols.bullet, " Try JSON-LD and other structured recipe markup first."] }), _jsxs(Text, { color: theme.colors.text, children: [theme.symbols.bullet, " Fall back to AI only when the page needs rescue."] }), _jsxs(Text, { color: theme.colors.text, children: [theme.symbols.bullet, " Plate the result in a terminal-friendly cooking layout."] }), !compact && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.muted, children: "Tip: most dedicated recipe sites work immediately if they publish Schema.org metadata." }) }))] }))] }));
|
|
6
9
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useDisplayPalette(color: string): void;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useStdout } from 'ink';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import { resetDefaultTerminalBackground, setDefaultTerminalBackground, shouldUseDisplayPalette, } from '../utils/terminal.js';
|
|
4
|
+
export function useDisplayPalette(color) {
|
|
5
|
+
const { stdout, write } = useStdout();
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (!stdout.isTTY || !shouldUseDisplayPalette()) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
write(setDefaultTerminalBackground(color));
|
|
11
|
+
return () => {
|
|
12
|
+
write(resetDefaultTerminalBackground());
|
|
13
|
+
};
|
|
14
|
+
}, [color, stdout, write]);
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useStdout } from 'ink';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
function getViewport(stdout) {
|
|
4
|
+
return {
|
|
5
|
+
width: stdout.columns ?? 100,
|
|
6
|
+
height: stdout.rows ?? 32,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function useTerminalViewport() {
|
|
10
|
+
const { stdout } = useStdout();
|
|
11
|
+
const [viewport, setViewport] = useState(() => getViewport(stdout));
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const onResize = () => {
|
|
14
|
+
setViewport(getViewport(stdout));
|
|
15
|
+
};
|
|
16
|
+
onResize();
|
|
17
|
+
stdout.on('resize', onResize);
|
|
18
|
+
return () => {
|
|
19
|
+
stdout.off('resize', onResize);
|
|
20
|
+
};
|
|
21
|
+
}, [stdout]);
|
|
22
|
+
return viewport;
|
|
23
|
+
}
|
|
@@ -18,9 +18,16 @@ export interface ScrapeStatus {
|
|
|
18
18
|
message: string;
|
|
19
19
|
recipe?: Recipe;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Walk through JSON-LD script blocks and return the first Recipe object found.
|
|
23
|
+
* Handles direct Recipe type, @graph arrays, and nested lists.
|
|
24
|
+
*/
|
|
25
|
+
export declare function findRecipeJson(scripts: string[]): Record<string, unknown> | null;
|
|
26
|
+
export declare function containsBrowserChallenge(html: string): boolean;
|
|
27
|
+
export declare function extractRecipeFromHtml(html: string): Recipe | null;
|
|
21
28
|
/**
|
|
22
29
|
* Scrape a recipe from the given URL.
|
|
23
30
|
* Tries Puppeteer-based browser scraping first, falls back to OpenAI.
|
|
24
31
|
* Calls `onStatus` with progress updates so the TUI can reflect each phase.
|
|
25
32
|
*/
|
|
26
|
-
export declare function scrapeRecipe(url: string, onStatus: (status: ScrapeStatus) => void): Promise<Recipe>;
|
|
33
|
+
export declare function scrapeRecipe(url: string, onStatus: (status: ScrapeStatus) => void, signal?: AbortSignal): Promise<Recipe>;
|