@sambitcreate/parsely-cli 2.0.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.
Files changed (42) hide show
  1. package/README.md +114 -113
  2. package/dist/app.js +104 -26
  3. package/dist/cli.js +36 -2
  4. package/dist/components/Banner.d.ts +9 -1
  5. package/dist/components/Banner.js +18 -8
  6. package/dist/components/ErrorDisplay.js +3 -2
  7. package/dist/components/Footer.d.ts +2 -1
  8. package/dist/components/Footer.js +28 -4
  9. package/dist/components/LandingScreen.d.ts +8 -0
  10. package/dist/components/LandingScreen.js +89 -0
  11. package/dist/components/LoadingScreen.d.ts +6 -0
  12. package/dist/components/LoadingScreen.js +19 -0
  13. package/dist/components/Panel.d.ts +9 -0
  14. package/dist/components/Panel.js +6 -0
  15. package/dist/components/PhaseRail.d.ts +9 -0
  16. package/dist/components/PhaseRail.js +88 -0
  17. package/dist/components/RecipeCard.d.ts +4 -1
  18. package/dist/components/RecipeCard.js +202 -12
  19. package/dist/components/ScrapingStatus.d.ts +2 -1
  20. package/dist/components/ScrapingStatus.js +25 -8
  21. package/dist/components/URLInput.d.ts +4 -1
  22. package/dist/components/URLInput.js +46 -18
  23. package/dist/components/Welcome.d.ts +6 -1
  24. package/dist/components/Welcome.js +5 -2
  25. package/dist/hooks/useDisplayPalette.d.ts +1 -0
  26. package/dist/hooks/useDisplayPalette.js +15 -0
  27. package/dist/hooks/useTerminalViewport.d.ts +6 -0
  28. package/dist/hooks/useTerminalViewport.js +23 -0
  29. package/dist/services/scraper.d.ts +9 -1
  30. package/dist/services/scraper.js +290 -58
  31. package/dist/theme.d.ts +88 -28
  32. package/dist/theme.js +122 -27
  33. package/dist/utils/helpers.d.ts +4 -0
  34. package/dist/utils/helpers.js +30 -0
  35. package/dist/utils/shortcuts.d.ts +6 -0
  36. package/dist/utils/shortcuts.js +15 -0
  37. package/dist/utils/terminal.d.ts +8 -0
  38. package/dist/utils/terminal.js +114 -0
  39. package/dist/utils/text-layout.d.ts +1 -0
  40. package/dist/utils/text-layout.js +63 -0
  41. package/package.json +12 -8
  42. package/public/parsely-logo.svg +1 -0
@@ -0,0 +1,89 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import CFonts from 'cfonts';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { URLInput } from './URLInput.js';
7
+ import { theme } from '../theme.js';
8
+ function readLogoSvg() {
9
+ try {
10
+ return readFileSync(fileURLToPath(new URL('../../public/parsely-logo.svg', import.meta.url)), 'utf8');
11
+ }
12
+ catch {
13
+ return '';
14
+ }
15
+ }
16
+ const logoSvg = readLogoSvg();
17
+ const logoColor = logoSvg.match(/fill="(#[0-9a-fA-F]{6})"/)?.[1] ?? theme.colors.brand;
18
+ const ANSI_PATTERN = /\u001B\[[0-9;]*m/g;
19
+ const RENDER_BOUNDS = { width: 240, height: 80 };
20
+ function getLineWidth(line) {
21
+ return Array.from(line).length;
22
+ }
23
+ function trimBlankLines(lines) {
24
+ const trimmed = [...lines];
25
+ while (trimmed[0]?.trim() === '') {
26
+ trimmed.shift();
27
+ }
28
+ while (trimmed.at(-1)?.trim() === '') {
29
+ trimmed.pop();
30
+ }
31
+ return trimmed;
32
+ }
33
+ function stripCommonIndent(lines) {
34
+ const nonEmpty = lines.filter((line) => line.trim());
35
+ if (nonEmpty.length === 0) {
36
+ return lines;
37
+ }
38
+ const indent = Math.min(...nonEmpty.map((line) => line.match(/^\s*/u)?.[0].length ?? 0));
39
+ if (indent === 0) {
40
+ return lines;
41
+ }
42
+ return lines.map((line) => line.slice(indent));
43
+ }
44
+ function buildOccurrenceKeys(items) {
45
+ const counts = new Map();
46
+ return items.map((item) => {
47
+ const count = (counts.get(item) ?? 0) + 1;
48
+ counts.set(item, count);
49
+ return `${item}-${count}`;
50
+ });
51
+ }
52
+ function buildLandingArt() {
53
+ const rendered = CFonts.render('Parsely', {
54
+ font: 'block',
55
+ align: 'left',
56
+ colors: ['system'],
57
+ letterSpacing: 0,
58
+ lineHeight: 0,
59
+ space: true,
60
+ maxLength: 0,
61
+ }, false, 0, RENDER_BOUNDS);
62
+ if (!rendered) {
63
+ return {
64
+ lines: ['PARSLEY'],
65
+ width: 'PARSLEY'.length,
66
+ };
67
+ }
68
+ const lines = stripCommonIndent(trimBlankLines(rendered.string
69
+ .replace(ANSI_PATTERN, '')
70
+ .split('\n')
71
+ .map((line) => line.replace(/\s+$/u, ''))));
72
+ return {
73
+ lines,
74
+ width: Math.max(...lines.map(getLineWidth)),
75
+ };
76
+ }
77
+ const primaryLandingArt = buildLandingArt();
78
+ const compactLandingArt = {
79
+ lines: ['PARSLEY'],
80
+ width: 'PARSLEY'.length,
81
+ };
82
+ export function LandingScreen({ width, height, onSubmit, onToggleTheme }) {
83
+ const art = width >= primaryLandingArt.width + 8 ? primaryLandingArt : compactLandingArt;
84
+ const artKeys = buildOccurrenceKeys(art.lines);
85
+ const inputWidth = width >= 120 ? 54 : width >= 84 ? 46 : Math.max(28, width - 16);
86
+ const controlsWidth = inputWidth + 8;
87
+ const contentWidth = Math.min(width - 6, Math.max(controlsWidth, art.width));
88
+ return (_jsx(Box, { flexDirection: "column", width: "100%", height: "100%", paddingX: 2, paddingY: 1, children: _jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", alignItems: "center", width: contentWidth, children: [_jsx(Box, { width: "100%", justifyContent: "center", marginBottom: 2, children: _jsx(Box, { flexDirection: "column", width: art.width, children: art.lines.map((line, index) => (_jsx(Text, { color: logoColor, bold: true, children: line }, artKeys[index]))) }) }), _jsx(Box, { width: controlsWidth, justifyContent: "center", children: _jsx(URLInput, { onSubmit: onSubmit, onToggleTheme: onToggleTheme, mode: "landing", width: inputWidth }) })] }) }) }));
89
+ }
@@ -0,0 +1,6 @@
1
+ import type { ScrapeStatus } from '../services/scraper.js';
2
+ interface LoadingScreenProps {
3
+ status?: ScrapeStatus | null;
4
+ }
5
+ export declare function LoadingScreen({ status }: LoadingScreenProps): import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { theme } from '../theme.js';
5
+ function getLoadingCopy(status) {
6
+ switch (status?.phase) {
7
+ case 'parsing':
8
+ return 'Preparing recipe...';
9
+ case 'ai':
10
+ return 'Recovering recipe...';
11
+ case 'error':
12
+ return 'Recipe failed to load.';
13
+ default:
14
+ return 'Loading recipe...';
15
+ }
16
+ }
17
+ export function LoadingScreen({ status }) {
18
+ return (_jsx(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { color: theme.colors.recipeText, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.recipeText, bold: true, children: getLoadingCopy(status) }) })] }) }));
19
+ }
@@ -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,9 @@
1
1
  import type { Recipe } from '../services/scraper.js';
2
2
  interface RecipeCardProps {
3
3
  recipe: Recipe;
4
+ width: number;
5
+ height: number;
6
+ sourceUrl?: string;
4
7
  }
5
- export declare function RecipeCard({ recipe }: RecipeCardProps): import("react/jsx-runtime").JSX.Element;
8
+ export declare function RecipeCard({ recipe, width, height, sourceUrl }: RecipeCardProps): import("react/jsx-runtime").JSX.Element;
6
9
  export {};
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
2
+ import { useEffect, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import BigText from 'ink-big-text';
3
5
  import { theme } from '../theme.js';
4
- import { isoToMinutes, formatMinutes } from '../utils/helpers.js';
5
- /**
6
- * Extract instruction text from the various formats recipes use.
7
- */
6
+ import { formatMinutes, getUrlHost, isoToMinutes } from '../utils/helpers.js';
7
+ import { wrapText } from '../utils/text-layout.js';
8
8
  function extractInstructions(recipe) {
9
9
  const raw = recipe.recipeInstructions;
10
10
  if (!raw)
@@ -33,14 +33,204 @@ function extractInstructions(recipe) {
33
33
  }
34
34
  return steps;
35
35
  }
36
- function TimeField({ label, iso }) {
36
+ function buildOccurrenceKeys(items) {
37
+ const counts = new Map();
38
+ return items.map((item) => {
39
+ const count = (counts.get(item) ?? 0) + 1;
40
+ counts.set(item, count);
41
+ return `${item}-${count}`;
42
+ });
43
+ }
44
+ function formatTimeValue(iso) {
37
45
  if (!iso)
38
- return null;
39
- const mins = isoToMinutes(iso);
40
- return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: theme.colors.success, bold: true, children: label }), _jsx(Text, { color: theme.colors.text, children: formatMinutes(mins) }), _jsxs(Text, { color: theme.colors.muted, dimColor: true, children: ["(", iso, ")"] })] }));
46
+ return 'Not listed';
47
+ return formatMinutes(isoToMinutes(iso));
48
+ }
49
+ function buildRule(width, maxWidth) {
50
+ const length = Math.max(18, Math.min(width, maxWidth));
51
+ return theme.symbols.line.repeat(length);
52
+ }
53
+ function splitTitle(title, maxChars, maxLines = 3) {
54
+ const words = title.trim().split(/\s+/).filter(Boolean);
55
+ if (words.length === 0) {
56
+ return ['Untitled recipe'];
57
+ }
58
+ const lines = [];
59
+ let current = '';
60
+ for (const word of words) {
61
+ const next = current ? `${current} ${word}` : word;
62
+ if (current && next.length > maxChars) {
63
+ lines.push(current);
64
+ current = word;
65
+ continue;
66
+ }
67
+ current = next;
68
+ }
69
+ if (current) {
70
+ lines.push(current);
71
+ }
72
+ while (lines.length > maxLines) {
73
+ const last = lines.pop();
74
+ const prev = lines.pop();
75
+ if (!last || !prev) {
76
+ break;
77
+ }
78
+ lines.push(`${prev} ${last}`);
79
+ }
80
+ return lines;
81
+ }
82
+ function Metric({ label, value }) {
83
+ 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 })] }));
84
+ }
85
+ function SidebarCard({ title, children, }) {
86
+ 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 })] }));
87
+ }
88
+ function DetailStack({ label, value }) {
89
+ 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
90
  }
42
- export function RecipeCard({ recipe }) {
91
+ function buildCompactHeaderLines(recipe, sourceHost, contentWidth) {
92
+ return [
93
+ 'View original recipe',
94
+ sourceHost,
95
+ '',
96
+ ...wrapText(recipe.name ?? 'Untitled recipe', contentWidth),
97
+ '',
98
+ `Prep Time ${formatTimeValue(recipe.prepTime)}`,
99
+ `Cook Time ${formatTimeValue(recipe.cookTime)}`,
100
+ `Total Time ${formatTimeValue(recipe.totalTime)}`,
101
+ '',
102
+ ];
103
+ }
104
+ function buildCompactBodyLines(recipe, sourceHost, sourceLabel, sourceCopy, contentWidth, ingredients, instructions) {
105
+ const lines = [];
106
+ lines.push('Ingredients');
107
+ lines.push(buildRule(contentWidth, contentWidth));
108
+ lines.push('');
109
+ if (ingredients.length === 0) {
110
+ lines.push('No ingredients were detected.');
111
+ }
112
+ else {
113
+ for (const ingredient of ingredients) {
114
+ lines.push(...wrapText(ingredient, contentWidth, '□ ', ' '));
115
+ lines.push('');
116
+ }
117
+ }
118
+ lines.push('Instructions');
119
+ lines.push(buildRule(contentWidth, contentWidth));
120
+ lines.push('');
121
+ if (instructions.length === 0) {
122
+ lines.push('No instructions were detected.');
123
+ }
124
+ else {
125
+ instructions.forEach((step, index) => {
126
+ const marker = `${String(index + 1).padStart(2, '0')} `;
127
+ lines.push(...wrapText(step, contentWidth, marker, ' '));
128
+ lines.push('');
129
+ });
130
+ }
131
+ lines.push('Recipe brief');
132
+ lines.push(buildRule(contentWidth, contentWidth));
133
+ lines.push(...wrapText(`Source: ${sourceLabel}`, contentWidth));
134
+ lines.push(...wrapText(`Origin: ${sourceHost}`, contentWidth));
135
+ lines.push(...wrapText(`Status: ${sourceCopy}`, contentWidth));
136
+ return lines;
137
+ }
138
+ function buildCompactFooter(width, scrollOffset, maxScroll) {
139
+ const location = maxScroll > 0 ? `${scrollOffset + 1}/${maxScroll + 1}` : '1/1';
140
+ if (width >= 96) {
141
+ return `${location} ${theme.symbols.dot} ↑↓ scroll ${theme.symbols.dot} pgup/pgdn jump ${theme.symbols.dot} ctrl+t theme ${theme.symbols.dot} esc back ${theme.symbols.dot} n new ${theme.symbols.dot} q quit`;
142
+ }
143
+ if (width >= 58) {
144
+ return `${location} ${theme.symbols.dot} ↑↓ scroll ${theme.symbols.dot} ctrl+t theme ${theme.symbols.dot} esc back`;
145
+ }
146
+ return `ctrl+t theme ${theme.symbols.dot} esc back`;
147
+ }
148
+ function clamp(value, min, max) {
149
+ return Math.max(min, Math.min(value, max));
150
+ }
151
+ export function RecipeCard({ recipe, width, height, sourceUrl }) {
152
+ const [scrollOffset, setScrollOffset] = useState(0);
43
153
  const instructions = extractInstructions(recipe);
44
- const sourceLabel = recipe.source === 'browser' ? 'JSON-LD' : 'AI Fallback';
45
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.secondary, paddingX: 1, paddingY: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: theme.colors.secondary, children: "Recipe Extract" }), _jsxs(Text, { color: theme.colors.muted, children: [' ', "(", sourceLabel, ")"] })] }), recipe.name && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.text, children: recipe.name }) })), (recipe.prepTime || recipe.cookTime || recipe.totalTime) && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(TimeField, { label: "Prep Time ", iso: recipe.prepTime }), _jsx(TimeField, { label: "Cook Time ", iso: recipe.cookTime }), _jsx(TimeField, { label: "Total Time", iso: recipe.totalTime })] })), recipe.recipeIngredient && recipe.recipeIngredient.length > 0 && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, color: theme.colors.accent, children: "Ingredients" }), recipe.recipeIngredient.map((item, i) => (_jsxs(Text, { color: theme.colors.text, children: [' ', theme.symbols.bullet, " ", item] }, i)))] })), instructions.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.info, children: "Instructions" }), instructions.map((step, i) => (_jsxs(Text, { color: theme.colors.text, wrap: "wrap", children: [' ', i + 1, ". ", step] }, i)))] }))] }));
154
+ const ingredients = recipe.recipeIngredient ?? [];
155
+ const ingredientKeys = buildOccurrenceKeys(ingredients);
156
+ const instructionKeys = buildOccurrenceKeys(instructions);
157
+ const sourceHost = getUrlHost(sourceUrl) || 'original page';
158
+ const sourceLabel = recipe.source === 'browser' ? 'Page schema' : 'AI rescue';
159
+ const sourceCopy = recipe.source === 'browser' ? 'Recovered from page schema' : 'Recovered with AI rescue';
160
+ const constrained = width < 110 || height < 34;
161
+ const compactContentWidth = Math.max(24, width - 4);
162
+ const compactHeaderLines = buildCompactHeaderLines(recipe, sourceHost, compactContentWidth);
163
+ const compactBodyLines = buildCompactBodyLines(recipe, sourceHost, sourceLabel, sourceCopy, compactContentWidth, ingredients, instructions);
164
+ const compactBodyHeight = Math.max(4, height - compactHeaderLines.length - 2);
165
+ const maxScroll = Math.max(0, compactBodyLines.length - compactBodyHeight);
166
+ const visibleBodyLines = compactBodyLines.slice(scrollOffset, scrollOffset + compactBodyHeight);
167
+ const compactHeaderKeys = buildOccurrenceKeys(compactHeaderLines);
168
+ const compactBodyKeys = buildOccurrenceKeys(compactBodyLines);
169
+ useEffect(() => {
170
+ setScrollOffset((current) => clamp(current, 0, maxScroll));
171
+ }, [maxScroll]);
172
+ useInput((input, key) => {
173
+ if (!constrained) {
174
+ return;
175
+ }
176
+ if (key.upArrow || input === 'k') {
177
+ setScrollOffset((current) => clamp(current - 1, 0, maxScroll));
178
+ return;
179
+ }
180
+ if (key.downArrow || input === 'j') {
181
+ setScrollOffset((current) => clamp(current + 1, 0, maxScroll));
182
+ return;
183
+ }
184
+ if (key.pageUp) {
185
+ setScrollOffset((current) => clamp(current - compactBodyHeight, 0, maxScroll));
186
+ return;
187
+ }
188
+ if (key.pageDown || input === ' ') {
189
+ setScrollOffset((current) => clamp(current + compactBodyHeight, 0, maxScroll));
190
+ }
191
+ }, { isActive: constrained });
192
+ if (constrained) {
193
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", paddingX: 1, children: [_jsx(Box, { flexDirection: "column", children: compactHeaderLines.map((line, index) => {
194
+ if (line === '') {
195
+ return _jsx(Text, { children: " " }, compactHeaderKeys[index]);
196
+ }
197
+ if (index === 0) {
198
+ return (_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: line }, compactHeaderKeys[index]));
199
+ }
200
+ if (index === 1) {
201
+ return (_jsx(Text, { color: theme.colors.recipeSubtle, children: line }, compactHeaderKeys[index]));
202
+ }
203
+ if (index >= compactHeaderLines.length - 4 && line.includes('Time')) {
204
+ return (_jsx(Text, { color: theme.colors.recipeText, bold: true, children: line }, compactHeaderKeys[index]));
205
+ }
206
+ return (_jsx(Text, { color: theme.colors.recipeText, bold: true, wrap: "wrap", children: line }, compactHeaderKeys[index]));
207
+ }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: visibleBodyLines.map((line, index) => {
208
+ const bodyKey = compactBodyKeys[scrollOffset + index];
209
+ const trimmed = line.trim();
210
+ if (!trimmed) {
211
+ return _jsx(Text, { children: " " }, bodyKey);
212
+ }
213
+ const heading = trimmed === 'Ingredients' || trimmed === 'Instructions' || trimmed === 'Recipe brief';
214
+ const rule = trimmed === buildRule(compactContentWidth, compactContentWidth);
215
+ return (_jsx(Text, { color: heading
216
+ ? theme.colors.recipeBorder
217
+ : rule
218
+ ? theme.colors.recipeSoft
219
+ : line.startsWith('□ ') || /^\d{2}\s/u.test(line)
220
+ ? theme.colors.recipeText
221
+ : theme.colors.recipeSubtle, bold: heading || line.startsWith('□ ') || /^\d{2}\s/u.test(line), children: line }, bodyKey));
222
+ }) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: theme.colors.recipeMuted, children: buildCompactFooter(width, scrollOffset, maxScroll) }), _jsx(Text, { color: theme.colors.recipePaper, children: " " })] })] }));
223
+ }
224
+ const wide = width >= 124;
225
+ const splitContent = width >= 96;
226
+ const compact = width < 82;
227
+ const titleLineCount = width >= 132 ? 2 : 3;
228
+ const titleChars = Math.max(16, Math.ceil((recipe.name ?? 'Untitled recipe').length / titleLineCount) + 4);
229
+ const titleLines = splitTitle(recipe.name ?? 'Untitled recipe', titleChars, titleLineCount);
230
+ const heroRule = buildRule(Math.floor(width * (wide ? 0.46 : 0.7)), 62);
231
+ const sectionRule = buildRule(Math.floor(width * (wide ? 0.5 : 0.82)), 58);
232
+ const sidebarWidth = wide ? 30 : '100%';
233
+ const mainWidth = wide ? '68%' : '100%';
234
+ const showBigTitle = width >= 110 && height >= 38;
235
+ return (_jsx(Box, { flexDirection: "column", width: "100%", height: "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: ["ctrl+t", _jsx(Text, { color: theme.colors.recipeMuted, children: " toggle theme" })] }), _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
236
  }
@@ -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: 'Browser Scraping',
7
- parsing: 'Parsing HTML',
8
- ai: 'AI Extraction',
9
- done: 'Complete',
10
- error: '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
- export function ScrapingStatus({ status }) {
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 label = phaseLabel[status.phase] ?? status.phase;
15
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.border, paddingX: 1, paddingY: 1, children: [_jsxs(Box, { gap: 1, children: [isActive && (_jsx(Text, { color: theme.colors.primary, children: _jsx(Spinner, { type: "dots" }) })), _jsx(Text, { bold: true, color: theme.colors.label, children: label })] }), _jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(Text, { color: theme.colors.muted, children: status.message }) })] }));
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,8 @@
1
1
  interface URLInputProps {
2
2
  onSubmit: (url: string) => void;
3
+ onToggleTheme?: () => void;
4
+ mode?: 'default' | 'landing';
5
+ width?: number;
3
6
  }
4
- export declare function URLInput({ onSubmit }: URLInputProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function URLInput({ onSubmit, onToggleTheme, mode, width }: URLInputProps): import("react/jsx-runtime").JSX.Element;
5
8
  export {};
@@ -1,21 +1,26 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
3
- import { Box, Text } from 'ink';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Box, Text, useStdin } from 'ink';
4
4
  import TextInput from 'ink-text-input';
5
5
  import { theme } from '../theme.js';
6
- import { isValidUrl } from '../utils/helpers.js';
7
- export function URLInput({ onSubmit }) {
6
+ import { normalizeRecipeUrl, sanitizeSingleLineInput } from '../utils/helpers.js';
7
+ export function URLInput({ onSubmit, onToggleTheme, mode = 'default', width }) {
8
8
  const [value, setValue] = useState('');
9
9
  const [error, setError] = useState('');
10
+ const ignoreNextChange = useRef(false);
11
+ const { stdin } = useStdin();
12
+ const landing = mode === 'landing';
13
+ const landingButtonLabel = ' Go ';
14
+ const shortcutCopy = width && width < 40
15
+ ? 'ctrl+c exit'
16
+ : 'ctrl+c exit · ctrl+t theme';
10
17
  const handleSubmit = (input) => {
11
- const trimmed = input.trim();
12
- if (!trimmed) {
13
- setError('Please enter a URL');
14
- return;
15
- }
16
- // Auto-prepend https:// if missing protocol
17
- const url = /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
18
- if (!isValidUrl(url)) {
18
+ const url = normalizeRecipeUrl(input);
19
+ if (!url) {
20
+ if (!input.trim()) {
21
+ setError('Please enter a URL');
22
+ return;
23
+ }
19
24
  setError('Invalid URL. Please enter a valid recipe URL.');
20
25
  return;
21
26
  }
@@ -23,9 +28,32 @@ export function URLInput({ onSubmit }) {
23
28
  setValue('');
24
29
  onSubmit(url);
25
30
  };
26
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "round", borderColor: theme.colors.borderFocus, paddingX: 1, children: [_jsx(Text, { color: theme.colors.primary, bold: true, children: '\u276F ' }), _jsx(TextInput, { value: value, onChange: (v) => {
27
- setValue(v);
28
- if (error)
29
- setError('');
30
- }, onSubmit: handleSubmit, placeholder: "Enter recipe URL..." })] }), error && (_jsx(Box, { marginLeft: 2, marginTop: 0, children: _jsxs(Text, { color: theme.colors.error, children: [theme.symbols.cross, " ", error] }) }))] }));
31
+ const handleChange = (nextValue) => {
32
+ if (ignoreNextChange.current) {
33
+ ignoreNextChange.current = false;
34
+ return;
35
+ }
36
+ const sanitized = sanitizeSingleLineInput(nextValue);
37
+ setValue(sanitized);
38
+ if (error)
39
+ setError('');
40
+ };
41
+ useEffect(() => {
42
+ if (!onToggleTheme) {
43
+ return;
44
+ }
45
+ const handleData = (data) => {
46
+ const chunk = typeof data === 'string' ? data : data.toString('utf8');
47
+ if (!chunk.includes('\u0014')) {
48
+ return;
49
+ }
50
+ ignoreNextChange.current = true;
51
+ onToggleTheme();
52
+ };
53
+ stdin.on('data', handleData);
54
+ return () => {
55
+ stdin.off('data', handleData);
56
+ };
57
+ }, [onToggleTheme, stdin]);
58
+ 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 }) }))] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [!landing && (_jsx(Text, { color: theme.colors.muted, children: "Press enter to scrape. Try a specific recipe page, not a site homepage." })), _jsx(Box, { justifyContent: landing ? 'center' : 'flex-end', children: _jsx(Text, { color: theme.colors.muted, children: shortcutCopy }) })] }), 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
59
  }
@@ -1 +1,6 @@
1
- export declare function Welcome(): import("react/jsx-runtime").JSX.Element;
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 {};