@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.
- package/README.md +114 -113
- package/dist/app.js +104 -26
- package/dist/cli.js +36 -2
- 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 +28 -4
- package/dist/components/LandingScreen.d.ts +8 -0
- package/dist/components/LandingScreen.js +89 -0
- package/dist/components/LoadingScreen.d.ts +6 -0
- package/dist/components/LoadingScreen.js +19 -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 +4 -1
- package/dist/components/RecipeCard.js +202 -12
- package/dist/components/ScrapingStatus.d.ts +2 -1
- package/dist/components/ScrapingStatus.js +25 -8
- package/dist/components/URLInput.d.ts +4 -1
- package/dist/components/URLInput.js +46 -18
- 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 +9 -1
- package/dist/services/scraper.js +290 -58
- package/dist/theme.d.ts +88 -28
- package/dist/theme.js +122 -27
- package/dist/utils/helpers.d.ts +4 -0
- package/dist/utils/helpers.js +30 -0
- package/dist/utils/shortcuts.d.ts +6 -0
- package/dist/utils/shortcuts.js +15 -0
- package/dist/utils/terminal.d.ts +8 -0
- package/dist/utils/terminal.js +114 -0
- package/dist/utils/text-layout.d.ts +1 -0
- package/dist/utils/text-layout.js +63 -0
- package/package.json +12 -8
- package/public/parsely-logo.svg +1 -0
package/README.md
CHANGED
|
@@ -1,151 +1,152 @@
|
|
|
1
1
|
# Parsely CLI
|
|
2
2
|
|
|
3
|
-
A smart
|
|
3
|
+
A smart recipe scraper for the terminal. It turns a recipe URL into a clean cooking brief with ingredients, timings, steps, and source metadata inside a viewport-filling terminal UI.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Installation
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- **Browser Scraping** — Headless Chrome via Puppeteer extracts Schema.org JSON-LD data from recipe pages, handling JavaScript-rendered content.
|
|
11
|
-
- **AI Fallback** — Automatically switches to OpenAI `gpt-4o-mini` when browser scraping can't find recipe data.
|
|
12
|
-
- **Structured Output** — Displays prep time, cook time, total time, ingredients, and step-by-step instructions in a clean card layout.
|
|
13
|
-
- **Keyboard-Driven** — Context-aware keybind hints in the footer; press `n` for a new recipe or `q` to quit.
|
|
14
|
-
|
|
15
|
-
## Preview
|
|
16
|
-
|
|
17
|
-

|
|
18
|
-
|
|
19
|
-
## Project Structure
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
parsely-cli/
|
|
23
|
-
├── src/
|
|
24
|
-
│ ├── cli.tsx # Entry point — arg parsing, renders <App>
|
|
25
|
-
│ ├── app.tsx # Root component — state machine (idle → scraping → display)
|
|
26
|
-
│ ├── theme.ts # Color palette and symbols
|
|
27
|
-
│ ├── components/
|
|
28
|
-
│ │ ├── Banner.tsx # ASCII art header
|
|
29
|
-
│ │ ├── URLInput.tsx # URL text input with validation
|
|
30
|
-
│ │ ├── RecipeCard.tsx # Recipe display card (times, ingredients, instructions)
|
|
31
|
-
│ │ ├── ScrapingStatus.tsx # Spinner with phase updates
|
|
32
|
-
│ │ ├── Footer.tsx # Context-aware keybind hints
|
|
33
|
-
│ │ ├── Welcome.tsx # Welcome message
|
|
34
|
-
│ │ └── ErrorDisplay.tsx # Error panel
|
|
35
|
-
│ ├── services/
|
|
36
|
-
│ │ └── scraper.ts # Puppeteer + OpenAI scraping logic
|
|
37
|
-
│ └── utils/
|
|
38
|
-
│ └── helpers.ts # ISO duration parser, config, URL validation
|
|
39
|
-
├── package.json
|
|
40
|
-
├── tsconfig.json
|
|
41
|
-
├── run.sh # Quick-start launcher script
|
|
42
|
-
├── .env.local # Your OpenAI API key (create this)
|
|
43
|
-
├── CLAUDE.md # AI assistant context
|
|
44
|
-
├── CODE_OF_CONDUCT.md
|
|
45
|
-
└── LICENSE
|
|
7
|
+
### npm
|
|
8
|
+
```bash
|
|
9
|
+
npm install -g @sambitcreate/parsely-cli
|
|
46
10
|
```
|
|
47
11
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
- **npm** v9 or later
|
|
54
|
-
|
|
55
|
-
### Installation
|
|
56
|
-
|
|
57
|
-
1. **Clone the repository:**
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
git clone <your-repository-url>
|
|
61
|
-
cd parsely-cli
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
2. **Install dependencies:**
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
npm install
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Uses `puppeteer-core` — no Chromium download. The CLI auto-detects system Chrome/Chromium. If none is found, browser scraping is skipped and the AI fallback is used.
|
|
71
|
-
|
|
72
|
-
3. **Configure AI fallback (optional but recommended):**
|
|
73
|
-
|
|
74
|
-
Create a `.env.local` file in the project root:
|
|
75
|
-
|
|
76
|
-
```
|
|
77
|
-
OPENAI_API_KEY="your_openai_api_key_here"
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
Without this, the AI fallback will not function — browser scraping will still work for most recipe sites.
|
|
12
|
+
### Homebrew
|
|
13
|
+
```bash
|
|
14
|
+
brew tap sambitcreate/tap
|
|
15
|
+
brew install parsely
|
|
16
|
+
```
|
|
81
17
|
|
|
82
18
|
## Usage
|
|
83
19
|
|
|
84
|
-
### Quick Start
|
|
85
|
-
|
|
86
20
|
```bash
|
|
87
|
-
|
|
21
|
+
parsely
|
|
22
|
+
parsely https://www.simplyrecipes.com/recipes/perfect_guacamole/
|
|
23
|
+
parsely --help
|
|
24
|
+
parsely --version
|
|
88
25
|
```
|
|
89
26
|
|
|
90
|
-
|
|
27
|
+
## Configuration
|
|
91
28
|
|
|
92
|
-
|
|
29
|
+
For AI fallback (optional but recommended), set the OpenAI API key:
|
|
93
30
|
|
|
94
31
|
```bash
|
|
95
|
-
|
|
32
|
+
export OPENAI_API_KEY="your_key_here"
|
|
96
33
|
```
|
|
97
34
|
|
|
98
|
-
|
|
35
|
+
Without this, browser scraping still works for most recipe sites.
|
|
36
|
+
|
|
37
|
+
Optional terminal tuning:
|
|
99
38
|
|
|
100
39
|
```bash
|
|
101
|
-
|
|
40
|
+
export PARSELY_SYNC_OUTPUT=1 # force synchronized output on
|
|
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
|
|
102
46
|
```
|
|
103
47
|
|
|
104
|
-
|
|
48
|
+
## Terminal UI
|
|
49
|
+
|
|
50
|
+
- Uses an Ink-based app shell instead of printing one-off output
|
|
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
|
|
53
|
+
- Reserves one terminal row so Ink can update incrementally instead of clearing the whole screen on every spinner tick
|
|
54
|
+
- Adapts the layout to the current terminal size for wide and narrow viewports
|
|
55
|
+
- Collapses non-essential panels on shorter terminals so the URL field stays usable
|
|
56
|
+
- Cancels in-flight browser and AI scraping when you press `Ctrl+C`
|
|
57
|
+
- Shows a live scraping pipeline so browser parsing and AI fallback are visible as separate stages
|
|
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
|
|
63
|
+
|
|
64
|
+
## Keyboard Shortcuts
|
|
65
|
+
|
|
66
|
+
| Key | Action |
|
|
67
|
+
| -------- | ----------------- |
|
|
68
|
+
| `Enter` | Submit URL |
|
|
69
|
+
| `Ctrl+T` | Toggle theme |
|
|
70
|
+
| `n` | Scrape new recipe |
|
|
71
|
+
| `q` | Quit |
|
|
72
|
+
| `Esc` | Quit from result view |
|
|
73
|
+
| `Ctrl+C` | Exit |
|
|
105
74
|
|
|
106
|
-
|
|
75
|
+
## Troubleshooting
|
|
107
76
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
77
|
+
- **`Error: OpenAI API key not found`** — Set `OPENAI_API_KEY` environment variable
|
|
78
|
+
- **Browser scraping skipped** — Install Chrome or Chromium for better results
|
|
79
|
+
- **No recipe found** — AI fallback handles most sites, but results vary by site
|
|
80
|
+
- **Terminal looks cleared while running** — Expected; Parsely uses the alternate screen and restores your previous terminal content when it exits
|
|
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
|
|
84
|
+
- **Some sites challenge headless browsers** — Parsely now uses a more browser-like Puppeteer setup, but challenge pages can still force an AI fallback
|
|
111
85
|
|
|
112
|
-
|
|
86
|
+
## License
|
|
113
87
|
|
|
114
|
-
|
|
115
|
-
| -------- | -------- | ----------------- |
|
|
116
|
-
| `Enter` | Input | Submit URL |
|
|
117
|
-
| `n` | Display | Scrape new recipe |
|
|
118
|
-
| `q` | Display | Quit |
|
|
119
|
-
| `Ctrl+C` | Anywhere | Exit |
|
|
88
|
+
MIT — see [LICENSE](LICENSE).
|
|
120
89
|
|
|
121
|
-
##
|
|
90
|
+
## For Developers
|
|
122
91
|
|
|
123
|
-
|
|
92
|
+
### Project Structure
|
|
93
|
+
```
|
|
94
|
+
parsely-cli/
|
|
95
|
+
├── src/
|
|
96
|
+
│ ├── cli.tsx # Entry point
|
|
97
|
+
│ ├── app.tsx # Root component — app shell + state machine
|
|
98
|
+
│ ├── theme.ts # Color palette
|
|
99
|
+
│ ├── components/ # UI components
|
|
100
|
+
│ ├── hooks/ # Terminal viewport and screen management
|
|
101
|
+
│ ├── services/scraper.ts # Puppeteer + OpenAI
|
|
102
|
+
│ └── utils/ # Input, URL, and terminal helpers
|
|
103
|
+
├── test/ # Unit tests for helpers and scraper parsing
|
|
104
|
+
├── package.json
|
|
105
|
+
├── tsconfig.json
|
|
106
|
+
└── CLAUDE.md # AI assistant context
|
|
107
|
+
```
|
|
124
108
|
|
|
125
|
-
|
|
109
|
+
### Development Setup
|
|
126
110
|
|
|
127
|
-
|
|
111
|
+
```bash
|
|
112
|
+
git clone https://github.com/sambitcreate/parsely-cli.git
|
|
113
|
+
cd parsely-cli
|
|
114
|
+
npm install
|
|
115
|
+
npm run dev
|
|
116
|
+
npm test
|
|
117
|
+
```
|
|
128
118
|
|
|
129
|
-
|
|
119
|
+
### How It Works
|
|
130
120
|
|
|
131
|
-
|
|
121
|
+
1. **Browser Scraping** — Headless Chrome loads the page and extracts Schema.org JSON-LD recipe data
|
|
122
|
+
2. **Parsing Stage** — Parsely scans and normalizes recipe schema before deciding whether the page is usable
|
|
123
|
+
3. **AI Fallback** — OpenAI `gpt-4o-mini` extracts data only when browser parsing cannot recover a recipe
|
|
124
|
+
4. **Display** — The result is plated into a responsive terminal recipe deck with pipeline, prep, and method panels
|
|
132
125
|
|
|
133
|
-
|
|
134
|
-
- **State machine** — The app cycles through phases: `idle` → `scraping` → `display` (or `error`).
|
|
135
|
-
- **Theme system** — Centralized color palette in `theme.ts` for consistent styling.
|
|
136
|
-
- **Context-aware footer** — Keybind hints update based on the current phase.
|
|
137
|
-
- **Callback-driven progress** — The scraper reports phase changes to the TUI via callbacks so the spinner updates in real time.
|
|
126
|
+
### UI Structure
|
|
138
127
|
|
|
139
|
-
|
|
128
|
+
- `LandingScreen` — centered logo and input for the idle state
|
|
129
|
+
- `LoadingScreen` — minimal centered status view during scraping
|
|
130
|
+
- `Banner` — status-aware header with current host and app state
|
|
131
|
+
- `Panel` — shared bordered container used across the error shell
|
|
132
|
+
- `PhaseRail` — pipeline view for browser, parsing, and AI stages
|
|
133
|
+
- `URLInput` — normalizes pasted newlines and exposes shortcut hints under the field
|
|
134
|
+
- `RecipeCard` — split recipe layout with summary, ingredients, timing, and method
|
|
135
|
+
- `Footer` — persistent status line and key hints on non-landing screens
|
|
136
|
+
- `useTerminalViewport` — terminal sizing and resize tracking
|
|
137
|
+
- `utils/terminal.ts` — terminal compatibility detection, synchronized-output, palette control, and render-height helpers
|
|
140
138
|
|
|
141
|
-
|
|
142
|
-
- **Browser scraping skipped** — The CLI auto-detects system Chrome/Chromium. If none is found, it skips browser scraping and uses the AI fallback. Install Chrome or Chromium to enable browser scraping.
|
|
143
|
-
- **No recipe found** — Some sites use non-standard recipe markup. The AI fallback handles most of these, but results depend on the OpenAI model's ability to extract the recipe.
|
|
139
|
+
### Tests
|
|
144
140
|
|
|
145
|
-
|
|
141
|
+
```bash
|
|
142
|
+
npm test
|
|
143
|
+
```
|
|
146
144
|
|
|
147
|
-
|
|
145
|
+
The test suite covers input normalization, schema extraction, theme-mode helpers, and terminal compatibility detection for common macOS and Linux terminals.
|
|
148
146
|
|
|
149
|
-
|
|
147
|
+
### Build & Publish
|
|
150
148
|
|
|
151
|
-
|
|
149
|
+
```bash
|
|
150
|
+
npm run build
|
|
151
|
+
npm publish --access public
|
|
152
|
+
```
|
package/dist/app.js
CHANGED
|
@@ -1,63 +1,141 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
2
|
-
import { useState, useCallback, useEffect } from 'react';
|
|
3
|
-
import { Box,
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import { Box, useApp, useInput } from 'ink';
|
|
4
4
|
import { Banner } from './components/Banner.js';
|
|
5
5
|
import { URLInput } from './components/URLInput.js';
|
|
6
6
|
import { RecipeCard } from './components/RecipeCard.js';
|
|
7
|
-
import { ScrapingStatus } from './components/ScrapingStatus.js';
|
|
8
7
|
import { Footer } from './components/Footer.js';
|
|
9
|
-
import { Welcome } from './components/Welcome.js';
|
|
10
8
|
import { ErrorDisplay } from './components/ErrorDisplay.js';
|
|
9
|
+
import { Panel } from './components/Panel.js';
|
|
10
|
+
import { PhaseRail } from './components/PhaseRail.js';
|
|
11
11
|
import { scrapeRecipe } from './services/scraper.js';
|
|
12
|
-
import {
|
|
12
|
+
import { useTerminalViewport } from './hooks/useTerminalViewport.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';
|
|
17
|
+
import { getRenderableHeight } from './utils/terminal.js';
|
|
18
|
+
import { LandingScreen } from './components/LandingScreen.js';
|
|
19
|
+
import { LoadingScreen } from './components/LoadingScreen.js';
|
|
13
20
|
export function App({ initialUrl }) {
|
|
14
21
|
const { exit } = useApp();
|
|
22
|
+
const { width, height } = useTerminalViewport();
|
|
23
|
+
const renderHeight = getRenderableHeight(height);
|
|
15
24
|
const [phase, setPhase] = useState(initialUrl ? 'scraping' : 'idle');
|
|
25
|
+
const [themeMode, setThemeMode] = useState(() => resolveInitialThemeMode());
|
|
16
26
|
const [recipe, setRecipe] = useState(null);
|
|
17
27
|
const [scrapeStatus, setScrapeStatus] = useState(null);
|
|
18
28
|
const [error, setError] = useState('');
|
|
29
|
+
const [currentUrl, setCurrentUrl] = useState(initialUrl ?? '');
|
|
30
|
+
const activeScrapeController = useRef(null);
|
|
31
|
+
const initialScrapeStarted = useRef(false);
|
|
32
|
+
const hasManualThemeOverride = useRef(false);
|
|
33
|
+
const wide = width >= 112;
|
|
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]);
|
|
44
|
+
const cancelActiveScrape = useCallback(() => {
|
|
45
|
+
activeScrapeController.current?.abort();
|
|
46
|
+
activeScrapeController.current = null;
|
|
47
|
+
}, []);
|
|
19
48
|
const handleScrape = useCallback(async (url) => {
|
|
49
|
+
cancelActiveScrape();
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
activeScrapeController.current = controller;
|
|
52
|
+
setCurrentUrl(url);
|
|
20
53
|
setPhase('scraping');
|
|
21
54
|
setError('');
|
|
22
55
|
setScrapeStatus({ phase: 'browser', message: 'Starting\u2026' });
|
|
23
56
|
try {
|
|
24
57
|
const result = await scrapeRecipe(url, (status) => {
|
|
25
58
|
setScrapeStatus(status);
|
|
26
|
-
});
|
|
59
|
+
}, controller.signal);
|
|
60
|
+
if (controller.signal.aborted || activeScrapeController.current !== controller) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
27
63
|
setRecipe(result);
|
|
28
64
|
setPhase('display');
|
|
29
65
|
}
|
|
30
66
|
catch (err) {
|
|
31
|
-
|
|
67
|
+
if (controller.signal.aborted || activeScrapeController.current !== controller) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
setError(err instanceof Error ? sanitizeTerminalText(err.message) : 'Failed to scrape recipe');
|
|
32
71
|
setPhase('error');
|
|
33
72
|
}
|
|
34
|
-
|
|
73
|
+
finally {
|
|
74
|
+
if (activeScrapeController.current === controller) {
|
|
75
|
+
activeScrapeController.current = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, [cancelActiveScrape]);
|
|
35
79
|
const handleNewRecipe = useCallback(() => {
|
|
36
80
|
setPhase('idle');
|
|
37
81
|
setRecipe(null);
|
|
38
82
|
setError('');
|
|
39
83
|
setScrapeStatus(null);
|
|
84
|
+
setCurrentUrl('');
|
|
40
85
|
}, []);
|
|
41
|
-
// Scrape the initial URL if provided via CLI argument
|
|
42
86
|
useEffect(() => {
|
|
43
|
-
if (initialUrl) {
|
|
44
|
-
|
|
87
|
+
if (!initialUrl || initialScrapeStarted.current) {
|
|
88
|
+
return;
|
|
45
89
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
90
|
+
initialScrapeStarted.current = true;
|
|
91
|
+
void handleScrape(initialUrl);
|
|
92
|
+
}, [handleScrape, initialUrl]);
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
return () => {
|
|
95
|
+
cancelActiveScrape();
|
|
96
|
+
};
|
|
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]);
|
|
49
110
|
useInput((input, key) => {
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
exit();
|
|
111
|
+
if (key.ctrl && input === 'c') {
|
|
112
|
+
cancelActiveScrape();
|
|
113
|
+
exit();
|
|
114
|
+
return;
|
|
55
115
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
exit();
|
|
116
|
+
if ((phase === 'display' || phase === 'scraping') && isThemeToggleShortcut(input, key)) {
|
|
117
|
+
handleToggleTheme();
|
|
118
|
+
return;
|
|
60
119
|
}
|
|
61
|
-
|
|
62
|
-
|
|
120
|
+
if (phase === 'display' && input === 'n')
|
|
121
|
+
handleNewRecipe();
|
|
122
|
+
if (phase === 'display' && input === 'q')
|
|
123
|
+
exit();
|
|
124
|
+
if (phase === 'display' && key.escape)
|
|
125
|
+
handleNewRecipe();
|
|
126
|
+
}, { isActive: phase === 'display' || phase === 'scraping' || phase === 'idle' || phase === 'error' });
|
|
127
|
+
const renderIdle = () => (_jsx(LandingScreen, { width: width, height: renderHeight, onSubmit: handleScrape, onToggleTheme: handleToggleTheme }));
|
|
128
|
+
const renderScraping = () => (_jsx(LoadingScreen, { status: scrapeStatus }));
|
|
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 }) }) })] }));
|
|
131
|
+
if (phase === 'display') {
|
|
132
|
+
return (_jsx(Box, { flexDirection: "column", width: "100%", height: renderHeight, children: renderDisplay() }));
|
|
133
|
+
}
|
|
134
|
+
if (phase === 'idle') {
|
|
135
|
+
return (_jsx(Box, { flexDirection: "column", width: "100%", height: renderHeight, children: renderIdle() }));
|
|
136
|
+
}
|
|
137
|
+
if (phase === 'scraping') {
|
|
138
|
+
return (_jsx(Box, { flexDirection: "column", width: "100%", height: renderHeight, children: renderScraping() }));
|
|
139
|
+
}
|
|
140
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: renderHeight, children: [_jsx(Banner, { phase: phase, currentUrl: currentUrl, width: width, height: renderHeight }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: phase === 'error' && renderError() }), _jsx(Footer, { phase: phase, width: width })] }));
|
|
63
141
|
}
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
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 { sanitizeTerminalText } from './utils/helpers.js';
|
|
6
|
+
import { createSynchronizedWriteProxy, resetDefaultTerminalBackground, shouldUseDisplayPalette, shouldUseSynchronizedOutput, } from './utils/terminal.js';
|
|
7
|
+
const ENTER_ALT_SCREEN = '\u001B[?1049h\u001B[2J\u001B[H';
|
|
8
|
+
const EXIT_ALT_SCREEN = '\u001B[?1049l';
|
|
5
9
|
// Simple arg parsing – accept an optional recipe URL as the first positional arg
|
|
6
10
|
const args = process.argv.slice(2);
|
|
7
11
|
const url = args.find((a) => !a.startsWith('-'));
|
|
@@ -28,7 +32,37 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
28
32
|
}
|
|
29
33
|
// Handle --version / -v
|
|
30
34
|
if (args.includes('--version') || args.includes('-v')) {
|
|
31
|
-
console.log('parsely-cli v2.
|
|
35
|
+
console.log('parsely-cli v2.2.0');
|
|
32
36
|
process.exit(0);
|
|
33
37
|
}
|
|
34
|
-
|
|
38
|
+
async function main() {
|
|
39
|
+
const useAltScreen = process.stdout.isTTY;
|
|
40
|
+
const inkStdout = useAltScreen && shouldUseSynchronizedOutput()
|
|
41
|
+
? createSynchronizedWriteProxy(process.stdout)
|
|
42
|
+
: process.stdout;
|
|
43
|
+
if (useAltScreen) {
|
|
44
|
+
process.stdout.write(ENTER_ALT_SCREEN);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const instance = render(_jsx(App, { initialUrl: url }), {
|
|
48
|
+
exitOnCtrlC: false,
|
|
49
|
+
stdout: inkStdout,
|
|
50
|
+
});
|
|
51
|
+
await instance.waitUntilExit();
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
if (useAltScreen && shouldUseDisplayPalette()) {
|
|
55
|
+
process.stdout.write(resetDefaultTerminalBackground());
|
|
56
|
+
}
|
|
57
|
+
if (useAltScreen) {
|
|
58
|
+
process.stdout.write(EXIT_ALT_SCREEN);
|
|
59
|
+
}
|
|
60
|
+
if (useAltScreen && shouldUseDisplayPalette()) {
|
|
61
|
+
process.stdout.write(resetDefaultTerminalBackground());
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
main().catch((error) => {
|
|
66
|
+
console.error(error instanceof Error ? sanitizeTerminalText(error.message) : 'Unexpected error');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
});
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
type AppPhase = 'idle' | 'scraping' | 'display' | 'error';
|
|
2
|
+
interface BannerProps {
|
|
3
|
+
phase: AppPhase;
|
|
4
|
+
currentUrl?: string;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function Banner({ phase, currentUrl, width, height }: BannerProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { theme } from '../theme.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
import { getUrlHost } from '../utils/helpers.js';
|
|
5
|
+
function getPhaseMeta(phase) {
|
|
6
|
+
switch (phase) {
|
|
7
|
+
case 'scraping':
|
|
8
|
+
return { label: 'SCRAPING', color: theme.colors.secondary };
|
|
9
|
+
case 'display':
|
|
10
|
+
return { label: 'PLATED', color: theme.colors.success };
|
|
11
|
+
case 'error':
|
|
12
|
+
return { label: 'RETRY', color: theme.colors.error };
|
|
13
|
+
default:
|
|
14
|
+
return { label: 'READY', color: theme.colors.primary };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function Banner({ phase, currentUrl, width, height }) {
|
|
18
|
+
const compact = width < 88 || height < 26;
|
|
19
|
+
const phaseMeta = getPhaseMeta(phase);
|
|
20
|
+
const host = getUrlHost(currentUrl);
|
|
21
|
+
return (_jsxs(Box, { flexDirection: compact ? 'column' : 'row', justifyContent: "space-between", borderStyle: "round", borderColor: theme.colors.border, paddingX: 1, paddingY: 0, marginBottom: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.banner, children: "PARSELY" }), _jsx(Text, { color: theme.colors.text, bold: true, children: "Recipe intelligence in the terminal" }), !compact && (_jsx(Text, { color: theme.colors.muted, children: "Browser-first extraction with an AI rescue path when recipe schema is missing." }))] }), _jsxs(Box, { flexDirection: "column", alignItems: compact ? undefined : 'flex-end', marginTop: compact ? 1 : 0, children: [_jsxs(Text, { bold: true, color: phaseMeta.color, children: ["[", phaseMeta.label, "]"] }), _jsx(Text, { color: theme.colors.muted, children: host || 'Paste a recipe URL to begin' })] })] }));
|
|
12
22
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { jsxs as _jsxs
|
|
1
|
+
import { 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
5
|
export function ErrorDisplay({ message }) {
|
|
5
|
-
return (_jsxs(
|
|
6
|
+
return (_jsxs(Panel, { title: "Scrape failed", eyebrow: "Recovery", accentColor: theme.colors.error, marginBottom: 1, children: [_jsxs(Text, { bold: true, color: theme.colors.error, children: [theme.symbols.cross, " ", message] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.colors.text, children: [theme.symbols.bullet, " Double-check that the URL points to a specific recipe page."] }), _jsxs(Text, { color: theme.colors.text, children: [theme.symbols.bullet, " Install Chrome or Chromium for the browser-first path."] }), _jsxs(Text, { color: theme.colors.text, children: [theme.symbols.bullet, " Add `OPENAI_API_KEY` to `.env.local` if you want AI fallback enabled."] })] })] }));
|
|
6
7
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type AppPhase = 'idle' | 'scraping' | 'display' | 'error';
|
|
2
2
|
interface FooterProps {
|
|
3
3
|
phase: AppPhase;
|
|
4
|
+
width: number;
|
|
4
5
|
}
|
|
5
|
-
export declare function Footer({ phase }: FooterProps): import("react/jsx-runtime").JSX.Element;
|
|
6
|
+
export declare function Footer({ phase, width }: FooterProps): import("react/jsx-runtime").JSX.Element;
|
|
6
7
|
export {};
|
|
@@ -4,22 +4,46 @@ import { Box, Text } from 'ink';
|
|
|
4
4
|
import { theme } from '../theme.js';
|
|
5
5
|
const keybinds = {
|
|
6
6
|
idle: [
|
|
7
|
-
{ key: 'enter', label: '
|
|
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' },
|
|
19
|
+
{ key: 'esc', label: 'back' },
|
|
16
20
|
],
|
|
17
21
|
error: [
|
|
18
|
-
{ key: 'enter', label: '
|
|
22
|
+
{ key: 'enter', label: 'retry' },
|
|
23
|
+
{ key: 'ctrl+t', label: 'theme' },
|
|
19
24
|
{ key: 'ctrl+c', label: 'exit' },
|
|
20
25
|
],
|
|
21
26
|
};
|
|
22
|
-
|
|
27
|
+
function getStatusCopy(phase) {
|
|
28
|
+
switch (phase) {
|
|
29
|
+
case 'scraping':
|
|
30
|
+
return 'Scanning the page and preparing a clean recipe deck.';
|
|
31
|
+
case 'display':
|
|
32
|
+
return 'Recipe plated. Press n to scrape another page.';
|
|
33
|
+
case 'error':
|
|
34
|
+
return 'The scrape failed. Adjust the URL or enable the AI fallback.';
|
|
35
|
+
default:
|
|
36
|
+
return 'Ready for a recipe URL.';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function Footer({ phase, width }) {
|
|
23
40
|
const hints = keybinds[phase];
|
|
24
|
-
|
|
41
|
+
const compact = width < 92;
|
|
42
|
+
const statusCopy = compact
|
|
43
|
+
? getStatusCopy(phase).replace('Scanning the page and preparing a clean recipe deck.', 'Scanning recipe page.')
|
|
44
|
+
.replace('Recipe plated. Press n to scrape another page.', 'Recipe plated.')
|
|
45
|
+
.replace('The scrape failed. Adjust the URL or enable the AI fallback.', 'Scrape failed.')
|
|
46
|
+
.replace('Ready for a recipe URL.', 'Ready.')
|
|
47
|
+
: getStatusCopy(phase);
|
|
48
|
+
return (_jsxs(Box, { flexDirection: compact ? 'column' : 'row', justifyContent: "space-between", borderStyle: "round", borderColor: theme.colors.border, paddingX: 1, paddingY: 0, marginTop: 1, children: [_jsx(Text, { color: theme.colors.muted, children: statusCopy }), _jsx(Box, { marginTop: compact ? 1 : 0, children: hints.map((hint, index) => (_jsxs(React.Fragment, { children: [index > 0 && _jsxs(Text, { color: theme.colors.muted, children: [" ", theme.symbols.dot, " "] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.colors.primary, bold: true, children: hint.key }), _jsxs(Text, { color: theme.colors.muted, children: [" ", hint.label] })] })] }, hint.key))) })] }));
|
|
25
49
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface LandingScreenProps {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
onSubmit: (url: string) => void;
|
|
5
|
+
onToggleTheme?: () => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function LandingScreen({ width, height, onSubmit, onToggleTheme }: LandingScreenProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export {};
|