@sambitcreate/parsely-cli 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +114 -113
  2. package/dist/app.js +104 -26
  3. package/dist/cli.js +36 -2
  4. package/dist/components/Banner.d.ts +9 -1
  5. package/dist/components/Banner.js +18 -8
  6. package/dist/components/ErrorDisplay.js +3 -2
  7. package/dist/components/Footer.d.ts +2 -1
  8. package/dist/components/Footer.js +28 -4
  9. package/dist/components/LandingScreen.d.ts +8 -0
  10. package/dist/components/LandingScreen.js +89 -0
  11. package/dist/components/LoadingScreen.d.ts +6 -0
  12. package/dist/components/LoadingScreen.js +19 -0
  13. package/dist/components/Panel.d.ts +9 -0
  14. package/dist/components/Panel.js +6 -0
  15. package/dist/components/PhaseRail.d.ts +9 -0
  16. package/dist/components/PhaseRail.js +88 -0
  17. package/dist/components/RecipeCard.d.ts +4 -1
  18. package/dist/components/RecipeCard.js +202 -12
  19. package/dist/components/ScrapingStatus.d.ts +2 -1
  20. package/dist/components/ScrapingStatus.js +25 -8
  21. package/dist/components/URLInput.d.ts +4 -1
  22. package/dist/components/URLInput.js +46 -18
  23. package/dist/components/Welcome.d.ts +6 -1
  24. package/dist/components/Welcome.js +5 -2
  25. package/dist/hooks/useDisplayPalette.d.ts +1 -0
  26. package/dist/hooks/useDisplayPalette.js +15 -0
  27. package/dist/hooks/useTerminalViewport.d.ts +6 -0
  28. package/dist/hooks/useTerminalViewport.js +23 -0
  29. package/dist/services/scraper.d.ts +9 -1
  30. package/dist/services/scraper.js +290 -58
  31. package/dist/theme.d.ts +88 -28
  32. package/dist/theme.js +122 -27
  33. package/dist/utils/helpers.d.ts +4 -0
  34. package/dist/utils/helpers.js +30 -0
  35. package/dist/utils/shortcuts.d.ts +6 -0
  36. package/dist/utils/shortcuts.js +15 -0
  37. package/dist/utils/terminal.d.ts +8 -0
  38. package/dist/utils/terminal.js +114 -0
  39. package/dist/utils/text-layout.d.ts +1 -0
  40. package/dist/utils/text-layout.js +63 -0
  41. package/package.json +12 -8
  42. package/public/parsely-logo.svg +1 -0
package/README.md CHANGED
@@ -1,151 +1,152 @@
1
1
  # Parsely CLI
2
2
 
3
- A smart, interactive recipe scraper for the terminal. Parsely extracts structured recipe data (ingredients, instructions, cook times) from any recipe URL using headless Chrome with an intelligent AI fallback.
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
- Built with [Ink](https://github.com/vadimdemedes/ink) (React for CLIs) for a rich, responsive terminal UI.
5
+ ## Installation
6
6
 
7
- ## Features
8
-
9
- - **Interactive TUI** — Full terminal interface built with Ink and React, featuring bordered panels, spinners, and color-coded output.
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
- ![Parsely CLI Screenshot](screenshot.png)
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
- ## Setup
49
-
50
- ### Prerequisites
51
-
52
- - **Node.js** v18 or later
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
- ./run.sh
21
+ parsely
22
+ parsely https://www.simplyrecipes.com/recipes/perfect_guacamole/
23
+ parsely --help
24
+ parsely --version
88
25
  ```
89
26
 
90
- The launcher script installs dependencies automatically on first run, then starts the TUI.
27
+ ## Configuration
91
28
 
92
- ### With a URL Argument
29
+ For AI fallback (optional but recommended), set the OpenAI API key:
93
30
 
94
31
  ```bash
95
- npm start -- https://www.simplyrecipes.com/recipes/perfect_guacamole/
32
+ export OPENAI_API_KEY="your_key_here"
96
33
  ```
97
34
 
98
- Or via the run script:
35
+ Without this, browser scraping still works for most recipe sites.
36
+
37
+ Optional terminal tuning:
99
38
 
100
39
  ```bash
101
- ./run.sh https://www.simplyrecipes.com/recipes/perfect_guacamole/
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
- ### Interactive Mode
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
- Run without arguments and enter a URL when prompted:
75
+ ## Troubleshooting
107
76
 
108
- ```bash
109
- npm start
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
- ### Keyboard Shortcuts
86
+ ## License
113
87
 
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 |
88
+ MIT see [LICENSE](LICENSE).
120
89
 
121
- ## How It Works
90
+ ## For Developers
122
91
 
123
- 1. **Browser Scraping** — Puppeteer launches headless Chrome, navigates to the URL, and extracts `<script type="application/ld+json">` blocks. The first Schema.org `Recipe` object found is parsed and displayed.
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
- 2. **AI Fallback** — If the browser fails or no JSON-LD recipe is found, the URL is sent to OpenAI's `gpt-4o-mini` with a structured extraction prompt. The model returns recipe data as JSON.
109
+ ### Development Setup
126
110
 
127
- 3. **Display** — Recipe data is rendered in a bordered card with color-coded sections for times, ingredients, and instructions.
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
- ## Architecture
119
+ ### How It Works
130
120
 
131
- The TUI is built with **Ink** (React for the terminal) following patterns inspired by [OpenCode](https://github.com/anomalyco/opencode):
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
- - **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.
126
+ ### UI Structure
138
127
 
139
- ## Troubleshooting
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
- - **`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.
139
+ ### Tests
144
140
 
145
- ## License
141
+ ```bash
142
+ npm test
143
+ ```
146
144
 
147
- MIT see [LICENSE](LICENSE).
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
- ## Code of Conduct
147
+ ### Build & Publish
150
148
 
151
- See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
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, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useCallback, useEffect } from 'react';
3
- import { Box, Text, useApp, useInput } from 'ink';
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 { theme } from './theme.js';
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
- setError(err instanceof Error ? err.message : 'Failed to scrape recipe');
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
- handleScrape(initialUrl);
87
+ if (!initialUrl || initialScrapeStarted.current) {
88
+ return;
45
89
  }
46
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
47
- // Global keybinds – only active during the display phase so they
48
- // do not interfere with the text input in idle/error phases.
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 (phase === 'display') {
51
- if (input === 'n')
52
- handleNewRecipe();
53
- if (input === 'q')
54
- exit();
111
+ if (key.ctrl && input === 'c') {
112
+ cancelActiveScrape();
113
+ exit();
114
+ return;
55
115
  }
56
- // Ctrl+C is handled by Ink automatically
57
- if (key.escape) {
58
- if (phase === 'display')
59
- exit();
116
+ if ((phase === 'display' || phase === 'scraping') && isThemeToggleShortcut(input, key)) {
117
+ handleToggleTheme();
118
+ return;
60
119
  }
61
- });
62
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Banner, {}), _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [phase === 'idle' && (_jsxs(_Fragment, { children: [_jsx(Welcome, {}), _jsx(URLInput, { onSubmit: handleScrape })] })), phase === 'scraping' && scrapeStatus && (_jsx(ScrapingStatus, { status: scrapeStatus })), phase === 'display' && recipe && (_jsxs(_Fragment, { children: [_jsx(RecipeCard, { recipe: recipe }), _jsx(Box, { marginTop: 1, marginLeft: 1, children: _jsxs(Text, { bold: true, color: theme.colors.success, children: [theme.symbols.check, " Recipe parsed successfully!"] }) })] })), phase === 'error' && (_jsxs(_Fragment, { children: [_jsx(ErrorDisplay, { message: error }), _jsx(URLInput, { onSubmit: handleScrape })] }))] }), _jsx(Footer, { phase: phase })] }));
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.0.0');
35
+ console.log('parsely-cli v2.2.0');
32
36
  process.exit(0);
33
37
  }
34
- render(_jsx(App, { initialUrl: url }));
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
- export declare function Banner(): import("react/jsx-runtime").JSX.Element;
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
- const LOGO = `\
5
- ____ _ ____ ____ _____ _ __ __ ____ _ ___
6
- | _ \\ / \\ | _ \\/ ___|| ____| | \\ \\ / / / ___| | |_ _|
7
- | |_) / _ \\ | |_) \\___ \\| _| | | \\ V / | | | | | |
8
- | __/ ___ \\| _ < ___) | |___| |___| | | |___| |___ | |
9
- |_| /_/ \\_\\_| \\_\\____/|_____|_____|_| \\____|_____|___|`;
10
- export function Banner() {
11
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: theme.colors.banner, bold: true, children: LOGO }), _jsxs(Text, { color: theme.colors.muted, children: [' ', "Smart recipe scraper ", theme.symbols.dot, " v2.0"] })] }));
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, jsx as _jsx } from "react/jsx-runtime";
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(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.error, paddingX: 1, paddingY: 1, marginBottom: 1, children: [_jsxs(Text, { bold: true, color: theme.colors.error, children: [theme.symbols.cross, " Scraping Failed"] }), _jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(Text, { color: theme.colors.text, wrap: "wrap", children: message }) }), _jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(Text, { color: theme.colors.muted, children: "Check the URL and try again, or ensure your .env.local has a valid OPENAI_API_KEY." }) })] }));
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: 'submit' },
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: 'submit' },
22
+ { key: 'enter', label: 'retry' },
23
+ { key: 'ctrl+t', label: 'theme' },
19
24
  { key: 'ctrl+c', label: 'exit' },
20
25
  ],
21
26
  };
22
- export function Footer({ phase }) {
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
- return (_jsx(Box, { marginTop: 1, paddingX: 1, gap: 1, children: hints.map((hint, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(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))) }));
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 {};