@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.
Files changed (38) hide show
  1. package/README.md +99 -112
  2. package/dist/app.js +65 -22
  3. package/dist/cli.js +28 -1
  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 +24 -4
  9. package/dist/components/LandingScreen.d.ts +7 -0
  10. package/dist/components/LandingScreen.js +74 -0
  11. package/dist/components/LoadingScreen.d.ts +6 -0
  12. package/dist/components/LoadingScreen.js +21 -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 +3 -1
  18. package/dist/components/RecipeCard.js +76 -11
  19. package/dist/components/ScrapingStatus.d.ts +2 -1
  20. package/dist/components/ScrapingStatus.js +25 -8
  21. package/dist/components/URLInput.d.ts +3 -1
  22. package/dist/components/URLInput.js +21 -16
  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 +8 -1
  30. package/dist/services/scraper.js +144 -21
  31. package/dist/theme.d.ts +27 -14
  32. package/dist/theme.js +27 -14
  33. package/dist/utils/helpers.d.ts +3 -0
  34. package/dist/utils/helpers.js +20 -0
  35. package/dist/utils/terminal.d.ts +8 -0
  36. package/dist/utils/terminal.js +65 -0
  37. package/package.json +12 -8
  38. package/public/parsely-logo.svg +1 -0
package/README.md CHANGED
@@ -1,151 +1,138 @@
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
12
+ ### Homebrew
13
+ ```bash
14
+ brew tap sambitcreate/tap
15
+ brew install parsely
16
+ ```
49
17
 
50
- ### Prerequisites
18
+ ## Usage
51
19
 
52
- - **Node.js** v18 or later
53
- - **npm** v9 or later
20
+ ```bash
21
+ parsely
22
+ parsely https://www.simplyrecipes.com/recipes/perfect_guacamole/
23
+ parsely --help
24
+ parsely --version
25
+ ```
54
26
 
55
- ### Installation
27
+ ## Configuration
56
28
 
57
- 1. **Clone the repository:**
29
+ For AI fallback (optional but recommended), set the OpenAI API key:
58
30
 
59
- ```bash
60
- git clone <your-repository-url>
61
- cd parsely-cli
62
- ```
31
+ ```bash
32
+ export OPENAI_API_KEY="your_key_here"
33
+ ```
63
34
 
64
- 2. **Install dependencies:**
35
+ Without this, browser scraping still works for most recipe sites.
65
36
 
66
- ```bash
67
- npm install
68
- ```
37
+ Optional terminal tuning:
69
38
 
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.
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
- 3. **Configure AI fallback (optional but recommended):**
44
+ ## Terminal UI
73
45
 
74
- Create a `.env.local` file in the project root:
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
- Without this, the AI fallback will not function — browser scraping will still work for most recipe sites.
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
- ## Usage
65
+ ## Troubleshooting
83
66
 
84
- ### Quick Start
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
- ```bash
87
- ./run.sh
88
- ```
74
+ ## License
89
75
 
90
- The launcher script installs dependencies automatically on first run, then starts the TUI.
76
+ MIT see [LICENSE](LICENSE).
91
77
 
92
- ### With a URL Argument
78
+ ## For Developers
93
79
 
94
- ```bash
95
- npm start -- https://www.simplyrecipes.com/recipes/perfect_guacamole/
80
+ ### Project Structure
96
81
  ```
97
-
98
- Or via the run script:
99
-
100
- ```bash
101
- ./run.sh https://www.simplyrecipes.com/recipes/perfect_guacamole/
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
- ### Interactive Mode
105
-
106
- Run without arguments and enter a URL when prompted:
97
+ ### Development Setup
107
98
 
108
99
  ```bash
109
- npm start
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
- ### Keyboard Shortcuts
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
- ## How It Works
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
- 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.
114
+ ### UI Structure
124
115
 
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.
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
- 3. **Display** — Recipe data is rendered in a bordered card with color-coded sections for times, ingredients, and instructions.
125
+ ### Tests
128
126
 
129
- ## Architecture
130
-
131
- The TUI is built with **Ink** (React for the terminal) following patterns inspired by [OpenCode](https://github.com/anomalyco/opencode):
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
- MIT see [LICENSE](LICENSE).
131
+ The test suite covers input normalization and the pure HTML/schema extraction helpers used by the scraper.
148
132
 
149
- ## Code of Conduct
133
+ ### Build & Publish
150
134
 
151
- See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
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, 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 { 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
- // Global keybinds – only active during the display phase so they
48
- // do not interfere with the text input in idle/error phases.
76
+ useEffect(() => {
77
+ return () => {
78
+ cancelActiveScrape();
79
+ };
80
+ }, [cancelActiveScrape]);
49
81
  useInput((input, key) => {
50
- if (phase === 'display') {
51
- if (input === 'n')
52
- handleNewRecipe();
53
- if (input === 'q')
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
- 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 })] }));
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
- render(_jsx(App, { initialUrl: url }));
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
- 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,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: 'submit' },
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: 'submit' },
19
+ { key: 'enter', label: 'retry' },
19
20
  { key: 'ctrl+c', label: 'exit' },
20
21
  ],
21
22
  };
22
- export function Footer({ phase }) {
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
- 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))) }));
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,7 @@
1
+ interface LandingScreenProps {
2
+ width: number;
3
+ height: number;
4
+ onSubmit: (url: string) => void;
5
+ }
6
+ export declare function LandingScreen({ width, height, onSubmit }: LandingScreenProps): import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -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,6 @@
1
+ import type { ScrapeStatus } from '../services/scraper.js';
2
+ interface LoadingScreenProps {
3
+ status?: ScrapeStatus | null;
4
+ }
5
+ export declare function LoadingScreen({ status }: LoadingScreenProps): import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -0,0 +1,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
+ }