@sambitcreate/parsely-cli 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -7
- package/dist/app.js +45 -10
- package/dist/cli.js +10 -3
- package/dist/components/Footer.js +5 -1
- package/dist/components/LandingScreen.d.ts +2 -1
- package/dist/components/LandingScreen.js +20 -5
- package/dist/components/LoadingScreen.js +0 -2
- package/dist/components/RecipeCard.d.ts +2 -1
- package/dist/components/RecipeCard.js +134 -9
- 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 +170 -61
- package/dist/theme.d.ts +88 -41
- package/dist/theme.js +122 -40
- package/dist/utils/helpers.d.ts +1 -0
- package/dist/utils/helpers.js +10 -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 +2 -2
package/README.md
CHANGED
|
@@ -39,24 +39,34 @@ Optional terminal tuning:
|
|
|
39
39
|
```bash
|
|
40
40
|
export PARSELY_SYNC_OUTPUT=1 # force synchronized output on
|
|
41
41
|
export PARSELY_SYNC_OUTPUT=0 # force synchronized output off
|
|
42
|
+
export PARSELY_DISPLAY_PALETTE=1 # force terminal background palette on
|
|
43
|
+
export PARSELY_DISPLAY_PALETTE=0 # force terminal background palette off
|
|
44
|
+
export PARSELY_THEME=dark # start in dark theme
|
|
45
|
+
export PARSELY_THEME=light # start in light theme
|
|
42
46
|
```
|
|
43
47
|
|
|
44
48
|
## Terminal UI
|
|
45
49
|
|
|
46
50
|
- Uses an Ink-based app shell instead of printing one-off output
|
|
47
51
|
- Switches into the terminal alternate screen from the CLI entrypoint and restores the previous screen on exit
|
|
52
|
+
- Restores the terminal's default background color on exit after using the app palette
|
|
48
53
|
- Reserves one terminal row so Ink can update incrementally instead of clearing the whole screen on every spinner tick
|
|
49
54
|
- Adapts the layout to the current terminal size for wide and narrow viewports
|
|
50
55
|
- Collapses non-essential panels on shorter terminals so the URL field stays usable
|
|
51
56
|
- Cancels in-flight browser and AI scraping when you press `Ctrl+C`
|
|
52
57
|
- Shows a live scraping pipeline so browser parsing and AI fallback are visible as separate stages
|
|
53
|
-
-
|
|
58
|
+
- Detects light/dark preference on startup and applies a matching app-wide theme
|
|
59
|
+
- Lets you toggle the full app theme at runtime with `Ctrl+T`
|
|
60
|
+
- Enables synchronized output in Ghostty and WezTerm by default so frame updates paint atomically
|
|
61
|
+
- Applies the terminal background palette by default in Ghostty, Apple Terminal, iTerm2, WezTerm, Warp, Kitty, Alacritty, and foot
|
|
62
|
+
- Avoids advanced palette/sync behavior in `tmux`, `screen`, VS Code's integrated terminal, JetBrains terminals, the Linux console, and `TERM=dumb` unless you explicitly override it
|
|
54
63
|
|
|
55
64
|
## Keyboard Shortcuts
|
|
56
65
|
|
|
57
66
|
| Key | Action |
|
|
58
67
|
| -------- | ----------------- |
|
|
59
68
|
| `Enter` | Submit URL |
|
|
69
|
+
| `Ctrl+T` | Toggle theme |
|
|
60
70
|
| `n` | Scrape new recipe |
|
|
61
71
|
| `q` | Quit |
|
|
62
72
|
| `Esc` | Quit from result view |
|
|
@@ -68,7 +78,9 @@ export PARSELY_SYNC_OUTPUT=0 # force synchronized output off
|
|
|
68
78
|
- **Browser scraping skipped** — Install Chrome or Chromium for better results
|
|
69
79
|
- **No recipe found** — AI fallback handles most sites, but results vary by site
|
|
70
80
|
- **Terminal looks cleared while running** — Expected; Parsely uses the alternate screen and restores your previous terminal content when it exits
|
|
71
|
-
- **
|
|
81
|
+
- **Background color does not change** — Your terminal may be outside the built-in compatibility list or behind a multiplexer. Use `PARSELY_DISPLAY_PALETTE=1` to force palette updates on, or `PARSELY_DISPLAY_PALETTE=0` to disable them entirely
|
|
82
|
+
- **Theme starts in the wrong mode** — Set `PARSELY_THEME=dark` or `PARSELY_THEME=light` to override automatic detection
|
|
83
|
+
- **Ghostty or WezTerm still flickers** — Parsely enables synchronized output automatically there; set `PARSELY_SYNC_OUTPUT=1` to force it on elsewhere or `PARSELY_SYNC_OUTPUT=0` to disable it
|
|
72
84
|
- **Some sites challenge headless browsers** — Parsely now uses a more browser-like Puppeteer setup, but challenge pages can still force an AI fallback
|
|
73
85
|
|
|
74
86
|
## License
|
|
@@ -113,14 +125,16 @@ npm test
|
|
|
113
125
|
|
|
114
126
|
### UI Structure
|
|
115
127
|
|
|
128
|
+
- `LandingScreen` — centered logo and input for the idle state
|
|
129
|
+
- `LoadingScreen` — minimal centered status view during scraping
|
|
116
130
|
- `Banner` — status-aware header with current host and app state
|
|
117
|
-
- `Panel` — shared bordered container used across the
|
|
131
|
+
- `Panel` — shared bordered container used across the error shell
|
|
118
132
|
- `PhaseRail` — pipeline view for browser, parsing, and AI stages
|
|
119
|
-
- `URLInput` — normalizes pasted newlines and
|
|
133
|
+
- `URLInput` — normalizes pasted newlines and exposes shortcut hints under the field
|
|
120
134
|
- `RecipeCard` — split recipe layout with summary, ingredients, timing, and method
|
|
121
|
-
- `Footer` — persistent status line and key hints
|
|
135
|
+
- `Footer` — persistent status line and key hints on non-landing screens
|
|
122
136
|
- `useTerminalViewport` — terminal sizing and resize tracking
|
|
123
|
-
- `utils/terminal.ts` — synchronized-output and render-height helpers
|
|
137
|
+
- `utils/terminal.ts` — terminal compatibility detection, synchronized-output, palette control, and render-height helpers
|
|
124
138
|
|
|
125
139
|
### Tests
|
|
126
140
|
|
|
@@ -128,7 +142,7 @@ npm test
|
|
|
128
142
|
npm test
|
|
129
143
|
```
|
|
130
144
|
|
|
131
|
-
The test suite covers input normalization and
|
|
145
|
+
The test suite covers input normalization, schema extraction, theme-mode helpers, and terminal compatibility detection for common macOS and Linux terminals.
|
|
132
146
|
|
|
133
147
|
### Build & Publish
|
|
134
148
|
|
package/dist/app.js
CHANGED
|
@@ -10,7 +10,10 @@ import { Panel } from './components/Panel.js';
|
|
|
10
10
|
import { PhaseRail } from './components/PhaseRail.js';
|
|
11
11
|
import { scrapeRecipe } from './services/scraper.js';
|
|
12
12
|
import { useTerminalViewport } from './hooks/useTerminalViewport.js';
|
|
13
|
-
import { theme } from './theme.js';
|
|
13
|
+
import { detectPreferredThemeMode, resolveInitialThemeMode, setActiveTheme, theme, toggleThemeMode } from './theme.js';
|
|
14
|
+
import { useDisplayPalette } from './hooks/useDisplayPalette.js';
|
|
15
|
+
import { sanitizeTerminalText } from './utils/helpers.js';
|
|
16
|
+
import { isThemeToggleShortcut } from './utils/shortcuts.js';
|
|
14
17
|
import { getRenderableHeight } from './utils/terminal.js';
|
|
15
18
|
import { LandingScreen } from './components/LandingScreen.js';
|
|
16
19
|
import { LoadingScreen } from './components/LoadingScreen.js';
|
|
@@ -19,13 +22,25 @@ export function App({ initialUrl }) {
|
|
|
19
22
|
const { width, height } = useTerminalViewport();
|
|
20
23
|
const renderHeight = getRenderableHeight(height);
|
|
21
24
|
const [phase, setPhase] = useState(initialUrl ? 'scraping' : 'idle');
|
|
25
|
+
const [themeMode, setThemeMode] = useState(() => resolveInitialThemeMode());
|
|
22
26
|
const [recipe, setRecipe] = useState(null);
|
|
23
27
|
const [scrapeStatus, setScrapeStatus] = useState(null);
|
|
24
28
|
const [error, setError] = useState('');
|
|
25
29
|
const [currentUrl, setCurrentUrl] = useState(initialUrl ?? '');
|
|
26
30
|
const activeScrapeController = useRef(null);
|
|
31
|
+
const initialScrapeStarted = useRef(false);
|
|
32
|
+
const hasManualThemeOverride = useRef(false);
|
|
27
33
|
const wide = width >= 112;
|
|
28
34
|
const shortViewport = renderHeight < 30;
|
|
35
|
+
useDisplayPalette(theme.colors.recipePaper);
|
|
36
|
+
const applyThemeMode = useCallback((mode) => {
|
|
37
|
+
setActiveTheme(mode);
|
|
38
|
+
setThemeMode(mode);
|
|
39
|
+
}, []);
|
|
40
|
+
const handleToggleTheme = useCallback(() => {
|
|
41
|
+
hasManualThemeOverride.current = true;
|
|
42
|
+
applyThemeMode(toggleThemeMode(themeMode));
|
|
43
|
+
}, [applyThemeMode, themeMode]);
|
|
29
44
|
const cancelActiveScrape = useCallback(() => {
|
|
30
45
|
activeScrapeController.current?.abort();
|
|
31
46
|
activeScrapeController.current = null;
|
|
@@ -52,7 +67,7 @@ export function App({ initialUrl }) {
|
|
|
52
67
|
if (controller.signal.aborted || activeScrapeController.current !== controller) {
|
|
53
68
|
return;
|
|
54
69
|
}
|
|
55
|
-
setError(err instanceof Error ? err.message : 'Failed to scrape recipe');
|
|
70
|
+
setError(err instanceof Error ? sanitizeTerminalText(err.message) : 'Failed to scrape recipe');
|
|
56
71
|
setPhase('error');
|
|
57
72
|
}
|
|
58
73
|
finally {
|
|
@@ -69,32 +84,52 @@ export function App({ initialUrl }) {
|
|
|
69
84
|
setCurrentUrl('');
|
|
70
85
|
}, []);
|
|
71
86
|
useEffect(() => {
|
|
72
|
-
if (initialUrl) {
|
|
73
|
-
|
|
87
|
+
if (!initialUrl || initialScrapeStarted.current) {
|
|
88
|
+
return;
|
|
74
89
|
}
|
|
75
|
-
|
|
90
|
+
initialScrapeStarted.current = true;
|
|
91
|
+
void handleScrape(initialUrl);
|
|
92
|
+
}, [handleScrape, initialUrl]);
|
|
76
93
|
useEffect(() => {
|
|
77
94
|
return () => {
|
|
78
95
|
cancelActiveScrape();
|
|
79
96
|
};
|
|
80
97
|
}, [cancelActiveScrape]);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
let cancelled = false;
|
|
100
|
+
void detectPreferredThemeMode().then((mode) => {
|
|
101
|
+
if (cancelled || hasManualThemeOverride.current || mode === themeMode) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
applyThemeMode(mode);
|
|
105
|
+
});
|
|
106
|
+
return () => {
|
|
107
|
+
cancelled = true;
|
|
108
|
+
};
|
|
109
|
+
}, [applyThemeMode, themeMode]);
|
|
81
110
|
useInput((input, key) => {
|
|
82
111
|
if (key.ctrl && input === 'c') {
|
|
83
112
|
cancelActiveScrape();
|
|
84
113
|
exit();
|
|
85
114
|
return;
|
|
86
115
|
}
|
|
116
|
+
if ((phase === 'display' || phase === 'scraping') && isThemeToggleShortcut(input, key)) {
|
|
117
|
+
handleToggleTheme();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
87
120
|
if (phase === 'display' && input === 'n')
|
|
88
121
|
handleNewRecipe();
|
|
89
|
-
if (phase === 'display' &&
|
|
122
|
+
if (phase === 'display' && input === 'q')
|
|
90
123
|
exit();
|
|
124
|
+
if (phase === 'display' && key.escape)
|
|
125
|
+
handleNewRecipe();
|
|
91
126
|
}, { isActive: phase === 'display' || phase === 'scraping' || phase === 'idle' || phase === 'error' });
|
|
92
|
-
const renderIdle = () => (_jsx(LandingScreen, { width: width, height: renderHeight, onSubmit: handleScrape }));
|
|
127
|
+
const renderIdle = () => (_jsx(LandingScreen, { width: width, height: renderHeight, onSubmit: handleScrape, onToggleTheme: handleToggleTheme }));
|
|
93
128
|
const renderScraping = () => (_jsx(LoadingScreen, { status: scrapeStatus }));
|
|
94
|
-
const renderDisplay = () => (_jsx(Box, { flexDirection: "column", flexGrow: 1, children: recipe && (_jsx(RecipeCard, { recipe: recipe, width: width, sourceUrl: currentUrl })) }));
|
|
95
|
-
const renderError = () => (_jsxs(Box, { flexDirection: wide && !shortViewport ? 'row' : 'column', gap: 1, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", width: wide && !shortViewport ? '58%' : undefined, children: [_jsx(ErrorDisplay, { message: error }), _jsx(Panel, { title: "Try another URL", eyebrow: "Retry", accentColor: theme.colors.primary, children: _jsx(URLInput, { onSubmit: handleScrape }) })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(Panel, { title: "Pipeline", eyebrow: "What happened", accentColor: theme.colors.secondary, children: _jsx(PhaseRail, { phase: phase, status: scrapeStatus, recipe: recipe }) }) })] }));
|
|
129
|
+
const renderDisplay = () => (_jsx(Box, { flexDirection: "column", flexGrow: 1, children: recipe && (_jsx(RecipeCard, { recipe: recipe, width: width, height: renderHeight, sourceUrl: currentUrl })) }));
|
|
130
|
+
const renderError = () => (_jsxs(Box, { flexDirection: wide && !shortViewport ? 'row' : 'column', gap: 1, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", width: wide && !shortViewport ? '58%' : undefined, children: [_jsx(ErrorDisplay, { message: error }), _jsx(Panel, { title: "Try another URL", eyebrow: "Retry", accentColor: theme.colors.primary, children: _jsx(URLInput, { onSubmit: handleScrape, onToggleTheme: handleToggleTheme }) })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(Panel, { title: "Pipeline", eyebrow: "What happened", accentColor: theme.colors.secondary, children: _jsx(PhaseRail, { phase: phase, status: scrapeStatus, recipe: recipe }) }) })] }));
|
|
96
131
|
if (phase === 'display') {
|
|
97
|
-
return (_jsx(Box, { flexDirection: "column", width: "100%", children: renderDisplay() }));
|
|
132
|
+
return (_jsx(Box, { flexDirection: "column", width: "100%", height: renderHeight, children: renderDisplay() }));
|
|
98
133
|
}
|
|
99
134
|
if (phase === 'idle') {
|
|
100
135
|
return (_jsx(Box, { flexDirection: "column", width: "100%", height: renderHeight, children: renderIdle() }));
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { render } from 'ink';
|
|
4
4
|
import { App } from './app.js';
|
|
5
|
-
import {
|
|
5
|
+
import { sanitizeTerminalText } from './utils/helpers.js';
|
|
6
|
+
import { createSynchronizedWriteProxy, resetDefaultTerminalBackground, shouldUseDisplayPalette, shouldUseSynchronizedOutput, } from './utils/terminal.js';
|
|
6
7
|
const ENTER_ALT_SCREEN = '\u001B[?1049h\u001B[2J\u001B[H';
|
|
7
8
|
const EXIT_ALT_SCREEN = '\u001B[?1049l';
|
|
8
9
|
// Simple arg parsing – accept an optional recipe URL as the first positional arg
|
|
@@ -31,7 +32,7 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
31
32
|
}
|
|
32
33
|
// Handle --version / -v
|
|
33
34
|
if (args.includes('--version') || args.includes('-v')) {
|
|
34
|
-
console.log('parsely-cli v2.
|
|
35
|
+
console.log('parsely-cli v2.2.0');
|
|
35
36
|
process.exit(0);
|
|
36
37
|
}
|
|
37
38
|
async function main() {
|
|
@@ -50,12 +51,18 @@ async function main() {
|
|
|
50
51
|
await instance.waitUntilExit();
|
|
51
52
|
}
|
|
52
53
|
finally {
|
|
54
|
+
if (useAltScreen && shouldUseDisplayPalette()) {
|
|
55
|
+
process.stdout.write(resetDefaultTerminalBackground());
|
|
56
|
+
}
|
|
53
57
|
if (useAltScreen) {
|
|
54
58
|
process.stdout.write(EXIT_ALT_SCREEN);
|
|
55
59
|
}
|
|
60
|
+
if (useAltScreen && shouldUseDisplayPalette()) {
|
|
61
|
+
process.stdout.write(resetDefaultTerminalBackground());
|
|
62
|
+
}
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
main().catch((error) => {
|
|
59
|
-
console.error(error instanceof Error ? error.message :
|
|
66
|
+
console.error(error instanceof Error ? sanitizeTerminalText(error.message) : 'Unexpected error');
|
|
60
67
|
process.exit(1);
|
|
61
68
|
});
|
|
@@ -5,18 +5,22 @@ import { theme } from '../theme.js';
|
|
|
5
5
|
const keybinds = {
|
|
6
6
|
idle: [
|
|
7
7
|
{ key: 'enter', label: 'scrape' },
|
|
8
|
+
{ key: 'ctrl+t', label: 'theme' },
|
|
8
9
|
{ key: 'ctrl+c', label: 'exit' },
|
|
9
10
|
],
|
|
10
11
|
scraping: [
|
|
12
|
+
{ key: 'ctrl+t', label: 'theme' },
|
|
11
13
|
{ key: 'ctrl+c', label: 'exit' },
|
|
12
14
|
],
|
|
13
15
|
display: [
|
|
14
16
|
{ key: 'n', label: 'new recipe' },
|
|
17
|
+
{ key: 'ctrl+t', label: 'theme' },
|
|
15
18
|
{ key: 'q', label: 'quit' },
|
|
16
|
-
{ key: 'esc', label: '
|
|
19
|
+
{ key: 'esc', label: 'back' },
|
|
17
20
|
],
|
|
18
21
|
error: [
|
|
19
22
|
{ key: 'enter', label: 'retry' },
|
|
23
|
+
{ key: 'ctrl+t', label: 'theme' },
|
|
20
24
|
{ key: 'ctrl+c', label: 'exit' },
|
|
21
25
|
],
|
|
22
26
|
};
|
|
@@ -2,6 +2,7 @@ interface LandingScreenProps {
|
|
|
2
2
|
width: number;
|
|
3
3
|
height: number;
|
|
4
4
|
onSubmit: (url: string) => void;
|
|
5
|
+
onToggleTheme?: () => void;
|
|
5
6
|
}
|
|
6
|
-
export declare function LandingScreen({ width, height, onSubmit }: LandingScreenProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export declare function LandingScreen({ width, height, onSubmit, onToggleTheme }: LandingScreenProps): import("react/jsx-runtime").JSX.Element;
|
|
7
8
|
export {};
|
|
@@ -5,8 +5,15 @@ import { readFileSync } from 'node:fs';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { URLInput } from './URLInput.js';
|
|
7
7
|
import { theme } from '../theme.js';
|
|
8
|
-
|
|
9
|
-
|
|
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();
|
|
10
17
|
const logoColor = logoSvg.match(/fill="(#[0-9a-fA-F]{6})"/)?.[1] ?? theme.colors.brand;
|
|
11
18
|
const ANSI_PATTERN = /\u001B\[[0-9;]*m/g;
|
|
12
19
|
const RENDER_BOUNDS = { width: 240, height: 80 };
|
|
@@ -34,6 +41,14 @@ function stripCommonIndent(lines) {
|
|
|
34
41
|
}
|
|
35
42
|
return lines.map((line) => line.slice(indent));
|
|
36
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
|
+
}
|
|
37
52
|
function buildLandingArt() {
|
|
38
53
|
const rendered = CFonts.render('Parsely', {
|
|
39
54
|
font: 'block',
|
|
@@ -64,11 +79,11 @@ const compactLandingArt = {
|
|
|
64
79
|
lines: ['PARSLEY'],
|
|
65
80
|
width: 'PARSLEY'.length,
|
|
66
81
|
};
|
|
67
|
-
export function LandingScreen({ width, height, onSubmit }) {
|
|
68
|
-
useDisplayPalette(theme.colors.recipePaper);
|
|
82
|
+
export function LandingScreen({ width, height, onSubmit, onToggleTheme }) {
|
|
69
83
|
const art = width >= primaryLandingArt.width + 8 ? primaryLandingArt : compactLandingArt;
|
|
84
|
+
const artKeys = buildOccurrenceKeys(art.lines);
|
|
70
85
|
const inputWidth = width >= 120 ? 54 : width >= 84 ? 46 : Math.max(28, width - 16);
|
|
71
86
|
const controlsWidth = inputWidth + 8;
|
|
72
87
|
const contentWidth = Math.min(width - 6, Math.max(controlsWidth, art.width));
|
|
73
|
-
return (
|
|
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 }) })] }) }) }));
|
|
74
89
|
}
|
|
@@ -2,7 +2,6 @@ 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
4
|
import { theme } from '../theme.js';
|
|
5
|
-
import { useDisplayPalette } from '../hooks/useDisplayPalette.js';
|
|
6
5
|
function getLoadingCopy(status) {
|
|
7
6
|
switch (status?.phase) {
|
|
8
7
|
case 'parsing':
|
|
@@ -16,6 +15,5 @@ function getLoadingCopy(status) {
|
|
|
16
15
|
}
|
|
17
16
|
}
|
|
18
17
|
export function LoadingScreen({ status }) {
|
|
19
|
-
useDisplayPalette(theme.colors.recipePaper);
|
|
20
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) }) })] }) }));
|
|
21
19
|
}
|
|
@@ -2,7 +2,8 @@ import type { Recipe } from '../services/scraper.js';
|
|
|
2
2
|
interface RecipeCardProps {
|
|
3
3
|
recipe: Recipe;
|
|
4
4
|
width: number;
|
|
5
|
+
height: number;
|
|
5
6
|
sourceUrl?: string;
|
|
6
7
|
}
|
|
7
|
-
export declare function RecipeCard({ recipe, width, sourceUrl }: RecipeCardProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export declare function RecipeCard({ recipe, width, height, sourceUrl }: RecipeCardProps): import("react/jsx-runtime").JSX.Element;
|
|
8
9
|
export {};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
4
|
import BigText from 'ink-big-text';
|
|
4
5
|
import { theme } from '../theme.js';
|
|
5
6
|
import { formatMinutes, getUrlHost, isoToMinutes } from '../utils/helpers.js';
|
|
6
|
-
import {
|
|
7
|
+
import { wrapText } from '../utils/text-layout.js';
|
|
7
8
|
function extractInstructions(recipe) {
|
|
8
9
|
const raw = recipe.recipeInstructions;
|
|
9
10
|
if (!raw)
|
|
@@ -87,18 +88,142 @@ function SidebarCard({ title, children, }) {
|
|
|
87
88
|
function DetailStack({ label, value }) {
|
|
88
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 })] }));
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
-
|
|
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);
|
|
92
153
|
const instructions = extractInstructions(recipe);
|
|
93
154
|
const ingredients = recipe.recipeIngredient ?? [];
|
|
94
155
|
const ingredientKeys = buildOccurrenceKeys(ingredients);
|
|
95
156
|
const instructionKeys = buildOccurrenceKeys(instructions);
|
|
96
|
-
const wide = width >= 124;
|
|
97
|
-
const splitContent = width >= 96;
|
|
98
|
-
const compact = width < 82;
|
|
99
157
|
const sourceHost = getUrlHost(sourceUrl) || 'original page';
|
|
100
158
|
const sourceLabel = recipe.source === 'browser' ? 'Page schema' : 'AI rescue';
|
|
101
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;
|
|
102
227
|
const titleLineCount = width >= 132 ? 2 : 3;
|
|
103
228
|
const titleChars = Math.max(16, Math.ceil((recipe.name ?? 'Untitled recipe').length / titleLineCount) + 4);
|
|
104
229
|
const titleLines = splitTitle(recipe.name ?? 'Untitled recipe', titleChars, titleLineCount);
|
|
@@ -106,6 +231,6 @@ export function RecipeCard({ recipe, width, sourceUrl }) {
|
|
|
106
231
|
const sectionRule = buildRule(Math.floor(width * (wide ? 0.5 : 0.82)), 58);
|
|
107
232
|
const sidebarWidth = wide ? 30 : '100%';
|
|
108
233
|
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" })] })] })] })] })] }) }));
|
|
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" })] })] })] })] })] }) }));
|
|
111
236
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
interface URLInputProps {
|
|
2
2
|
onSubmit: (url: string) => void;
|
|
3
|
+
onToggleTheme?: () => void;
|
|
3
4
|
mode?: 'default' | 'landing';
|
|
4
5
|
width?: number;
|
|
5
6
|
}
|
|
6
|
-
export declare function URLInput({ onSubmit, mode, width }: URLInputProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export declare function URLInput({ onSubmit, onToggleTheme, mode, width }: URLInputProps): import("react/jsx-runtime").JSX.Element;
|
|
7
8
|
export {};
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
3
|
-
import { Box, Text } from 'ink';
|
|
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
6
|
import { normalizeRecipeUrl, sanitizeSingleLineInput } from '../utils/helpers.js';
|
|
7
|
-
export function URLInput({ onSubmit, mode = 'default', width }) {
|
|
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();
|
|
10
12
|
const landing = mode === 'landing';
|
|
11
13
|
const landingButtonLabel = ' Go ';
|
|
14
|
+
const shortcutCopy = width && width < 40
|
|
15
|
+
? 'ctrl+c exit'
|
|
16
|
+
: 'ctrl+c exit · ctrl+t theme';
|
|
12
17
|
const handleSubmit = (input) => {
|
|
13
18
|
const url = normalizeRecipeUrl(input);
|
|
14
19
|
if (!url) {
|
|
@@ -24,13 +29,31 @@ export function URLInput({ onSubmit, mode = 'default', width }) {
|
|
|
24
29
|
onSubmit(url);
|
|
25
30
|
};
|
|
26
31
|
const handleChange = (nextValue) => {
|
|
32
|
+
if (ignoreNextChange.current) {
|
|
33
|
+
ignoreNextChange.current = false;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
27
36
|
const sanitized = sanitizeSingleLineInput(nextValue);
|
|
28
37
|
setValue(sanitized);
|
|
29
38
|
if (error)
|
|
30
39
|
setError('');
|
|
31
|
-
if (sanitized !== nextValue && sanitized.trim()) {
|
|
32
|
-
handleSubmit(sanitized);
|
|
33
|
-
}
|
|
34
40
|
};
|
|
35
|
-
|
|
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] }) }))] }));
|
|
36
59
|
}
|
|
@@ -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.
|