@sambitcreate/parsely-cli 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,24 +39,34 @@ Optional terminal tuning:
39
39
  ```bash
40
40
  export PARSELY_SYNC_OUTPUT=1 # force synchronized output on
41
41
  export PARSELY_SYNC_OUTPUT=0 # force synchronized output off
42
+ export PARSELY_DISPLAY_PALETTE=1 # force terminal background palette on
43
+ export PARSELY_DISPLAY_PALETTE=0 # force terminal background palette off
44
+ export PARSELY_THEME=dark # start in dark theme
45
+ export PARSELY_THEME=light # start in light theme
42
46
  ```
43
47
 
44
48
  ## Terminal UI
45
49
 
46
50
  - Uses an Ink-based app shell instead of printing one-off output
47
51
  - Switches into the terminal alternate screen from the CLI entrypoint and restores the previous screen on exit
52
+ - Restores the terminal's default background color on exit after using the app palette
48
53
  - Reserves one terminal row so Ink can update incrementally instead of clearing the whole screen on every spinner tick
49
54
  - Adapts the layout to the current terminal size for wide and narrow viewports
50
55
  - Collapses non-essential panels on shorter terminals so the URL field stays usable
51
56
  - Cancels in-flight browser and AI scraping when you press `Ctrl+C`
52
57
  - Shows a live scraping pipeline so browser parsing and AI fallback are visible as separate stages
53
- - Enables synchronized output in Ghostty by default so frame updates paint atomically
58
+ - Detects light/dark preference on startup and applies a matching app-wide theme
59
+ - Lets you toggle the full app theme at runtime with `Ctrl+T`
60
+ - Enables synchronized output in Ghostty and WezTerm by default so frame updates paint atomically
61
+ - Applies the terminal background palette by default in Ghostty, Apple Terminal, iTerm2, WezTerm, Warp, Kitty, Alacritty, and foot
62
+ - Avoids advanced palette/sync behavior in `tmux`, `screen`, VS Code's integrated terminal, JetBrains terminals, the Linux console, and `TERM=dumb` unless you explicitly override it
54
63
 
55
64
  ## Keyboard Shortcuts
56
65
 
57
66
  | Key | Action |
58
67
  | -------- | ----------------- |
59
68
  | `Enter` | Submit URL |
69
+ | `Ctrl+T` | Toggle theme |
60
70
  | `n` | Scrape new recipe |
61
71
  | `q` | Quit |
62
72
  | `Esc` | Quit from result view |
@@ -68,7 +78,9 @@ export PARSELY_SYNC_OUTPUT=0 # force synchronized output off
68
78
  - **Browser scraping skipped** — Install Chrome or Chromium for better results
69
79
  - **No recipe found** — AI fallback handles most sites, but results vary by site
70
80
  - **Terminal looks cleared while running** — Expected; Parsely uses the alternate screen and restores your previous terminal content when it exits
71
- - **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
81
+ - **Background color does not change** — Your terminal may be outside the built-in compatibility list or behind a multiplexer. Use `PARSELY_DISPLAY_PALETTE=1` to force palette updates on, or `PARSELY_DISPLAY_PALETTE=0` to disable them entirely
82
+ - **Theme starts in the wrong mode** — Set `PARSELY_THEME=dark` or `PARSELY_THEME=light` to override automatic detection
83
+ - **Ghostty or WezTerm still flickers** — Parsely enables synchronized output automatically there; set `PARSELY_SYNC_OUTPUT=1` to force it on elsewhere or `PARSELY_SYNC_OUTPUT=0` to disable it
72
84
  - **Some sites challenge headless browsers** — Parsely now uses a more browser-like Puppeteer setup, but challenge pages can still force an AI fallback
73
85
 
74
86
  ## License
@@ -113,14 +125,16 @@ npm test
113
125
 
114
126
  ### UI Structure
115
127
 
128
+ - `LandingScreen` — centered logo and input for the idle state
129
+ - `LoadingScreen` — minimal centered status view during scraping
116
130
  - `Banner` — status-aware header with current host and app state
117
- - `Panel` — shared bordered container used across the app shell
131
+ - `Panel` — shared bordered container used across the error shell
118
132
  - `PhaseRail` — pipeline view for browser, parsing, and AI stages
119
- - `URLInput` — normalizes pasted newlines and submits on `Enter`
133
+ - `URLInput` — normalizes pasted newlines and exposes shortcut hints under the field
120
134
  - `RecipeCard` — split recipe layout with summary, ingredients, timing, and method
121
- - `Footer` — persistent status line and key hints
135
+ - `Footer` — persistent status line and key hints on non-landing screens
122
136
  - `useTerminalViewport` — terminal sizing and resize tracking
123
- - `utils/terminal.ts` — synchronized-output and render-height helpers for stable full-screen updates
137
+ - `utils/terminal.ts` — terminal compatibility detection, synchronized-output, palette control, and render-height helpers
124
138
 
125
139
  ### Tests
126
140
 
@@ -128,7 +142,7 @@ npm test
128
142
  npm test
129
143
  ```
130
144
 
131
- The test suite covers input normalization and the pure HTML/schema extraction helpers used by the scraper.
145
+ The test suite covers input normalization, schema extraction, theme-mode helpers, and terminal compatibility detection for common macOS and Linux terminals.
132
146
 
133
147
  ### Build & Publish
134
148
 
package/dist/app.js CHANGED
@@ -10,7 +10,10 @@ import { Panel } from './components/Panel.js';
10
10
  import { PhaseRail } from './components/PhaseRail.js';
11
11
  import { scrapeRecipe } from './services/scraper.js';
12
12
  import { useTerminalViewport } from './hooks/useTerminalViewport.js';
13
- import { theme } from './theme.js';
13
+ import { detectPreferredThemeMode, resolveInitialThemeMode, setActiveTheme, theme, toggleThemeMode } from './theme.js';
14
+ import { useDisplayPalette } from './hooks/useDisplayPalette.js';
15
+ import { sanitizeTerminalText } from './utils/helpers.js';
16
+ import { isThemeToggleShortcut } from './utils/shortcuts.js';
14
17
  import { getRenderableHeight } from './utils/terminal.js';
15
18
  import { LandingScreen } from './components/LandingScreen.js';
16
19
  import { LoadingScreen } from './components/LoadingScreen.js';
@@ -19,13 +22,25 @@ export function App({ initialUrl }) {
19
22
  const { width, height } = useTerminalViewport();
20
23
  const renderHeight = getRenderableHeight(height);
21
24
  const [phase, setPhase] = useState(initialUrl ? 'scraping' : 'idle');
25
+ const [themeMode, setThemeMode] = useState(() => resolveInitialThemeMode());
22
26
  const [recipe, setRecipe] = useState(null);
23
27
  const [scrapeStatus, setScrapeStatus] = useState(null);
24
28
  const [error, setError] = useState('');
25
29
  const [currentUrl, setCurrentUrl] = useState(initialUrl ?? '');
26
30
  const activeScrapeController = useRef(null);
31
+ const initialScrapeStarted = useRef(false);
32
+ const hasManualThemeOverride = useRef(false);
27
33
  const wide = width >= 112;
28
34
  const shortViewport = renderHeight < 30;
35
+ useDisplayPalette(theme.colors.recipePaper);
36
+ const applyThemeMode = useCallback((mode) => {
37
+ setActiveTheme(mode);
38
+ setThemeMode(mode);
39
+ }, []);
40
+ const handleToggleTheme = useCallback(() => {
41
+ hasManualThemeOverride.current = true;
42
+ applyThemeMode(toggleThemeMode(themeMode));
43
+ }, [applyThemeMode, themeMode]);
29
44
  const cancelActiveScrape = useCallback(() => {
30
45
  activeScrapeController.current?.abort();
31
46
  activeScrapeController.current = null;
@@ -52,7 +67,7 @@ export function App({ initialUrl }) {
52
67
  if (controller.signal.aborted || activeScrapeController.current !== controller) {
53
68
  return;
54
69
  }
55
- setError(err instanceof Error ? err.message : 'Failed to scrape recipe');
70
+ setError(err instanceof Error ? sanitizeTerminalText(err.message) : 'Failed to scrape recipe');
56
71
  setPhase('error');
57
72
  }
58
73
  finally {
@@ -69,32 +84,52 @@ export function App({ initialUrl }) {
69
84
  setCurrentUrl('');
70
85
  }, []);
71
86
  useEffect(() => {
72
- if (initialUrl) {
73
- handleScrape(initialUrl);
87
+ if (!initialUrl || initialScrapeStarted.current) {
88
+ return;
74
89
  }
75
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
90
+ initialScrapeStarted.current = true;
91
+ void handleScrape(initialUrl);
92
+ }, [handleScrape, initialUrl]);
76
93
  useEffect(() => {
77
94
  return () => {
78
95
  cancelActiveScrape();
79
96
  };
80
97
  }, [cancelActiveScrape]);
98
+ useEffect(() => {
99
+ let cancelled = false;
100
+ void detectPreferredThemeMode().then((mode) => {
101
+ if (cancelled || hasManualThemeOverride.current || mode === themeMode) {
102
+ return;
103
+ }
104
+ applyThemeMode(mode);
105
+ });
106
+ return () => {
107
+ cancelled = true;
108
+ };
109
+ }, [applyThemeMode, themeMode]);
81
110
  useInput((input, key) => {
82
111
  if (key.ctrl && input === 'c') {
83
112
  cancelActiveScrape();
84
113
  exit();
85
114
  return;
86
115
  }
116
+ if ((phase === 'display' || phase === 'scraping') && isThemeToggleShortcut(input, key)) {
117
+ handleToggleTheme();
118
+ return;
119
+ }
87
120
  if (phase === 'display' && input === 'n')
88
121
  handleNewRecipe();
89
- if (phase === 'display' && (input === 'q' || key.escape))
122
+ if (phase === 'display' && input === 'q')
90
123
  exit();
124
+ if (phase === 'display' && key.escape)
125
+ handleNewRecipe();
91
126
  }, { isActive: phase === 'display' || phase === 'scraping' || phase === 'idle' || phase === 'error' });
92
- const renderIdle = () => (_jsx(LandingScreen, { width: width, height: renderHeight, onSubmit: handleScrape }));
127
+ const renderIdle = () => (_jsx(LandingScreen, { width: width, height: renderHeight, onSubmit: handleScrape, onToggleTheme: handleToggleTheme }));
93
128
  const renderScraping = () => (_jsx(LoadingScreen, { status: scrapeStatus }));
94
- const renderDisplay = () => (_jsx(Box, { flexDirection: "column", flexGrow: 1, children: recipe && (_jsx(RecipeCard, { recipe: recipe, width: width, sourceUrl: currentUrl })) }));
95
- const renderError = () => (_jsxs(Box, { flexDirection: wide && !shortViewport ? 'row' : 'column', gap: 1, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", width: wide && !shortViewport ? '58%' : undefined, children: [_jsx(ErrorDisplay, { message: error }), _jsx(Panel, { title: "Try another URL", eyebrow: "Retry", accentColor: theme.colors.primary, children: _jsx(URLInput, { onSubmit: handleScrape }) })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(Panel, { title: "Pipeline", eyebrow: "What happened", accentColor: theme.colors.secondary, children: _jsx(PhaseRail, { phase: phase, status: scrapeStatus, recipe: recipe }) }) })] }));
129
+ const renderDisplay = () => (_jsx(Box, { flexDirection: "column", flexGrow: 1, children: recipe && (_jsx(RecipeCard, { recipe: recipe, width: width, height: renderHeight, sourceUrl: currentUrl })) }));
130
+ const renderError = () => (_jsxs(Box, { flexDirection: wide && !shortViewport ? 'row' : 'column', gap: 1, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", width: wide && !shortViewport ? '58%' : undefined, children: [_jsx(ErrorDisplay, { message: error }), _jsx(Panel, { title: "Try another URL", eyebrow: "Retry", accentColor: theme.colors.primary, children: _jsx(URLInput, { onSubmit: handleScrape, onToggleTheme: handleToggleTheme }) })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(Panel, { title: "Pipeline", eyebrow: "What happened", accentColor: theme.colors.secondary, children: _jsx(PhaseRail, { phase: phase, status: scrapeStatus, recipe: recipe }) }) })] }));
96
131
  if (phase === 'display') {
97
- return (_jsx(Box, { flexDirection: "column", width: "100%", children: renderDisplay() }));
132
+ return (_jsx(Box, { flexDirection: "column", width: "100%", height: renderHeight, children: renderDisplay() }));
98
133
  }
99
134
  if (phase === 'idle') {
100
135
  return (_jsx(Box, { flexDirection: "column", width: "100%", height: renderHeight, children: renderIdle() }));
package/dist/cli.js CHANGED
@@ -2,7 +2,8 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { render } from 'ink';
4
4
  import { App } from './app.js';
5
- import { createSynchronizedWriteProxy, shouldUseSynchronizedOutput } from './utils/terminal.js';
5
+ import { sanitizeTerminalText } from './utils/helpers.js';
6
+ import { createSynchronizedWriteProxy, resetDefaultTerminalBackground, shouldUseDisplayPalette, shouldUseSynchronizedOutput, } from './utils/terminal.js';
6
7
  const ENTER_ALT_SCREEN = '\u001B[?1049h\u001B[2J\u001B[H';
7
8
  const EXIT_ALT_SCREEN = '\u001B[?1049l';
8
9
  // Simple arg parsing – accept an optional recipe URL as the first positional arg
@@ -31,7 +32,7 @@ if (args.includes('--help') || args.includes('-h')) {
31
32
  }
32
33
  // Handle --version / -v
33
34
  if (args.includes('--version') || args.includes('-v')) {
34
- console.log('parsely-cli v2.0.0');
35
+ console.log('parsely-cli v2.2.0');
35
36
  process.exit(0);
36
37
  }
37
38
  async function main() {
@@ -50,12 +51,18 @@ async function main() {
50
51
  await instance.waitUntilExit();
51
52
  }
52
53
  finally {
54
+ if (useAltScreen && shouldUseDisplayPalette()) {
55
+ process.stdout.write(resetDefaultTerminalBackground());
56
+ }
53
57
  if (useAltScreen) {
54
58
  process.stdout.write(EXIT_ALT_SCREEN);
55
59
  }
60
+ if (useAltScreen && shouldUseDisplayPalette()) {
61
+ process.stdout.write(resetDefaultTerminalBackground());
62
+ }
56
63
  }
57
64
  }
58
65
  main().catch((error) => {
59
- console.error(error instanceof Error ? error.message : String(error));
66
+ console.error(error instanceof Error ? sanitizeTerminalText(error.message) : 'Unexpected error');
60
67
  process.exit(1);
61
68
  });
@@ -5,18 +5,22 @@ import { theme } from '../theme.js';
5
5
  const keybinds = {
6
6
  idle: [
7
7
  { key: 'enter', label: 'scrape' },
8
+ { key: 'ctrl+t', label: 'theme' },
8
9
  { key: 'ctrl+c', label: 'exit' },
9
10
  ],
10
11
  scraping: [
12
+ { key: 'ctrl+t', label: 'theme' },
11
13
  { key: 'ctrl+c', label: 'exit' },
12
14
  ],
13
15
  display: [
14
16
  { key: 'n', label: 'new recipe' },
17
+ { key: 'ctrl+t', label: 'theme' },
15
18
  { key: 'q', label: 'quit' },
16
- { key: 'esc', label: 'quit' },
19
+ { key: 'esc', label: 'back' },
17
20
  ],
18
21
  error: [
19
22
  { key: 'enter', label: 'retry' },
23
+ { key: 'ctrl+t', label: 'theme' },
20
24
  { key: 'ctrl+c', label: 'exit' },
21
25
  ],
22
26
  };
@@ -2,6 +2,7 @@ interface LandingScreenProps {
2
2
  width: number;
3
3
  height: number;
4
4
  onSubmit: (url: string) => void;
5
+ onToggleTheme?: () => void;
5
6
  }
6
- export declare function LandingScreen({ width, height, onSubmit }: LandingScreenProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function LandingScreen({ width, height, onSubmit, onToggleTheme }: LandingScreenProps): import("react/jsx-runtime").JSX.Element;
7
8
  export {};
@@ -5,8 +5,15 @@ import { readFileSync } from 'node:fs';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { URLInput } from './URLInput.js';
7
7
  import { theme } from '../theme.js';
8
- import { useDisplayPalette } from '../hooks/useDisplayPalette.js';
9
- const logoSvg = readFileSync(fileURLToPath(new URL('../../public/parsely-logo.svg', import.meta.url)), 'utf8');
8
+ function readLogoSvg() {
9
+ try {
10
+ return readFileSync(fileURLToPath(new URL('../../public/parsely-logo.svg', import.meta.url)), 'utf8');
11
+ }
12
+ catch {
13
+ return '';
14
+ }
15
+ }
16
+ const logoSvg = readLogoSvg();
10
17
  const logoColor = logoSvg.match(/fill="(#[0-9a-fA-F]{6})"/)?.[1] ?? theme.colors.brand;
11
18
  const ANSI_PATTERN = /\u001B\[[0-9;]*m/g;
12
19
  const RENDER_BOUNDS = { width: 240, height: 80 };
@@ -34,6 +41,14 @@ function stripCommonIndent(lines) {
34
41
  }
35
42
  return lines.map((line) => line.slice(indent));
36
43
  }
44
+ function buildOccurrenceKeys(items) {
45
+ const counts = new Map();
46
+ return items.map((item) => {
47
+ const count = (counts.get(item) ?? 0) + 1;
48
+ counts.set(item, count);
49
+ return `${item}-${count}`;
50
+ });
51
+ }
37
52
  function buildLandingArt() {
38
53
  const rendered = CFonts.render('Parsely', {
39
54
  font: 'block',
@@ -64,11 +79,11 @@ const compactLandingArt = {
64
79
  lines: ['PARSLEY'],
65
80
  width: 'PARSLEY'.length,
66
81
  };
67
- export function LandingScreen({ width, height, onSubmit }) {
68
- useDisplayPalette(theme.colors.recipePaper);
82
+ export function LandingScreen({ width, height, onSubmit, onToggleTheme }) {
69
83
  const art = width >= primaryLandingArt.width + 8 ? primaryLandingArt : compactLandingArt;
84
+ const artKeys = buildOccurrenceKeys(art.lines);
70
85
  const inputWidth = width >= 120 ? 54 : width >= 84 ? 46 : Math.max(28, width - 16);
71
86
  const controlsWidth = inputWidth + 8;
72
87
  const contentWidth = Math.min(width - 6, Math.max(controlsWidth, art.width));
73
- return (_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 })] }));
88
+ return (_jsx(Box, { flexDirection: "column", width: "100%", height: "100%", paddingX: 2, paddingY: 1, children: _jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", alignItems: "center", width: contentWidth, children: [_jsx(Box, { width: "100%", justifyContent: "center", marginBottom: 2, children: _jsx(Box, { flexDirection: "column", width: art.width, children: art.lines.map((line, index) => (_jsx(Text, { color: logoColor, bold: true, children: line }, artKeys[index]))) }) }), _jsx(Box, { width: controlsWidth, justifyContent: "center", children: _jsx(URLInput, { onSubmit: onSubmit, onToggleTheme: onToggleTheme, mode: "landing", width: inputWidth }) })] }) }) }));
74
89
  }
@@ -2,7 +2,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import Spinner from 'ink-spinner';
4
4
  import { theme } from '../theme.js';
5
- import { useDisplayPalette } from '../hooks/useDisplayPalette.js';
6
5
  function getLoadingCopy(status) {
7
6
  switch (status?.phase) {
8
7
  case 'parsing':
@@ -16,6 +15,5 @@ function getLoadingCopy(status) {
16
15
  }
17
16
  }
18
17
  export function LoadingScreen({ status }) {
19
- useDisplayPalette(theme.colors.recipePaper);
20
18
  return (_jsx(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { color: theme.colors.recipeText, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.recipeText, bold: true, children: getLoadingCopy(status) }) })] }) }));
21
19
  }
@@ -2,7 +2,8 @@ import type { Recipe } from '../services/scraper.js';
2
2
  interface RecipeCardProps {
3
3
  recipe: Recipe;
4
4
  width: number;
5
+ height: number;
5
6
  sourceUrl?: string;
6
7
  }
7
- export declare function RecipeCard({ recipe, width, sourceUrl }: RecipeCardProps): import("react/jsx-runtime").JSX.Element;
8
+ export declare function RecipeCard({ recipe, width, height, sourceUrl }: RecipeCardProps): import("react/jsx-runtime").JSX.Element;
8
9
  export {};
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
2
+ import { useEffect, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
3
4
  import BigText from 'ink-big-text';
4
5
  import { theme } from '../theme.js';
5
6
  import { formatMinutes, getUrlHost, isoToMinutes } from '../utils/helpers.js';
6
- import { useDisplayPalette } from '../hooks/useDisplayPalette.js';
7
+ import { wrapText } from '../utils/text-layout.js';
7
8
  function extractInstructions(recipe) {
8
9
  const raw = recipe.recipeInstructions;
9
10
  if (!raw)
@@ -87,18 +88,142 @@ function SidebarCard({ title, children, }) {
87
88
  function DetailStack({ label, value }) {
88
89
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: label }), _jsx(Text, { color: theme.colors.recipeText, wrap: "wrap", children: value })] }));
89
90
  }
90
- export function RecipeCard({ recipe, width, sourceUrl }) {
91
- useDisplayPalette(theme.colors.recipePaper);
91
+ function buildCompactHeaderLines(recipe, sourceHost, contentWidth) {
92
+ return [
93
+ 'View original recipe',
94
+ sourceHost,
95
+ '',
96
+ ...wrapText(recipe.name ?? 'Untitled recipe', contentWidth),
97
+ '',
98
+ `Prep Time ${formatTimeValue(recipe.prepTime)}`,
99
+ `Cook Time ${formatTimeValue(recipe.cookTime)}`,
100
+ `Total Time ${formatTimeValue(recipe.totalTime)}`,
101
+ '',
102
+ ];
103
+ }
104
+ function buildCompactBodyLines(recipe, sourceHost, sourceLabel, sourceCopy, contentWidth, ingredients, instructions) {
105
+ const lines = [];
106
+ lines.push('Ingredients');
107
+ lines.push(buildRule(contentWidth, contentWidth));
108
+ lines.push('');
109
+ if (ingredients.length === 0) {
110
+ lines.push('No ingredients were detected.');
111
+ }
112
+ else {
113
+ for (const ingredient of ingredients) {
114
+ lines.push(...wrapText(ingredient, contentWidth, '□ ', ' '));
115
+ lines.push('');
116
+ }
117
+ }
118
+ lines.push('Instructions');
119
+ lines.push(buildRule(contentWidth, contentWidth));
120
+ lines.push('');
121
+ if (instructions.length === 0) {
122
+ lines.push('No instructions were detected.');
123
+ }
124
+ else {
125
+ instructions.forEach((step, index) => {
126
+ const marker = `${String(index + 1).padStart(2, '0')} `;
127
+ lines.push(...wrapText(step, contentWidth, marker, ' '));
128
+ lines.push('');
129
+ });
130
+ }
131
+ lines.push('Recipe brief');
132
+ lines.push(buildRule(contentWidth, contentWidth));
133
+ lines.push(...wrapText(`Source: ${sourceLabel}`, contentWidth));
134
+ lines.push(...wrapText(`Origin: ${sourceHost}`, contentWidth));
135
+ lines.push(...wrapText(`Status: ${sourceCopy}`, contentWidth));
136
+ return lines;
137
+ }
138
+ function buildCompactFooter(width, scrollOffset, maxScroll) {
139
+ const location = maxScroll > 0 ? `${scrollOffset + 1}/${maxScroll + 1}` : '1/1';
140
+ if (width >= 96) {
141
+ return `${location} ${theme.symbols.dot} ↑↓ scroll ${theme.symbols.dot} pgup/pgdn jump ${theme.symbols.dot} ctrl+t theme ${theme.symbols.dot} esc back ${theme.symbols.dot} n new ${theme.symbols.dot} q quit`;
142
+ }
143
+ if (width >= 58) {
144
+ return `${location} ${theme.symbols.dot} ↑↓ scroll ${theme.symbols.dot} ctrl+t theme ${theme.symbols.dot} esc back`;
145
+ }
146
+ return `ctrl+t theme ${theme.symbols.dot} esc back`;
147
+ }
148
+ function clamp(value, min, max) {
149
+ return Math.max(min, Math.min(value, max));
150
+ }
151
+ export function RecipeCard({ recipe, width, height, sourceUrl }) {
152
+ const [scrollOffset, setScrollOffset] = useState(0);
92
153
  const instructions = extractInstructions(recipe);
93
154
  const ingredients = recipe.recipeIngredient ?? [];
94
155
  const ingredientKeys = buildOccurrenceKeys(ingredients);
95
156
  const instructionKeys = buildOccurrenceKeys(instructions);
96
- const wide = width >= 124;
97
- const splitContent = width >= 96;
98
- const compact = width < 82;
99
157
  const sourceHost = getUrlHost(sourceUrl) || 'original page';
100
158
  const sourceLabel = recipe.source === 'browser' ? 'Page schema' : 'AI rescue';
101
159
  const sourceCopy = recipe.source === 'browser' ? 'Recovered from page schema' : 'Recovered with AI rescue';
160
+ const constrained = width < 110 || height < 34;
161
+ const compactContentWidth = Math.max(24, width - 4);
162
+ const compactHeaderLines = buildCompactHeaderLines(recipe, sourceHost, compactContentWidth);
163
+ const compactBodyLines = buildCompactBodyLines(recipe, sourceHost, sourceLabel, sourceCopy, compactContentWidth, ingredients, instructions);
164
+ const compactBodyHeight = Math.max(4, height - compactHeaderLines.length - 2);
165
+ const maxScroll = Math.max(0, compactBodyLines.length - compactBodyHeight);
166
+ const visibleBodyLines = compactBodyLines.slice(scrollOffset, scrollOffset + compactBodyHeight);
167
+ const compactHeaderKeys = buildOccurrenceKeys(compactHeaderLines);
168
+ const compactBodyKeys = buildOccurrenceKeys(compactBodyLines);
169
+ useEffect(() => {
170
+ setScrollOffset((current) => clamp(current, 0, maxScroll));
171
+ }, [maxScroll]);
172
+ useInput((input, key) => {
173
+ if (!constrained) {
174
+ return;
175
+ }
176
+ if (key.upArrow || input === 'k') {
177
+ setScrollOffset((current) => clamp(current - 1, 0, maxScroll));
178
+ return;
179
+ }
180
+ if (key.downArrow || input === 'j') {
181
+ setScrollOffset((current) => clamp(current + 1, 0, maxScroll));
182
+ return;
183
+ }
184
+ if (key.pageUp) {
185
+ setScrollOffset((current) => clamp(current - compactBodyHeight, 0, maxScroll));
186
+ return;
187
+ }
188
+ if (key.pageDown || input === ' ') {
189
+ setScrollOffset((current) => clamp(current + compactBodyHeight, 0, maxScroll));
190
+ }
191
+ }, { isActive: constrained });
192
+ if (constrained) {
193
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", paddingX: 1, children: [_jsx(Box, { flexDirection: "column", children: compactHeaderLines.map((line, index) => {
194
+ if (line === '') {
195
+ return _jsx(Text, { children: " " }, compactHeaderKeys[index]);
196
+ }
197
+ if (index === 0) {
198
+ return (_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: line }, compactHeaderKeys[index]));
199
+ }
200
+ if (index === 1) {
201
+ return (_jsx(Text, { color: theme.colors.recipeSubtle, children: line }, compactHeaderKeys[index]));
202
+ }
203
+ if (index >= compactHeaderLines.length - 4 && line.includes('Time')) {
204
+ return (_jsx(Text, { color: theme.colors.recipeText, bold: true, children: line }, compactHeaderKeys[index]));
205
+ }
206
+ return (_jsx(Text, { color: theme.colors.recipeText, bold: true, wrap: "wrap", children: line }, compactHeaderKeys[index]));
207
+ }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: visibleBodyLines.map((line, index) => {
208
+ const bodyKey = compactBodyKeys[scrollOffset + index];
209
+ const trimmed = line.trim();
210
+ if (!trimmed) {
211
+ return _jsx(Text, { children: " " }, bodyKey);
212
+ }
213
+ const heading = trimmed === 'Ingredients' || trimmed === 'Instructions' || trimmed === 'Recipe brief';
214
+ const rule = trimmed === buildRule(compactContentWidth, compactContentWidth);
215
+ return (_jsx(Text, { color: heading
216
+ ? theme.colors.recipeBorder
217
+ : rule
218
+ ? theme.colors.recipeSoft
219
+ : line.startsWith('□ ') || /^\d{2}\s/u.test(line)
220
+ ? theme.colors.recipeText
221
+ : theme.colors.recipeSubtle, bold: heading || line.startsWith('□ ') || /^\d{2}\s/u.test(line), children: line }, bodyKey));
222
+ }) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: theme.colors.recipeMuted, children: buildCompactFooter(width, scrollOffset, maxScroll) }), _jsx(Text, { color: theme.colors.recipePaper, children: " " })] })] }));
223
+ }
224
+ const wide = width >= 124;
225
+ const splitContent = width >= 96;
226
+ const compact = width < 82;
102
227
  const titleLineCount = width >= 132 ? 2 : 3;
103
228
  const titleChars = Math.max(16, Math.ceil((recipe.name ?? 'Untitled recipe').length / titleLineCount) + 4);
104
229
  const titleLines = splitTitle(recipe.name ?? 'Untitled recipe', titleChars, titleLineCount);
@@ -106,6 +231,6 @@ export function RecipeCard({ recipe, width, sourceUrl }) {
106
231
  const sectionRule = buildRule(Math.floor(width * (wide ? 0.5 : 0.82)), 58);
107
232
  const sidebarWidth = wide ? 30 : '100%';
108
233
  const mainWidth = wide ? '68%' : '100%';
109
- const showBigTitle = width >= 110;
110
- return (_jsx(Box, { flexDirection: "column", width: "100%", paddingX: compact ? 1 : 2, paddingY: 1, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: "View original recipe" }), _jsx(Text, { color: theme.colors.recipeSubtle, children: sourceHost }), showBigTitle ? (_jsx(Box, { marginTop: 1, flexDirection: "column", children: titleLines.map((line) => (_jsx(BigText, { text: line, font: "tiny", colors: [theme.colors.recipeText], lineHeight: 0 }, line))) })) : (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: theme.colors.recipeText, bold: true, wrap: "wrap", children: recipe.name ?? 'Untitled recipe' }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.recipeBorder, children: heroRule }) }), _jsxs(Box, { flexDirection: compact ? 'column' : 'row', marginTop: 1, marginBottom: 2, children: [_jsx(Metric, { label: "Prep Time", value: formatTimeValue(recipe.prepTime) }), _jsx(Metric, { label: "Cook Time", value: formatTimeValue(recipe.cookTime) }), _jsx(Metric, { label: "Total Time", value: formatTimeValue(recipe.totalTime) })] }), _jsxs(Box, { flexDirection: wide ? 'row' : 'column', gap: 3, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, width: mainWidth, children: [_jsxs(Box, { flexDirection: splitContent ? 'row' : 'column', gap: 3, alignItems: "flex-end", children: [_jsxs(Box, { width: splitContent ? '38%' : '100%', children: [_jsx(Text, { color: theme.colors.recipeBorder, bold: true, children: "Ingredients" }), _jsx(Text, { color: theme.colors.recipeBorder, children: buildRule(16, 16) })] }), _jsxs(Box, { width: splitContent ? '62%' : '100%', children: [_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: "Instructions" }), _jsx(Text, { color: theme.colors.recipeSoft, children: buildRule(22, 22) })] })] }), _jsx(Text, { color: theme.colors.recipeBorder, children: sectionRule }), _jsxs(Box, { flexDirection: splitContent ? 'row' : 'column', gap: 4, marginTop: 1, flexGrow: 1, children: [_jsx(Box, { flexDirection: "column", width: splitContent ? '38%' : '100%', children: ingredients.length > 0 ? (ingredients.map((item, index) => (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.colors.recipeBorder, bold: true, children: "\u25A1" }), _jsx(Text, { color: theme.colors.recipeText, children: " " }), _jsx(Text, { color: theme.colors.recipeText, bold: true, wrap: "wrap", children: item })] }, ingredientKeys[index])))) : (_jsx(Text, { color: theme.colors.recipeMuted, children: "No ingredients were detected." })) }), _jsx(Box, { flexDirection: "column", width: splitContent ? '62%' : '100%', children: instructions.length > 0 ? (instructions.map((step, index) => (_jsxs(Box, { marginBottom: 1, flexDirection: "row", children: [_jsx(Box, { width: 4, children: _jsx(Text, { color: theme.colors.recipeBorder, bold: true, children: String(index + 1).padStart(2, '0') }) }), _jsx(Text, { color: theme.colors.recipeSubtle, wrap: "wrap", children: step })] }, instructionKeys[index])))) : (_jsx(Text, { color: theme.colors.recipeMuted, children: "No instructions were detected." })) })] })] }), _jsxs(Box, { flexDirection: "column", width: sidebarWidth, minWidth: wide ? 30 : undefined, marginTop: wide ? 2 : 0, children: [_jsxs(SidebarCard, { title: "Recipe brief", children: [_jsx(DetailStack, { label: "Source", value: sourceLabel }), _jsx(DetailStack, { label: "Origin", value: sourceHost }), _jsx(DetailStack, { label: "Status", value: sourceCopy })] }), _jsxs(SidebarCard, { title: "Kitchen rhythm", children: [_jsx(DetailStack, { label: "Ingredients", value: String(ingredients.length) }), _jsx(DetailStack, { label: "Steps", value: String(instructions.length) }), _jsx(DetailStack, { label: "Timeline", value: formatTimeValue(recipe.totalTime) })] }), _jsxs(SidebarCard, { title: "Next actions", children: [_jsxs(Text, { color: theme.colors.recipeText, bold: true, children: ["n", _jsx(Text, { color: theme.colors.recipeMuted, children: " new recipe" })] }), _jsxs(Text, { color: theme.colors.recipeText, bold: true, children: ["q", _jsx(Text, { color: theme.colors.recipeMuted, children: " quit" })] }), _jsxs(Text, { color: theme.colors.recipeText, bold: true, children: ["esc", _jsx(Text, { color: theme.colors.recipeMuted, children: " back" })] })] })] })] })] }) }));
234
+ const showBigTitle = width >= 110 && height >= 38;
235
+ return (_jsx(Box, { flexDirection: "column", width: "100%", height: "100%", paddingX: compact ? 1 : 2, paddingY: 1, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: "View original recipe" }), _jsx(Text, { color: theme.colors.recipeSubtle, children: sourceHost }), showBigTitle ? (_jsx(Box, { marginTop: 1, flexDirection: "column", children: titleLines.map((line) => (_jsx(BigText, { text: line, font: "tiny", colors: [theme.colors.recipeText], lineHeight: 0 }, line))) })) : (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: theme.colors.recipeText, bold: true, wrap: "wrap", children: recipe.name ?? 'Untitled recipe' }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.recipeBorder, children: heroRule }) }), _jsxs(Box, { flexDirection: compact ? 'column' : 'row', marginTop: 1, marginBottom: 2, children: [_jsx(Metric, { label: "Prep Time", value: formatTimeValue(recipe.prepTime) }), _jsx(Metric, { label: "Cook Time", value: formatTimeValue(recipe.cookTime) }), _jsx(Metric, { label: "Total Time", value: formatTimeValue(recipe.totalTime) })] }), _jsxs(Box, { flexDirection: wide ? 'row' : 'column', gap: 3, flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, width: mainWidth, children: [_jsxs(Box, { flexDirection: splitContent ? 'row' : 'column', gap: 3, alignItems: "flex-end", children: [_jsxs(Box, { width: splitContent ? '38%' : '100%', children: [_jsx(Text, { color: theme.colors.recipeBorder, bold: true, children: "Ingredients" }), _jsx(Text, { color: theme.colors.recipeBorder, children: buildRule(16, 16) })] }), _jsxs(Box, { width: splitContent ? '62%' : '100%', children: [_jsx(Text, { color: theme.colors.recipeMuted, bold: true, children: "Instructions" }), _jsx(Text, { color: theme.colors.recipeSoft, children: buildRule(22, 22) })] })] }), _jsx(Text, { color: theme.colors.recipeBorder, children: sectionRule }), _jsxs(Box, { flexDirection: splitContent ? 'row' : 'column', gap: 4, marginTop: 1, flexGrow: 1, children: [_jsx(Box, { flexDirection: "column", width: splitContent ? '38%' : '100%', children: ingredients.length > 0 ? (ingredients.map((item, index) => (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.colors.recipeBorder, bold: true, children: "\u25A1" }), _jsx(Text, { color: theme.colors.recipeText, children: " " }), _jsx(Text, { color: theme.colors.recipeText, bold: true, wrap: "wrap", children: item })] }, ingredientKeys[index])))) : (_jsx(Text, { color: theme.colors.recipeMuted, children: "No ingredients were detected." })) }), _jsx(Box, { flexDirection: "column", width: splitContent ? '62%' : '100%', children: instructions.length > 0 ? (instructions.map((step, index) => (_jsxs(Box, { marginBottom: 1, flexDirection: "row", children: [_jsx(Box, { width: 4, children: _jsx(Text, { color: theme.colors.recipeBorder, bold: true, children: String(index + 1).padStart(2, '0') }) }), _jsx(Text, { color: theme.colors.recipeSubtle, wrap: "wrap", children: step })] }, instructionKeys[index])))) : (_jsx(Text, { color: theme.colors.recipeMuted, children: "No instructions were detected." })) })] })] }), _jsxs(Box, { flexDirection: "column", width: sidebarWidth, minWidth: wide ? 30 : undefined, marginTop: wide ? 2 : 0, children: [_jsxs(SidebarCard, { title: "Recipe brief", children: [_jsx(DetailStack, { label: "Source", value: sourceLabel }), _jsx(DetailStack, { label: "Origin", value: sourceHost }), _jsx(DetailStack, { label: "Status", value: sourceCopy })] }), _jsxs(SidebarCard, { title: "Kitchen rhythm", children: [_jsx(DetailStack, { label: "Ingredients", value: String(ingredients.length) }), _jsx(DetailStack, { label: "Steps", value: String(instructions.length) }), _jsx(DetailStack, { label: "Timeline", value: formatTimeValue(recipe.totalTime) })] }), _jsxs(SidebarCard, { title: "Next actions", children: [_jsxs(Text, { color: theme.colors.recipeText, bold: true, children: ["n", _jsx(Text, { color: theme.colors.recipeMuted, children: " new recipe" })] }), _jsxs(Text, { color: theme.colors.recipeText, bold: true, children: ["ctrl+t", _jsx(Text, { color: theme.colors.recipeMuted, children: " toggle theme" })] }), _jsxs(Text, { color: theme.colors.recipeText, bold: true, children: ["q", _jsx(Text, { color: theme.colors.recipeMuted, children: " quit" })] }), _jsxs(Text, { color: theme.colors.recipeText, bold: true, children: ["esc", _jsx(Text, { color: theme.colors.recipeMuted, children: " back" })] })] })] })] })] }) }));
111
236
  }
@@ -1,7 +1,8 @@
1
1
  interface URLInputProps {
2
2
  onSubmit: (url: string) => void;
3
+ onToggleTheme?: () => void;
3
4
  mode?: 'default' | 'landing';
4
5
  width?: number;
5
6
  }
6
- export declare function URLInput({ onSubmit, mode, width }: URLInputProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function URLInput({ onSubmit, onToggleTheme, mode, width }: URLInputProps): import("react/jsx-runtime").JSX.Element;
7
8
  export {};
@@ -1,14 +1,19 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
3
- import { Box, Text } from 'ink';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Box, Text, useStdin } from 'ink';
4
4
  import TextInput from 'ink-text-input';
5
5
  import { theme } from '../theme.js';
6
6
  import { normalizeRecipeUrl, sanitizeSingleLineInput } from '../utils/helpers.js';
7
- export function URLInput({ onSubmit, mode = 'default', width }) {
7
+ export function URLInput({ onSubmit, onToggleTheme, mode = 'default', width }) {
8
8
  const [value, setValue] = useState('');
9
9
  const [error, setError] = useState('');
10
+ const ignoreNextChange = useRef(false);
11
+ const { stdin } = useStdin();
10
12
  const landing = mode === 'landing';
11
13
  const landingButtonLabel = ' Go ';
14
+ const shortcutCopy = width && width < 40
15
+ ? 'ctrl+c exit'
16
+ : 'ctrl+c exit · ctrl+t theme';
12
17
  const handleSubmit = (input) => {
13
18
  const url = normalizeRecipeUrl(input);
14
19
  if (!url) {
@@ -24,13 +29,31 @@ export function URLInput({ onSubmit, mode = 'default', width }) {
24
29
  onSubmit(url);
25
30
  };
26
31
  const handleChange = (nextValue) => {
32
+ if (ignoreNextChange.current) {
33
+ ignoreNextChange.current = false;
34
+ return;
35
+ }
27
36
  const sanitized = sanitizeSingleLineInput(nextValue);
28
37
  setValue(sanitized);
29
38
  if (error)
30
39
  setError('');
31
- if (sanitized !== nextValue && sanitized.trim()) {
32
- handleSubmit(sanitized);
33
- }
34
40
  };
35
- return (_jsxs(Box, { flexDirection: "column", children: [!landing && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.muted, children: "Paste a recipe URL or type a hostname. Parsely will add `https://` when needed." }) })), _jsxs(Box, { alignItems: "center", children: [_jsxs(Box, { borderStyle: "round", borderColor: landing ? theme.colors.brand : theme.colors.borderFocus, width: width, paddingX: 1, paddingY: 0, children: [!landing && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.colors.primary, bold: true, children: "URL" }), _jsx(Text, { color: theme.colors.muted, children: " " })] })), _jsx(TextInput, { value: value, focus: true, onChange: handleChange, onSubmit: handleSubmit, placeholder: landing ? 'Paste recipe link here' : 'Enter recipe URL...' })] }), landing && (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { backgroundColor: theme.colors.brand, color: theme.colors.recipePaper, bold: true, children: landingButtonLabel }) }))] }), !landing && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.muted, children: "Press enter to scrape. Try a specific recipe page, not a site homepage." }) })), error && (_jsx(Box, { marginLeft: landing ? 0 : 2, marginTop: 1, justifyContent: landing ? 'center' : undefined, children: _jsxs(Text, { color: theme.colors.error, children: [theme.symbols.cross, " ", error] }) }))] }));
41
+ useEffect(() => {
42
+ if (!onToggleTheme) {
43
+ return;
44
+ }
45
+ const handleData = (data) => {
46
+ const chunk = typeof data === 'string' ? data : data.toString('utf8');
47
+ if (!chunk.includes('\u0014')) {
48
+ return;
49
+ }
50
+ ignoreNextChange.current = true;
51
+ onToggleTheme();
52
+ };
53
+ stdin.on('data', handleData);
54
+ return () => {
55
+ stdin.off('data', handleData);
56
+ };
57
+ }, [onToggleTheme, stdin]);
58
+ return (_jsxs(Box, { flexDirection: "column", children: [!landing && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.muted, children: "Paste a recipe URL or type a hostname. Parsely will add `https://` when needed." }) })), _jsxs(Box, { alignItems: "center", children: [_jsxs(Box, { borderStyle: "round", borderColor: landing ? theme.colors.brand : theme.colors.borderFocus, width: width, paddingX: 1, paddingY: 0, children: [!landing && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.colors.primary, bold: true, children: "URL" }), _jsx(Text, { color: theme.colors.muted, children: " " })] })), _jsx(TextInput, { value: value, focus: true, onChange: handleChange, onSubmit: handleSubmit, placeholder: landing ? 'Paste recipe link here' : 'Enter recipe URL...' })] }), landing && (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { backgroundColor: theme.colors.brand, color: theme.colors.recipePaper, bold: true, children: landingButtonLabel }) }))] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [!landing && (_jsx(Text, { color: theme.colors.muted, children: "Press enter to scrape. Try a specific recipe page, not a site homepage." })), _jsx(Box, { justifyContent: landing ? 'center' : 'flex-end', children: _jsx(Text, { color: theme.colors.muted, children: shortcutCopy }) })] }), error && (_jsx(Box, { marginLeft: landing ? 0 : 2, marginTop: 1, justifyContent: landing ? 'center' : undefined, children: _jsxs(Text, { color: theme.colors.error, children: [theme.symbols.cross, " ", error] }) }))] }));
36
59
  }
@@ -24,6 +24,7 @@ export interface ScrapeStatus {
24
24
  */
25
25
  export declare function findRecipeJson(scripts: string[]): Record<string, unknown> | null;
26
26
  export declare function containsBrowserChallenge(html: string): boolean;
27
+ export declare function normalizeAiRecipe(recipe: Record<string, unknown>): Recipe;
27
28
  export declare function extractRecipeFromHtml(html: string): Recipe | null;
28
29
  /**
29
30
  * Scrape a recipe from the given URL.