@sambitcreate/parsely-cli 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -112
- package/dist/app.js +65 -22
- package/dist/cli.js +28 -1
- package/dist/components/Banner.d.ts +9 -1
- package/dist/components/Banner.js +18 -8
- package/dist/components/ErrorDisplay.js +3 -2
- package/dist/components/Footer.d.ts +2 -1
- package/dist/components/Footer.js +24 -4
- package/dist/components/LandingScreen.d.ts +7 -0
- package/dist/components/LandingScreen.js +74 -0
- package/dist/components/LoadingScreen.d.ts +6 -0
- package/dist/components/LoadingScreen.js +21 -0
- package/dist/components/Panel.d.ts +9 -0
- package/dist/components/Panel.js +6 -0
- package/dist/components/PhaseRail.d.ts +9 -0
- package/dist/components/PhaseRail.js +88 -0
- package/dist/components/RecipeCard.d.ts +3 -1
- package/dist/components/RecipeCard.js +76 -11
- package/dist/components/ScrapingStatus.d.ts +2 -1
- package/dist/components/ScrapingStatus.js +25 -8
- package/dist/components/URLInput.d.ts +3 -1
- package/dist/components/URLInput.js +21 -16
- package/dist/components/Welcome.d.ts +6 -1
- package/dist/components/Welcome.js +5 -2
- package/dist/hooks/useDisplayPalette.d.ts +1 -0
- package/dist/hooks/useDisplayPalette.js +15 -0
- package/dist/hooks/useTerminalViewport.d.ts +6 -0
- package/dist/hooks/useTerminalViewport.js +23 -0
- package/dist/services/scraper.d.ts +8 -1
- package/dist/services/scraper.js +144 -21
- package/dist/theme.d.ts +27 -14
- package/dist/theme.js +27 -14
- package/dist/utils/helpers.d.ts +3 -0
- package/dist/utils/helpers.js +20 -0
- package/dist/utils/terminal.d.ts +8 -0
- package/dist/utils/terminal.js +65 -0
- package/package.json +12 -8
- package/public/parsely-logo.svg +1 -0
package/README.md
CHANGED
|
@@ -1,151 +1,138 @@
|
|
|
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
|
-
|
|
12
|
+
### Homebrew
|
|
13
|
+
```bash
|
|
14
|
+
brew tap sambitcreate/tap
|
|
15
|
+
brew install parsely
|
|
16
|
+
```
|
|
49
17
|
|
|
50
|
-
|
|
18
|
+
## Usage
|
|
51
19
|
|
|
52
|
-
|
|
53
|
-
|
|
20
|
+
```bash
|
|
21
|
+
parsely
|
|
22
|
+
parsely https://www.simplyrecipes.com/recipes/perfect_guacamole/
|
|
23
|
+
parsely --help
|
|
24
|
+
parsely --version
|
|
25
|
+
```
|
|
54
26
|
|
|
55
|
-
|
|
27
|
+
## Configuration
|
|
56
28
|
|
|
57
|
-
|
|
29
|
+
For AI fallback (optional but recommended), set the OpenAI API key:
|
|
58
30
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
```
|
|
31
|
+
```bash
|
|
32
|
+
export OPENAI_API_KEY="your_key_here"
|
|
33
|
+
```
|
|
63
34
|
|
|
64
|
-
|
|
35
|
+
Without this, browser scraping still works for most recipe sites.
|
|
65
36
|
|
|
66
|
-
|
|
67
|
-
npm install
|
|
68
|
-
```
|
|
37
|
+
Optional terminal tuning:
|
|
69
38
|
|
|
70
|
-
|
|
39
|
+
```bash
|
|
40
|
+
export PARSELY_SYNC_OUTPUT=1 # force synchronized output on
|
|
41
|
+
export PARSELY_SYNC_OUTPUT=0 # force synchronized output off
|
|
42
|
+
```
|
|
71
43
|
|
|
72
|
-
|
|
44
|
+
## Terminal UI
|
|
73
45
|
|
|
74
|
-
|
|
46
|
+
- Uses an Ink-based app shell instead of printing one-off output
|
|
47
|
+
- Switches into the terminal alternate screen from the CLI entrypoint and restores the previous screen on exit
|
|
48
|
+
- Reserves one terminal row so Ink can update incrementally instead of clearing the whole screen on every spinner tick
|
|
49
|
+
- Adapts the layout to the current terminal size for wide and narrow viewports
|
|
50
|
+
- Collapses non-essential panels on shorter terminals so the URL field stays usable
|
|
51
|
+
- Cancels in-flight browser and AI scraping when you press `Ctrl+C`
|
|
52
|
+
- Shows a live scraping pipeline so browser parsing and AI fallback are visible as separate stages
|
|
53
|
+
- Enables synchronized output in Ghostty by default so frame updates paint atomically
|
|
75
54
|
|
|
76
|
-
|
|
77
|
-
OPENAI_API_KEY="your_openai_api_key_here"
|
|
78
|
-
```
|
|
55
|
+
## Keyboard Shortcuts
|
|
79
56
|
|
|
80
|
-
|
|
57
|
+
| Key | Action |
|
|
58
|
+
| -------- | ----------------- |
|
|
59
|
+
| `Enter` | Submit URL |
|
|
60
|
+
| `n` | Scrape new recipe |
|
|
61
|
+
| `q` | Quit |
|
|
62
|
+
| `Esc` | Quit from result view |
|
|
63
|
+
| `Ctrl+C` | Exit |
|
|
81
64
|
|
|
82
|
-
##
|
|
65
|
+
## Troubleshooting
|
|
83
66
|
|
|
84
|
-
|
|
67
|
+
- **`Error: OpenAI API key not found`** — Set `OPENAI_API_KEY` environment variable
|
|
68
|
+
- **Browser scraping skipped** — Install Chrome or Chromium for better results
|
|
69
|
+
- **No recipe found** — AI fallback handles most sites, but results vary by site
|
|
70
|
+
- **Terminal looks cleared while running** — Expected; Parsely uses the alternate screen and restores your previous terminal content when it exits
|
|
71
|
+
- **Ghostty still flickers** — Parsely enables synchronized output automatically in Ghostty; set `PARSELY_SYNC_OUTPUT=1` to force it on elsewhere or `PARSELY_SYNC_OUTPUT=0` to disable it
|
|
72
|
+
- **Some sites challenge headless browsers** — Parsely now uses a more browser-like Puppeteer setup, but challenge pages can still force an AI fallback
|
|
85
73
|
|
|
86
|
-
|
|
87
|
-
./run.sh
|
|
88
|
-
```
|
|
74
|
+
## License
|
|
89
75
|
|
|
90
|
-
|
|
76
|
+
MIT — see [LICENSE](LICENSE).
|
|
91
77
|
|
|
92
|
-
|
|
78
|
+
## For Developers
|
|
93
79
|
|
|
94
|
-
|
|
95
|
-
npm start -- https://www.simplyrecipes.com/recipes/perfect_guacamole/
|
|
80
|
+
### Project Structure
|
|
96
81
|
```
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
82
|
+
parsely-cli/
|
|
83
|
+
├── src/
|
|
84
|
+
│ ├── cli.tsx # Entry point
|
|
85
|
+
│ ├── app.tsx # Root component — app shell + state machine
|
|
86
|
+
│ ├── theme.ts # Color palette
|
|
87
|
+
│ ├── components/ # UI components
|
|
88
|
+
│ ├── hooks/ # Terminal viewport and screen management
|
|
89
|
+
│ ├── services/scraper.ts # Puppeteer + OpenAI
|
|
90
|
+
│ └── utils/ # Input, URL, and terminal helpers
|
|
91
|
+
├── test/ # Unit tests for helpers and scraper parsing
|
|
92
|
+
├── package.json
|
|
93
|
+
├── tsconfig.json
|
|
94
|
+
└── CLAUDE.md # AI assistant context
|
|
102
95
|
```
|
|
103
96
|
|
|
104
|
-
###
|
|
105
|
-
|
|
106
|
-
Run without arguments and enter a URL when prompted:
|
|
97
|
+
### Development Setup
|
|
107
98
|
|
|
108
99
|
```bash
|
|
109
|
-
|
|
100
|
+
git clone https://github.com/sambitcreate/parsely-cli.git
|
|
101
|
+
cd parsely-cli
|
|
102
|
+
npm install
|
|
103
|
+
npm run dev
|
|
104
|
+
npm test
|
|
110
105
|
```
|
|
111
106
|
|
|
112
|
-
###
|
|
113
|
-
|
|
114
|
-
| Key | Context | Action |
|
|
115
|
-
| -------- | -------- | ----------------- |
|
|
116
|
-
| `Enter` | Input | Submit URL |
|
|
117
|
-
| `n` | Display | Scrape new recipe |
|
|
118
|
-
| `q` | Display | Quit |
|
|
119
|
-
| `Ctrl+C` | Anywhere | Exit |
|
|
107
|
+
### How It Works
|
|
120
108
|
|
|
121
|
-
|
|
109
|
+
1. **Browser Scraping** — Headless Chrome loads the page and extracts Schema.org JSON-LD recipe data
|
|
110
|
+
2. **Parsing Stage** — Parsely scans and normalizes recipe schema before deciding whether the page is usable
|
|
111
|
+
3. **AI Fallback** — OpenAI `gpt-4o-mini` extracts data only when browser parsing cannot recover a recipe
|
|
112
|
+
4. **Display** — The result is plated into a responsive terminal recipe deck with pipeline, prep, and method panels
|
|
122
113
|
|
|
123
|
-
|
|
114
|
+
### UI Structure
|
|
124
115
|
|
|
125
|
-
|
|
116
|
+
- `Banner` — status-aware header with current host and app state
|
|
117
|
+
- `Panel` — shared bordered container used across the app shell
|
|
118
|
+
- `PhaseRail` — pipeline view for browser, parsing, and AI stages
|
|
119
|
+
- `URLInput` — normalizes pasted newlines and submits on `Enter`
|
|
120
|
+
- `RecipeCard` — split recipe layout with summary, ingredients, timing, and method
|
|
121
|
+
- `Footer` — persistent status line and key hints
|
|
122
|
+
- `useTerminalViewport` — terminal sizing and resize tracking
|
|
123
|
+
- `utils/terminal.ts` — synchronized-output and render-height helpers for stable full-screen updates
|
|
126
124
|
|
|
127
|
-
|
|
125
|
+
### Tests
|
|
128
126
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
- **Component-based architecture** — Each UI element is an isolated React component.
|
|
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.
|
|
138
|
-
|
|
139
|
-
## Troubleshooting
|
|
140
|
-
|
|
141
|
-
- **`Error: OpenAI API key not found`** — Create a `.env.local` file with your API key. The AI fallback requires this, but browser scraping works without it.
|
|
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.
|
|
144
|
-
|
|
145
|
-
## License
|
|
127
|
+
```bash
|
|
128
|
+
npm test
|
|
129
|
+
```
|
|
146
130
|
|
|
147
|
-
|
|
131
|
+
The test suite covers input normalization and the pure HTML/schema extraction helpers used by the scraper.
|
|
148
132
|
|
|
149
|
-
|
|
133
|
+
### Build & Publish
|
|
150
134
|
|
|
151
|
-
|
|
135
|
+
```bash
|
|
136
|
+
npm run build
|
|
137
|
+
npm publish --access public
|
|
138
|
+
```
|
package/dist/app.js
CHANGED
|
@@ -1,63 +1,106 @@
|
|
|
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 { useTerminalViewport } from './hooks/useTerminalViewport.js';
|
|
12
13
|
import { theme } from './theme.js';
|
|
14
|
+
import { getRenderableHeight } from './utils/terminal.js';
|
|
15
|
+
import { LandingScreen } from './components/LandingScreen.js';
|
|
16
|
+
import { LoadingScreen } from './components/LoadingScreen.js';
|
|
13
17
|
export function App({ initialUrl }) {
|
|
14
18
|
const { exit } = useApp();
|
|
19
|
+
const { width, height } = useTerminalViewport();
|
|
20
|
+
const renderHeight = getRenderableHeight(height);
|
|
15
21
|
const [phase, setPhase] = useState(initialUrl ? 'scraping' : 'idle');
|
|
16
22
|
const [recipe, setRecipe] = useState(null);
|
|
17
23
|
const [scrapeStatus, setScrapeStatus] = useState(null);
|
|
18
24
|
const [error, setError] = useState('');
|
|
25
|
+
const [currentUrl, setCurrentUrl] = useState(initialUrl ?? '');
|
|
26
|
+
const activeScrapeController = useRef(null);
|
|
27
|
+
const wide = width >= 112;
|
|
28
|
+
const shortViewport = renderHeight < 30;
|
|
29
|
+
const cancelActiveScrape = useCallback(() => {
|
|
30
|
+
activeScrapeController.current?.abort();
|
|
31
|
+
activeScrapeController.current = null;
|
|
32
|
+
}, []);
|
|
19
33
|
const handleScrape = useCallback(async (url) => {
|
|
34
|
+
cancelActiveScrape();
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
activeScrapeController.current = controller;
|
|
37
|
+
setCurrentUrl(url);
|
|
20
38
|
setPhase('scraping');
|
|
21
39
|
setError('');
|
|
22
40
|
setScrapeStatus({ phase: 'browser', message: 'Starting\u2026' });
|
|
23
41
|
try {
|
|
24
42
|
const result = await scrapeRecipe(url, (status) => {
|
|
25
43
|
setScrapeStatus(status);
|
|
26
|
-
});
|
|
44
|
+
}, controller.signal);
|
|
45
|
+
if (controller.signal.aborted || activeScrapeController.current !== controller) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
27
48
|
setRecipe(result);
|
|
28
49
|
setPhase('display');
|
|
29
50
|
}
|
|
30
51
|
catch (err) {
|
|
52
|
+
if (controller.signal.aborted || activeScrapeController.current !== controller) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
31
55
|
setError(err instanceof Error ? err.message : 'Failed to scrape recipe');
|
|
32
56
|
setPhase('error');
|
|
33
57
|
}
|
|
34
|
-
|
|
58
|
+
finally {
|
|
59
|
+
if (activeScrapeController.current === controller) {
|
|
60
|
+
activeScrapeController.current = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}, [cancelActiveScrape]);
|
|
35
64
|
const handleNewRecipe = useCallback(() => {
|
|
36
65
|
setPhase('idle');
|
|
37
66
|
setRecipe(null);
|
|
38
67
|
setError('');
|
|
39
68
|
setScrapeStatus(null);
|
|
69
|
+
setCurrentUrl('');
|
|
40
70
|
}, []);
|
|
41
|
-
// Scrape the initial URL if provided via CLI argument
|
|
42
71
|
useEffect(() => {
|
|
43
72
|
if (initialUrl) {
|
|
44
73
|
handleScrape(initialUrl);
|
|
45
74
|
}
|
|
46
75
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
47
|
-
|
|
48
|
-
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
return () => {
|
|
78
|
+
cancelActiveScrape();
|
|
79
|
+
};
|
|
80
|
+
}, [cancelActiveScrape]);
|
|
49
81
|
useInput((input, key) => {
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
exit();
|
|
55
|
-
}
|
|
56
|
-
// Ctrl+C is handled by Ink automatically
|
|
57
|
-
if (key.escape) {
|
|
58
|
-
if (phase === 'display')
|
|
59
|
-
exit();
|
|
82
|
+
if (key.ctrl && input === 'c') {
|
|
83
|
+
cancelActiveScrape();
|
|
84
|
+
exit();
|
|
85
|
+
return;
|
|
60
86
|
}
|
|
61
|
-
|
|
62
|
-
|
|
87
|
+
if (phase === 'display' && input === 'n')
|
|
88
|
+
handleNewRecipe();
|
|
89
|
+
if (phase === 'display' && (input === 'q' || key.escape))
|
|
90
|
+
exit();
|
|
91
|
+
}, { isActive: phase === 'display' || phase === 'scraping' || phase === 'idle' || phase === 'error' });
|
|
92
|
+
const renderIdle = () => (_jsx(LandingScreen, { width: width, height: renderHeight, onSubmit: handleScrape }));
|
|
93
|
+
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 }) }) })] }));
|
|
96
|
+
if (phase === 'display') {
|
|
97
|
+
return (_jsx(Box, { flexDirection: "column", width: "100%", children: renderDisplay() }));
|
|
98
|
+
}
|
|
99
|
+
if (phase === 'idle') {
|
|
100
|
+
return (_jsx(Box, { flexDirection: "column", width: "100%", height: renderHeight, children: renderIdle() }));
|
|
101
|
+
}
|
|
102
|
+
if (phase === 'scraping') {
|
|
103
|
+
return (_jsx(Box, { flexDirection: "column", width: "100%", height: renderHeight, children: renderScraping() }));
|
|
104
|
+
}
|
|
105
|
+
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
106
|
}
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
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 { createSynchronizedWriteProxy, shouldUseSynchronizedOutput } from './utils/terminal.js';
|
|
6
|
+
const ENTER_ALT_SCREEN = '\u001B[?1049h\u001B[2J\u001B[H';
|
|
7
|
+
const EXIT_ALT_SCREEN = '\u001B[?1049l';
|
|
5
8
|
// Simple arg parsing – accept an optional recipe URL as the first positional arg
|
|
6
9
|
const args = process.argv.slice(2);
|
|
7
10
|
const url = args.find((a) => !a.startsWith('-'));
|
|
@@ -31,4 +34,28 @@ if (args.includes('--version') || args.includes('-v')) {
|
|
|
31
34
|
console.log('parsely-cli v2.0.0');
|
|
32
35
|
process.exit(0);
|
|
33
36
|
}
|
|
34
|
-
|
|
37
|
+
async function main() {
|
|
38
|
+
const useAltScreen = process.stdout.isTTY;
|
|
39
|
+
const inkStdout = useAltScreen && shouldUseSynchronizedOutput()
|
|
40
|
+
? createSynchronizedWriteProxy(process.stdout)
|
|
41
|
+
: process.stdout;
|
|
42
|
+
if (useAltScreen) {
|
|
43
|
+
process.stdout.write(ENTER_ALT_SCREEN);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const instance = render(_jsx(App, { initialUrl: url }), {
|
|
47
|
+
exitOnCtrlC: false,
|
|
48
|
+
stdout: inkStdout,
|
|
49
|
+
});
|
|
50
|
+
await instance.waitUntilExit();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
if (useAltScreen) {
|
|
54
|
+
process.stdout.write(EXIT_ALT_SCREEN);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
main().catch((error) => {
|
|
59
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|
|
@@ -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,7 +4,7 @@ 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
8
|
{ key: 'ctrl+c', label: 'exit' },
|
|
9
9
|
],
|
|
10
10
|
scraping: [
|
|
@@ -13,13 +13,33 @@ const keybinds = {
|
|
|
13
13
|
display: [
|
|
14
14
|
{ key: 'n', label: 'new recipe' },
|
|
15
15
|
{ key: 'q', label: 'quit' },
|
|
16
|
+
{ key: 'esc', label: 'quit' },
|
|
16
17
|
],
|
|
17
18
|
error: [
|
|
18
|
-
{ key: 'enter', label: '
|
|
19
|
+
{ key: 'enter', label: 'retry' },
|
|
19
20
|
{ key: 'ctrl+c', label: 'exit' },
|
|
20
21
|
],
|
|
21
22
|
};
|
|
22
|
-
|
|
23
|
+
function getStatusCopy(phase) {
|
|
24
|
+
switch (phase) {
|
|
25
|
+
case 'scraping':
|
|
26
|
+
return 'Scanning the page and preparing a clean recipe deck.';
|
|
27
|
+
case 'display':
|
|
28
|
+
return 'Recipe plated. Press n to scrape another page.';
|
|
29
|
+
case 'error':
|
|
30
|
+
return 'The scrape failed. Adjust the URL or enable the AI fallback.';
|
|
31
|
+
default:
|
|
32
|
+
return 'Ready for a recipe URL.';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function Footer({ phase, width }) {
|
|
23
36
|
const hints = keybinds[phase];
|
|
24
|
-
|
|
37
|
+
const compact = width < 92;
|
|
38
|
+
const statusCopy = compact
|
|
39
|
+
? getStatusCopy(phase).replace('Scanning the page and preparing a clean recipe deck.', 'Scanning recipe page.')
|
|
40
|
+
.replace('Recipe plated. Press n to scrape another page.', 'Recipe plated.')
|
|
41
|
+
.replace('The scrape failed. Adjust the URL or enable the AI fallback.', 'Scrape failed.')
|
|
42
|
+
.replace('Ready for a recipe URL.', 'Ready.')
|
|
43
|
+
: getStatusCopy(phase);
|
|
44
|
+
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
45
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
import { useDisplayPalette } from '../hooks/useDisplayPalette.js';
|
|
9
|
+
const logoSvg = readFileSync(fileURLToPath(new URL('../../public/parsely-logo.svg', import.meta.url)), 'utf8');
|
|
10
|
+
const logoColor = logoSvg.match(/fill="(#[0-9a-fA-F]{6})"/)?.[1] ?? theme.colors.brand;
|
|
11
|
+
const ANSI_PATTERN = /\u001B\[[0-9;]*m/g;
|
|
12
|
+
const RENDER_BOUNDS = { width: 240, height: 80 };
|
|
13
|
+
function getLineWidth(line) {
|
|
14
|
+
return Array.from(line).length;
|
|
15
|
+
}
|
|
16
|
+
function trimBlankLines(lines) {
|
|
17
|
+
const trimmed = [...lines];
|
|
18
|
+
while (trimmed[0]?.trim() === '') {
|
|
19
|
+
trimmed.shift();
|
|
20
|
+
}
|
|
21
|
+
while (trimmed.at(-1)?.trim() === '') {
|
|
22
|
+
trimmed.pop();
|
|
23
|
+
}
|
|
24
|
+
return trimmed;
|
|
25
|
+
}
|
|
26
|
+
function stripCommonIndent(lines) {
|
|
27
|
+
const nonEmpty = lines.filter((line) => line.trim());
|
|
28
|
+
if (nonEmpty.length === 0) {
|
|
29
|
+
return lines;
|
|
30
|
+
}
|
|
31
|
+
const indent = Math.min(...nonEmpty.map((line) => line.match(/^\s*/u)?.[0].length ?? 0));
|
|
32
|
+
if (indent === 0) {
|
|
33
|
+
return lines;
|
|
34
|
+
}
|
|
35
|
+
return lines.map((line) => line.slice(indent));
|
|
36
|
+
}
|
|
37
|
+
function buildLandingArt() {
|
|
38
|
+
const rendered = CFonts.render('Parsely', {
|
|
39
|
+
font: 'block',
|
|
40
|
+
align: 'left',
|
|
41
|
+
colors: ['system'],
|
|
42
|
+
letterSpacing: 0,
|
|
43
|
+
lineHeight: 0,
|
|
44
|
+
space: true,
|
|
45
|
+
maxLength: 0,
|
|
46
|
+
}, false, 0, RENDER_BOUNDS);
|
|
47
|
+
if (!rendered) {
|
|
48
|
+
return {
|
|
49
|
+
lines: ['PARSLEY'],
|
|
50
|
+
width: 'PARSLEY'.length,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const lines = stripCommonIndent(trimBlankLines(rendered.string
|
|
54
|
+
.replace(ANSI_PATTERN, '')
|
|
55
|
+
.split('\n')
|
|
56
|
+
.map((line) => line.replace(/\s+$/u, ''))));
|
|
57
|
+
return {
|
|
58
|
+
lines,
|
|
59
|
+
width: Math.max(...lines.map(getLineWidth)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const primaryLandingArt = buildLandingArt();
|
|
63
|
+
const compactLandingArt = {
|
|
64
|
+
lines: ['PARSLEY'],
|
|
65
|
+
width: 'PARSLEY'.length,
|
|
66
|
+
};
|
|
67
|
+
export function LandingScreen({ width, height, onSubmit }) {
|
|
68
|
+
useDisplayPalette(theme.colors.recipePaper);
|
|
69
|
+
const art = width >= primaryLandingArt.width + 8 ? primaryLandingArt : compactLandingArt;
|
|
70
|
+
const inputWidth = width >= 120 ? 54 : width >= 84 ? 46 : Math.max(28, width - 16);
|
|
71
|
+
const controlsWidth = inputWidth + 8;
|
|
72
|
+
const contentWidth = Math.min(width - 6, Math.max(controlsWidth, art.width));
|
|
73
|
+
return (_jsxs(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 }, index))) }) }), _jsx(Box, { width: controlsWidth, justifyContent: "center", children: _jsx(URLInput, { onSubmit: onSubmit, mode: "landing", width: inputWidth }) })] }) }), _jsx(Box, { height: 1 })] }));
|
|
74
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
import { useDisplayPalette } from '../hooks/useDisplayPalette.js';
|
|
6
|
+
function getLoadingCopy(status) {
|
|
7
|
+
switch (status?.phase) {
|
|
8
|
+
case 'parsing':
|
|
9
|
+
return 'Preparing recipe...';
|
|
10
|
+
case 'ai':
|
|
11
|
+
return 'Recovering recipe...';
|
|
12
|
+
case 'error':
|
|
13
|
+
return 'Recipe failed to load.';
|
|
14
|
+
default:
|
|
15
|
+
return 'Loading recipe...';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function LoadingScreen({ status }) {
|
|
19
|
+
useDisplayPalette(theme.colors.recipePaper);
|
|
20
|
+
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
|
+
}
|